mirror of
https://github.com/microsoft/PowerToys.git
synced 2026-04-03 09:46:54 +02:00
CmdPal: Lightning-fast mode (#45764)
## Summary of the Pull Request
This PR unlocks lightning-fast mode for Command Palette:
- Hides visual and motion distractions when updating the result list:
- Ensures the first interactable result item is selected as early as
possible after the result list is updated, reducing flashing and
blinking caused by the selection highlight moving around.
- Removes the list item selection indicator animation (unfortunately by
removing the pill altogether for now) and prevents it from temporarily
appearing on other items as the selection moves.
- Adds a new "Results" section header above the home page results when
no other section is present.
- This ensures the first item on the home page has consistent visuals
and styling, preventing offsets and excessive visual changes when
elements are replaced in place.
- Improves update performance and container reuse:
- Fixes the `removed` output parameter in `ListHelper.UpdateInPlace` to
only include items that were actually removed (items that were merely
moved to a different position should not be reported as removed).
- Adds unit tests to prevent regression.
- Updates `ListHelper.UpdateInPlace` for `ObservableCollection` to use
`Move` instead of `Remove`/`Add`, and avoids `Clear` to prevent
`ListView` resets (which force recreation of all item containers and are
expensive).
- Adds a simple cache for list page item view models to reduce
unnecessary recreation during forward incremental search.
- `ListViewModel` and `FetchItems` have no notion of item lifetime or
incremental search phase, so the cache intentionally remains simple
rather than clever.
- Updates ListPage templates to make them a little lighter:
- Tag template uses OneTime, instead of OneWay - since Tag is immutable
- Replaces ItemsControl with ItemsRepeater for Tag list on list items
- Increases the debounce for showing the details pane and adds a
debounce for hiding it. This improves performance when browsing the list
and prevents the details pane animation from bouncing left and right
## Pictures? Moving!
https://github.com/user-attachments/assets/36428d20-cf46-4321-83c0-d94d6d4a2299
<!-- Please review the items on the PR checklist before submitting-->
## PR Checklist
- [x] Closes: #44407
- [x] Closes: #45691
This commit is contained in:
@@ -36,6 +36,11 @@ public sealed partial class MainListPage : DynamicListPage,
|
|||||||
private readonly ScoringFunction<IListItem> _fallbackScoringFunction;
|
private readonly ScoringFunction<IListItem> _fallbackScoringFunction;
|
||||||
private readonly IFuzzyMatcherProvider _fuzzyMatcherProvider;
|
private readonly IFuzzyMatcherProvider _fuzzyMatcherProvider;
|
||||||
|
|
||||||
|
// Stable separator instances so that the VM cache and InPlaceUpdateList
|
||||||
|
// recognise them across successive GetItems() calls
|
||||||
|
private readonly Separator _resultsSeparator = new(Resources.results);
|
||||||
|
private readonly Separator _fallbacksSeparator = new(Resources.fallbacks);
|
||||||
|
|
||||||
private RoScored<IListItem>[]? _filteredItems;
|
private RoScored<IListItem>[]? _filteredItems;
|
||||||
private RoScored<IListItem>[]? _filteredApps;
|
private RoScored<IListItem>[]? _filteredApps;
|
||||||
|
|
||||||
@@ -171,9 +176,40 @@ public sealed partial class MainListPage : DynamicListPage,
|
|||||||
// filtered results.
|
// filtered results.
|
||||||
if (string.IsNullOrWhiteSpace(SearchText))
|
if (string.IsNullOrWhiteSpace(SearchText))
|
||||||
{
|
{
|
||||||
return _tlcManager.TopLevelCommands
|
var allCommands = _tlcManager.TopLevelCommands;
|
||||||
.Where(tlc => !tlc.IsFallback && !string.IsNullOrEmpty(tlc.Title))
|
|
||||||
.ToArray();
|
// First pass: count eligible commands
|
||||||
|
var eligibleCount = 0;
|
||||||
|
for (var i = 0; i < allCommands.Count; i++)
|
||||||
|
{
|
||||||
|
var cmd = allCommands[i];
|
||||||
|
if (!cmd.IsFallback && !string.IsNullOrEmpty(cmd.Title))
|
||||||
|
{
|
||||||
|
eligibleCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (eligibleCount == 0)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// +1 for the separator
|
||||||
|
var result = new IListItem[eligibleCount + 1];
|
||||||
|
result[0] = _resultsSeparator;
|
||||||
|
|
||||||
|
// Second pass: populate
|
||||||
|
var writeIndex = 1;
|
||||||
|
for (var i = 0; i < allCommands.Count; i++)
|
||||||
|
{
|
||||||
|
var cmd = allCommands[i];
|
||||||
|
if (!cmd.IsFallback && !string.IsNullOrEmpty(cmd.Title))
|
||||||
|
{
|
||||||
|
result[writeIndex++] = cmd;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -190,6 +226,8 @@ public sealed partial class MainListPage : DynamicListPage,
|
|||||||
validScoredFallbacks,
|
validScoredFallbacks,
|
||||||
_filteredApps,
|
_filteredApps,
|
||||||
validFallbacks,
|
validFallbacks,
|
||||||
|
_resultsSeparator,
|
||||||
|
_fallbacksSeparator,
|
||||||
AppResultLimit);
|
AppResultLimit);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ internal static class MainListPageResultFactory
|
|||||||
IList<RoScored<IListItem>>? scoredFallbackItems,
|
IList<RoScored<IListItem>>? scoredFallbackItems,
|
||||||
IList<RoScored<IListItem>>? filteredApps,
|
IList<RoScored<IListItem>>? filteredApps,
|
||||||
IList<RoScored<IListItem>>? fallbackItems,
|
IList<RoScored<IListItem>>? fallbackItems,
|
||||||
|
IListItem resultsSeparator,
|
||||||
|
IListItem fallbacksSeparator,
|
||||||
int appResultLimit)
|
int appResultLimit)
|
||||||
{
|
{
|
||||||
if (appResultLimit < 0)
|
if (appResultLimit < 0)
|
||||||
@@ -40,8 +42,13 @@ internal static class MainListPageResultFactory
|
|||||||
int nonEmptyFallbackCount = fallbackItems?.Count ?? 0;
|
int nonEmptyFallbackCount = fallbackItems?.Count ?? 0;
|
||||||
|
|
||||||
// Allocate the exact size of the result array.
|
// Allocate the exact size of the result array.
|
||||||
// We'll add an extra slot for the fallbacks section header if needed.
|
// We'll add an extra slot for the fallbacks section header if needed,
|
||||||
int totalCount = len1 + len2 + len3 + nonEmptyFallbackCount + (nonEmptyFallbackCount > 0 ? 1 : 0);
|
// and another for the "Results" section header when merged results exist.
|
||||||
|
int mergedCount = len1 + len2 + len3;
|
||||||
|
bool needsResultsHeader = mergedCount > 0;
|
||||||
|
int totalCount = mergedCount + nonEmptyFallbackCount
|
||||||
|
+ (needsResultsHeader ? 1 : 0)
|
||||||
|
+ (nonEmptyFallbackCount > 0 ? 1 : 0);
|
||||||
|
|
||||||
var result = new IListItem[totalCount];
|
var result = new IListItem[totalCount];
|
||||||
|
|
||||||
@@ -49,6 +56,12 @@ internal static class MainListPageResultFactory
|
|||||||
int idx1 = 0, idx2 = 0, idx3 = 0;
|
int idx1 = 0, idx2 = 0, idx3 = 0;
|
||||||
int writePos = 0;
|
int writePos = 0;
|
||||||
|
|
||||||
|
// Add "Results" section header when merged results will precede the fallbacks.
|
||||||
|
if (needsResultsHeader)
|
||||||
|
{
|
||||||
|
result[writePos++] = resultsSeparator;
|
||||||
|
}
|
||||||
|
|
||||||
// Merge while all three lists have items. To maintain a stable sort, the
|
// Merge while all three lists have items. To maintain a stable sort, the
|
||||||
// priority is: list1 > list2 > list3 when scores are equal.
|
// priority is: list1 > list2 > list3 when scores are equal.
|
||||||
while (idx1 < len1 && idx2 < len2 && idx3 < len3)
|
while (idx1 < len1 && idx2 < len2 && idx3 < len3)
|
||||||
@@ -132,7 +145,7 @@ internal static class MainListPageResultFactory
|
|||||||
// Create the fallbacks section header
|
// Create the fallbacks section header
|
||||||
if (fallbackItems.Count > 0)
|
if (fallbackItems.Count > 0)
|
||||||
{
|
{
|
||||||
result[writePos++] = new Separator(Properties.Resources.fallbacks);
|
result[writePos++] = fallbacksSeparator;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (int i = 0; i < fallbackItems.Count; i++)
|
for (int i = 0; i < fallbackItems.Count; i++)
|
||||||
|
|||||||
@@ -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.
|
// See the LICENSE file in the project root for more information.
|
||||||
|
|
||||||
using System.Collections.ObjectModel;
|
using System.Collections.ObjectModel;
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
using CommunityToolkit.Mvvm.Input;
|
using CommunityToolkit.Mvvm.Input;
|
||||||
using CommunityToolkit.Mvvm.Messaging;
|
using CommunityToolkit.Mvvm.Messaging;
|
||||||
|
using Microsoft.CmdPal.Common;
|
||||||
using Microsoft.CmdPal.Common.Helpers;
|
using Microsoft.CmdPal.Common.Helpers;
|
||||||
|
using Microsoft.CmdPal.Core.ViewModels;
|
||||||
using Microsoft.CmdPal.UI.ViewModels.Messages;
|
using Microsoft.CmdPal.UI.ViewModels.Messages;
|
||||||
using Microsoft.CmdPal.UI.ViewModels.Models;
|
using Microsoft.CmdPal.UI.ViewModels.Models;
|
||||||
using Microsoft.CommandPalette.Extensions;
|
using Microsoft.CommandPalette.Extensions;
|
||||||
@@ -16,9 +19,10 @@ namespace Microsoft.CmdPal.UI.ViewModels;
|
|||||||
|
|
||||||
public partial class ListViewModel : PageViewModel, IDisposable
|
public partial class ListViewModel : PageViewModel, IDisposable
|
||||||
{
|
{
|
||||||
// private readonly HashSet<ListItemViewModel> _itemCache = [];
|
|
||||||
private readonly TaskFactory filterTaskFactory = new(new ConcurrentExclusiveSchedulerPair().ExclusiveScheduler);
|
private readonly TaskFactory filterTaskFactory = new(new ConcurrentExclusiveSchedulerPair().ExclusiveScheduler);
|
||||||
|
|
||||||
|
private readonly Dictionary<IListItem, ListItemViewModel> _vmCache = new(new ProxyReferenceEqualityComparer());
|
||||||
|
|
||||||
// TODO: Do we want a base "ItemsPageViewModel" for anything that's going to have items?
|
// TODO: Do we want a base "ItemsPageViewModel" for anything that's going to have items?
|
||||||
|
|
||||||
// Observable from MVVM Toolkit will auto create public properties that use INotifyPropertyChange change
|
// Observable from MVVM Toolkit will auto create public properties that use INotifyPropertyChange change
|
||||||
@@ -37,7 +41,7 @@ public partial class ListViewModel : PageViewModel, IDisposable
|
|||||||
private InterlockedBoolean _isLoading;
|
private InterlockedBoolean _isLoading;
|
||||||
private bool _isFetching;
|
private bool _isFetching;
|
||||||
|
|
||||||
public event TypedEventHandler<ListViewModel, object>? ItemsUpdated;
|
public event TypedEventHandler<ListViewModel, ItemsUpdatedEventArgs>? ItemsUpdated;
|
||||||
|
|
||||||
public bool ShowEmptyContent =>
|
public bool ShowEmptyContent =>
|
||||||
IsInitialized &&
|
IsInitialized &&
|
||||||
@@ -80,6 +84,9 @@ public partial class ListViewModel : PageViewModel, IDisposable
|
|||||||
|
|
||||||
private ListItemViewModel? _lastSelectedItem;
|
private ListItemViewModel? _lastSelectedItem;
|
||||||
|
|
||||||
|
// For cancelling a deferred SafeSlowInit when the user navigates rapidly
|
||||||
|
private CancellationTokenSource? _selectedItemCts;
|
||||||
|
|
||||||
public override bool IsInitialized
|
public override bool IsInitialized
|
||||||
{
|
{
|
||||||
get => base.IsInitialized; protected set
|
get => base.IsInitialized; protected set
|
||||||
@@ -113,10 +120,6 @@ public partial class ListViewModel : PageViewModel, IDisposable
|
|||||||
|
|
||||||
protected override void OnSearchTextBoxUpdated(string searchTextBox)
|
protected override void OnSearchTextBoxUpdated(string searchTextBox)
|
||||||
{
|
{
|
||||||
//// TODO: Just temp testing, need to think about where we want to filter, as AdvancedCollectionView in View could be done, but then grouping need CollectionViewSource, maybe we do grouping in view
|
|
||||||
//// and manage filtering below, but we should be smarter about this and understand caching and other requirements...
|
|
||||||
//// Investigate if we re-use src\modules\cmdpal\extensionsdk\Microsoft.CommandPalette.Extensions.Toolkit\ListHelpers.cs InPlaceUpdateList and FilterList?
|
|
||||||
|
|
||||||
// Dynamic pages will handler their own filtering. They will tell us if
|
// Dynamic pages will handler their own filtering. They will tell us if
|
||||||
// something needs to change, by raising ItemsChanged.
|
// something needs to change, by raising ItemsChanged.
|
||||||
if (_isDynamic)
|
if (_isDynamic)
|
||||||
@@ -132,24 +135,24 @@ public partial class ListViewModel : PageViewModel, IDisposable
|
|||||||
// concurrently.
|
// concurrently.
|
||||||
_ = filterTaskFactory.StartNew(
|
_ = filterTaskFactory.StartNew(
|
||||||
() =>
|
() =>
|
||||||
{
|
{
|
||||||
filterCancellationTokenSource.Token.ThrowIfCancellationRequested();
|
filterCancellationTokenSource.Token.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
|
||||||
if (_model.Unsafe is IDynamicListPage dynamic)
|
|
||||||
{
|
{
|
||||||
dynamic.SearchText = searchTextBox;
|
if (_model.Unsafe is IDynamicListPage dynamic)
|
||||||
|
{
|
||||||
|
dynamic.SearchText = searchTextBox;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
catch (OperationCanceledException)
|
||||||
catch (OperationCanceledException)
|
{
|
||||||
{
|
}
|
||||||
}
|
catch (Exception ex)
|
||||||
catch (Exception ex)
|
{
|
||||||
{
|
ShowException(ex, _model?.Unsafe?.Name);
|
||||||
ShowException(ex, _model?.Unsafe?.Name);
|
}
|
||||||
}
|
},
|
||||||
},
|
|
||||||
filterCancellationTokenSource.Token,
|
filterCancellationTokenSource.Token,
|
||||||
TaskCreationOptions.None,
|
TaskCreationOptions.None,
|
||||||
filterTaskFactory.Scheduler!);
|
filterTaskFactory.Scheduler!);
|
||||||
@@ -162,7 +165,7 @@ public partial class ListViewModel : PageViewModel, IDisposable
|
|||||||
ApplyFilterUnderLock();
|
ApplyFilterUnderLock();
|
||||||
}
|
}
|
||||||
|
|
||||||
ItemsUpdated?.Invoke(this, EventArgs.Empty);
|
ItemsUpdated?.Invoke(this, new ItemsUpdatedEventArgs(true));
|
||||||
UpdateEmptyContent();
|
UpdateEmptyContent();
|
||||||
_isLoading.Clear();
|
_isLoading.Clear();
|
||||||
}
|
}
|
||||||
@@ -198,12 +201,10 @@ public partial class ListViewModel : PageViewModel, IDisposable
|
|||||||
|
|
||||||
var cancellationToken = _fetchItemsCancellationTokenSource.Token;
|
var cancellationToken = _fetchItemsCancellationTokenSource.Token;
|
||||||
|
|
||||||
// TEMPORARY: just plop all the items into a single group
|
|
||||||
// see 9806fe5d8 for the last commit that had this with sections
|
|
||||||
_isFetching = true;
|
_isFetching = true;
|
||||||
|
|
||||||
// Collect all the items into new viewmodels
|
// Collect all the items into new viewmodels
|
||||||
Collection<ListItemViewModel> newViewModels = [];
|
List<ListItemViewModel> newViewModels = [];
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -221,11 +222,10 @@ public partial class ListViewModel : PageViewModel, IDisposable
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO we can probably further optimize this by also keeping a
|
|
||||||
// HashSet of every ExtensionObject we currently have, and only
|
|
||||||
// building new viewmodels for the ones we haven't already built.
|
|
||||||
var showsTitle = GridProperties?.ShowTitle ?? true;
|
var showsTitle = GridProperties?.ShowTitle ?? true;
|
||||||
var showsSubtitle = GridProperties?.ShowSubtitle ?? true;
|
var showsSubtitle = GridProperties?.ShowSubtitle ?? true;
|
||||||
|
var created = 0;
|
||||||
|
var reused = 0;
|
||||||
foreach (var item in newItems)
|
foreach (var item in newItems)
|
||||||
{
|
{
|
||||||
// Check for cancellation during item processing
|
// Check for cancellation during item processing
|
||||||
@@ -234,17 +234,33 @@ public partial class ListViewModel : PageViewModel, IDisposable
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
ListItemViewModel viewModel = new(item, new(this));
|
if (_vmCache.TryGetValue(item, out var existing))
|
||||||
|
{
|
||||||
|
existing.LayoutShowsTitle = showsTitle;
|
||||||
|
existing.LayoutShowsSubtitle = showsSubtitle;
|
||||||
|
newViewModels.Add(existing);
|
||||||
|
reused++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var viewModel = new ListItemViewModel(item, new(this));
|
||||||
|
|
||||||
// If an item fails to load, silently ignore it.
|
// If an item fails to load, silently ignore it.
|
||||||
if (viewModel.SafeFastInit())
|
if (viewModel.SafeFastInit())
|
||||||
{
|
{
|
||||||
viewModel.LayoutShowsTitle = showsTitle;
|
viewModel.LayoutShowsTitle = showsTitle;
|
||||||
viewModel.LayoutShowsSubtitle = showsSubtitle;
|
viewModel.LayoutShowsSubtitle = showsSubtitle;
|
||||||
|
|
||||||
|
_vmCache[item] = viewModel;
|
||||||
newViewModels.Add(viewModel);
|
newViewModels.Add(viewModel);
|
||||||
|
created++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
CoreLogger.LogInfo($"[ListViewModel] FetchItems: {created} created, {reused} reused, {_vmCache.Count} cached");
|
||||||
|
#endif
|
||||||
|
|
||||||
// Check for cancellation before initializing first twenty items
|
// Check for cancellation before initializing first twenty items
|
||||||
if (cancellationToken.IsCancellationRequested)
|
if (cancellationToken.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
@@ -271,13 +287,22 @@ public partial class ListViewModel : PageViewModel, IDisposable
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
List<ListItemViewModel> removedItems = [];
|
List<ListItemViewModel> removedItems;
|
||||||
lock (_listLock)
|
lock (_listLock)
|
||||||
{
|
{
|
||||||
// Now that we have new ViewModels for everything from the
|
// Now that we have new ViewModels for everything from the
|
||||||
// extension, smartly update our list of VMs
|
// extension, smartly update our list of VMs
|
||||||
ListHelpers.InPlaceUpdateList(Items, newViewModels, out removedItems);
|
ListHelpers.InPlaceUpdateList(Items, newViewModels, out removedItems);
|
||||||
|
|
||||||
|
_vmCache.Clear();
|
||||||
|
foreach (var vm in newViewModels)
|
||||||
|
{
|
||||||
|
if (vm.Model.Unsafe is { } li)
|
||||||
|
{
|
||||||
|
_vmCache[li] = vm;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// DO NOT ThrowIfCancellationRequested AFTER THIS! If you do,
|
// DO NOT ThrowIfCancellationRequested AFTER THIS! If you do,
|
||||||
// you'll clean up list items that we've now transferred into
|
// you'll clean up list items that we've now transferred into
|
||||||
// .Items
|
// .Items
|
||||||
@@ -288,9 +313,6 @@ public partial class ListViewModel : PageViewModel, IDisposable
|
|||||||
{
|
{
|
||||||
removedItem.SafeCleanup();
|
removedItem.SafeCleanup();
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Iterate over everything in Items, and prune items from the
|
|
||||||
// cache if we don't need them anymore
|
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException)
|
catch (OperationCanceledException)
|
||||||
{
|
{
|
||||||
@@ -342,13 +364,14 @@ public partial class ListViewModel : PageViewModel, IDisposable
|
|||||||
{
|
{
|
||||||
// A dynamic list? Even better! Just stick everything into
|
// A dynamic list? Even better! Just stick everything into
|
||||||
// FilteredItems. The extension already did any filtering it cared about.
|
// FilteredItems. The extension already did any filtering it cared about.
|
||||||
ListHelpers.InPlaceUpdateList(FilteredItems, Items.Where(i => !i.IsInErrorState));
|
var snapshot = Items.Where(i => !i.IsInErrorState).ToList();
|
||||||
|
ListHelpers.InPlaceUpdateList(FilteredItems, snapshot);
|
||||||
}
|
}
|
||||||
|
|
||||||
UpdateEmptyContent();
|
UpdateEmptyContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
ItemsUpdated?.Invoke(this, EventArgs.Empty);
|
ItemsUpdated?.Invoke(this, new ItemsUpdatedEventArgs(!IsNested));
|
||||||
_isLoading.Clear();
|
_isLoading.Clear();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -485,40 +508,58 @@ public partial class ListViewModel : PageViewModel, IDisposable
|
|||||||
|
|
||||||
private void SetSelectedItem(ListItemViewModel item)
|
private void SetSelectedItem(ListItemViewModel item)
|
||||||
{
|
{
|
||||||
if (!item.SafeSlowInit())
|
|
||||||
{
|
|
||||||
// Even if initialization fails, we need to hide any previously shown details
|
|
||||||
DoOnUiThread(() =>
|
|
||||||
{
|
|
||||||
WeakReferenceMessenger.Default.Send<HideDetailsMessage>();
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// GH #322:
|
|
||||||
// For inexplicable reasons, if you try updating the command bar and
|
|
||||||
// the details on the same UI thread tick as updating the list, we'll
|
|
||||||
// explode
|
|
||||||
DoOnUiThread(
|
|
||||||
() =>
|
|
||||||
{
|
|
||||||
WeakReferenceMessenger.Default.Send<UpdateCommandBarMessage>(new(item));
|
|
||||||
|
|
||||||
if (ShowDetails && item.HasDetails)
|
|
||||||
{
|
|
||||||
WeakReferenceMessenger.Default.Send<ShowDetailsMessage>(new(item.Details));
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
WeakReferenceMessenger.Default.Send<HideDetailsMessage>();
|
|
||||||
}
|
|
||||||
|
|
||||||
TextToSuggest = item.TextToSuggest;
|
|
||||||
WeakReferenceMessenger.Default.Send<UpdateSuggestionMessage>(new(item.TextToSuggest));
|
|
||||||
});
|
|
||||||
|
|
||||||
_lastSelectedItem = item;
|
_lastSelectedItem = item;
|
||||||
_lastSelectedItem.PropertyChanged += SelectedItemPropertyChanged;
|
_lastSelectedItem.PropertyChanged += SelectedItemPropertyChanged;
|
||||||
|
|
||||||
|
WeakReferenceMessenger.Default.Send<UpdateCommandBarMessage>(new(item));
|
||||||
|
|
||||||
|
// Cancel any in-flight slow init from a previous selection and defer
|
||||||
|
// the expensive work (extension IPC for MoreCommands, details) so
|
||||||
|
// rapid arrow-key navigation skips intermediate items entirely.
|
||||||
|
_selectedItemCts?.Cancel();
|
||||||
|
var cts = _selectedItemCts = new CancellationTokenSource();
|
||||||
|
var ct = cts.Token;
|
||||||
|
|
||||||
|
_ = Task.Run(
|
||||||
|
() =>
|
||||||
|
{
|
||||||
|
if (ct.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!item.SafeSlowInit())
|
||||||
|
{
|
||||||
|
if (ct.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
WeakReferenceMessenger.Default.Send<HideDetailsMessage>();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ct.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// SafeSlowInit completed on a background thread — details
|
||||||
|
// messages will be marshalled to the UI thread by the receiver.
|
||||||
|
if (ShowDetails && item.HasDetails)
|
||||||
|
{
|
||||||
|
WeakReferenceMessenger.Default.Send<ShowDetailsMessage>(new(item.Details));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
WeakReferenceMessenger.Default.Send<HideDetailsMessage>();
|
||||||
|
}
|
||||||
|
|
||||||
|
TextToSuggest = item.TextToSuggest;
|
||||||
|
WeakReferenceMessenger.Default.Send<UpdateSuggestionMessage>(new(item.TextToSuggest));
|
||||||
|
},
|
||||||
|
ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void SelectedItemPropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
|
private void SelectedItemPropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
|
||||||
@@ -557,21 +598,12 @@ public partial class ListViewModel : PageViewModel, IDisposable
|
|||||||
|
|
||||||
private void ClearSelectedItem()
|
private void ClearSelectedItem()
|
||||||
{
|
{
|
||||||
// GH #322:
|
_selectedItemCts?.Cancel();
|
||||||
// For inexplicable reasons, if you try updating the command bar and
|
|
||||||
// the details on the same UI thread tick as updating the list, we'll
|
|
||||||
// explode
|
|
||||||
DoOnUiThread(
|
|
||||||
() =>
|
|
||||||
{
|
|
||||||
WeakReferenceMessenger.Default.Send<UpdateCommandBarMessage>(new(null));
|
|
||||||
|
|
||||||
WeakReferenceMessenger.Default.Send<HideDetailsMessage>();
|
WeakReferenceMessenger.Default.Send<UpdateCommandBarMessage>(new(null));
|
||||||
|
WeakReferenceMessenger.Default.Send<HideDetailsMessage>();
|
||||||
WeakReferenceMessenger.Default.Send<UpdateSuggestionMessage>(new(string.Empty));
|
WeakReferenceMessenger.Default.Send<UpdateSuggestionMessage>(new(string.Empty));
|
||||||
|
TextToSuggest = string.Empty;
|
||||||
TextToSuggest = string.Empty;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public override void InitializeProperties()
|
public override void InitializeProperties()
|
||||||
@@ -763,6 +795,10 @@ public partial class ListViewModel : PageViewModel, IDisposable
|
|||||||
_fetchItemsCancellationTokenSource?.Cancel();
|
_fetchItemsCancellationTokenSource?.Cancel();
|
||||||
_fetchItemsCancellationTokenSource?.Dispose();
|
_fetchItemsCancellationTokenSource?.Dispose();
|
||||||
_fetchItemsCancellationTokenSource = null;
|
_fetchItemsCancellationTokenSource = null;
|
||||||
|
|
||||||
|
_selectedItemCts?.Cancel();
|
||||||
|
_selectedItemCts?.Dispose();
|
||||||
|
_selectedItemCts = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override void UnsafeCleanup()
|
protected override void UnsafeCleanup()
|
||||||
@@ -775,6 +811,7 @@ public partial class ListViewModel : PageViewModel, IDisposable
|
|||||||
_cancellationTokenSource?.Cancel();
|
_cancellationTokenSource?.Cancel();
|
||||||
filterCancellationTokenSource?.Cancel();
|
filterCancellationTokenSource?.Cancel();
|
||||||
_fetchItemsCancellationTokenSource?.Cancel();
|
_fetchItemsCancellationTokenSource?.Cancel();
|
||||||
|
_selectedItemCts?.Cancel();
|
||||||
|
|
||||||
lock (_listLock)
|
lock (_listLock)
|
||||||
{
|
{
|
||||||
@@ -801,4 +838,11 @@ public partial class ListViewModel : PageViewModel, IDisposable
|
|||||||
model.ItemsChanged -= Model_ItemsChanged;
|
model.ItemsChanged -= Model_ItemsChanged;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private sealed class ProxyReferenceEqualityComparer : IEqualityComparer<IListItem>
|
||||||
|
{
|
||||||
|
public bool Equals(IListItem? x, IListItem? y) => ReferenceEquals(x, y);
|
||||||
|
|
||||||
|
public int GetHashCode(IListItem obj) => RuntimeHelpers.GetHashCode(obj);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ namespace Microsoft.CmdPal.UI.ViewModels.Properties {
|
|||||||
// class via a tool like ResGen or Visual Studio.
|
// class via a tool like ResGen or Visual Studio.
|
||||||
// To add or remove a member, edit your .ResX file then rerun ResGen
|
// To add or remove a member, edit your .ResX file then rerun ResGen
|
||||||
// with the /str option, or rebuild your VS project.
|
// with the /str option, or rebuild your VS project.
|
||||||
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "17.0.0.0")]
|
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "18.0.0.0")]
|
||||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
|
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
|
||||||
[global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()]
|
[global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()]
|
||||||
public class Resources {
|
public class Resources {
|
||||||
@@ -474,6 +474,15 @@ namespace Microsoft.CmdPal.UI.ViewModels.Properties {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Looks up a localized string similar to Results.
|
||||||
|
/// </summary>
|
||||||
|
public static string results {
|
||||||
|
get {
|
||||||
|
return ResourceManager.GetString("results", resourceCulture);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Looks up a localized string similar to Show details.
|
/// Looks up a localized string similar to Show details.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -264,4 +264,8 @@
|
|||||||
<value>Show details</value>
|
<value>Show details</value>
|
||||||
<comment>Name for the command that shows details of an item</comment>
|
<comment>Name for the command that shows details of an item</comment>
|
||||||
</data>
|
</data>
|
||||||
|
<data name="results" xml:space="preserve">
|
||||||
|
<value>Results</value>
|
||||||
|
<comment>Section title for list of all search results that doesn't fall into any other category</comment>
|
||||||
|
</data>
|
||||||
</root>
|
</root>
|
||||||
@@ -201,12 +201,70 @@
|
|||||||
<Setter Property="cpcontrols:WrapPanel.IsFullLine" Value="True" />
|
<Setter Property="cpcontrols:WrapPanel.IsFullLine" Value="True" />
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
|
<ControlTemplate x:Key="ListViewItemWithoutVisualIndicatorTemplate" TargetType="ListViewItem">
|
||||||
|
<ListViewItemPresenter
|
||||||
|
x:Name="Root"
|
||||||
|
HorizontalContentAlignment="{TemplateBinding HorizontalContentAlignment}"
|
||||||
|
VerticalContentAlignment="{TemplateBinding VerticalContentAlignment}"
|
||||||
|
CheckBoxBorderBrush="{ThemeResource ListViewItemCheckBoxBorderBrush}"
|
||||||
|
CheckBoxBrush="{ThemeResource ListViewItemCheckBoxBrush}"
|
||||||
|
CheckBoxCornerRadius="{ThemeResource ListViewItemCheckBoxCornerRadius}"
|
||||||
|
CheckBoxDisabledBorderBrush="{ThemeResource ListViewItemCheckBoxDisabledBorderBrush}"
|
||||||
|
CheckBoxDisabledBrush="{ThemeResource ListViewItemCheckBoxDisabledBrush}"
|
||||||
|
CheckBoxPointerOverBorderBrush="{ThemeResource ListViewItemCheckBoxPointerOverBorderBrush}"
|
||||||
|
CheckBoxPointerOverBrush="{ThemeResource ListViewItemCheckBoxPointerOverBrush}"
|
||||||
|
CheckBoxPressedBorderBrush="{ThemeResource ListViewItemCheckBoxPressedBorderBrush}"
|
||||||
|
CheckBoxPressedBrush="{ThemeResource ListViewItemCheckBoxPressedBrush}"
|
||||||
|
CheckBoxSelectedBrush="{ThemeResource ListViewItemCheckBoxSelectedBrush}"
|
||||||
|
CheckBoxSelectedDisabledBrush="{ThemeResource ListViewItemCheckBoxSelectedDisabledBrush}"
|
||||||
|
CheckBoxSelectedPointerOverBrush="{ThemeResource ListViewItemCheckBoxSelectedPointerOverBrush}"
|
||||||
|
CheckBoxSelectedPressedBrush="{ThemeResource ListViewItemCheckBoxSelectedPressedBrush}"
|
||||||
|
CheckBrush="{ThemeResource ListViewItemCheckBrush}"
|
||||||
|
CheckDisabledBrush="{ThemeResource ListViewItemCheckDisabledBrush}"
|
||||||
|
CheckMode="{ThemeResource ListViewItemCheckMode}"
|
||||||
|
CheckPressedBrush="{ThemeResource ListViewItemCheckPressedBrush}"
|
||||||
|
ContentMargin="{TemplateBinding Padding}"
|
||||||
|
ContentTransitions="{TemplateBinding ContentTransitions}"
|
||||||
|
Control.IsTemplateFocusTarget="True"
|
||||||
|
CornerRadius="{ThemeResource ListViewItemCornerRadius}"
|
||||||
|
DisabledOpacity="{ThemeResource ListViewItemDisabledThemeOpacity}"
|
||||||
|
DragBackground="{ThemeResource ListViewItemDragBackground}"
|
||||||
|
DragForeground="{ThemeResource ListViewItemDragForeground}"
|
||||||
|
DragOpacity="{ThemeResource ListViewItemDragThemeOpacity}"
|
||||||
|
FocusBorderBrush="{ThemeResource ListViewItemFocusBorderBrush}"
|
||||||
|
FocusSecondaryBorderBrush="{ThemeResource ListViewItemFocusSecondaryBorderBrush}"
|
||||||
|
FocusVisualMargin="{TemplateBinding FocusVisualMargin}"
|
||||||
|
FocusVisualPrimaryBrush="{TemplateBinding FocusVisualPrimaryBrush}"
|
||||||
|
FocusVisualPrimaryThickness="{TemplateBinding FocusVisualPrimaryThickness}"
|
||||||
|
FocusVisualSecondaryBrush="{TemplateBinding FocusVisualSecondaryBrush}"
|
||||||
|
FocusVisualSecondaryThickness="{TemplateBinding FocusVisualSecondaryThickness}"
|
||||||
|
PlaceholderBackground="{ThemeResource ListViewItemPlaceholderBackground}"
|
||||||
|
PointerOverBackground="{ThemeResource ListViewItemBackgroundPointerOver}"
|
||||||
|
PointerOverForeground="{ThemeResource ListViewItemForegroundPointerOver}"
|
||||||
|
PressedBackground="{ThemeResource ListViewItemBackgroundPressed}"
|
||||||
|
ReorderHintOffset="{ThemeResource ListViewItemReorderHintThemeOffset}"
|
||||||
|
SelectedBackground="{ThemeResource ListViewItemBackgroundSelected}"
|
||||||
|
SelectedDisabledBackground="{ThemeResource ListViewItemBackgroundSelectedDisabled}"
|
||||||
|
SelectedForeground="{ThemeResource ListViewItemForegroundSelected}"
|
||||||
|
SelectedPointerOverBackground="{ThemeResource ListViewItemBackgroundSelectedPointerOver}"
|
||||||
|
SelectedPressedBackground="{ThemeResource ListViewItemBackgroundSelectedPressed}"
|
||||||
|
SelectionCheckMarkVisualEnabled="{ThemeResource ListViewItemSelectionCheckMarkVisualEnabled}"
|
||||||
|
SelectionIndicatorBrush="{ThemeResource ListViewItemSelectionIndicatorBrush}"
|
||||||
|
SelectionIndicatorCornerRadius="{ThemeResource ListViewItemSelectionIndicatorCornerRadius}"
|
||||||
|
SelectionIndicatorDisabledBrush="{ThemeResource ListViewItemSelectionIndicatorDisabledBrush}"
|
||||||
|
SelectionIndicatorPointerOverBrush="{ThemeResource ListViewItemSelectionIndicatorPointerOverBrush}"
|
||||||
|
SelectionIndicatorPressedBrush="{ThemeResource ListViewItemSelectionIndicatorPressedBrush}"
|
||||||
|
SelectionIndicatorVisualEnabled="False" />
|
||||||
|
</ControlTemplate>
|
||||||
|
|
||||||
<Style
|
<Style
|
||||||
x:Key="ListDefaultContainerStyle"
|
x:Key="ListSingleRowItemContainerStyle"
|
||||||
BasedOn="{StaticResource DefaultListViewItemStyle}"
|
BasedOn="{StaticResource DefaultListViewItemStyle}"
|
||||||
TargetType="ListViewItem">
|
TargetType="ListViewItem">
|
||||||
<Setter Property="MinHeight" Value="{StaticResource SingleRowListViewItemHeight}" />
|
<Setter Property="MinHeight" Value="{StaticResource SingleRowListViewItemHeight}" />
|
||||||
<Setter Property="Height" Value="{StaticResource SingleRowListViewItemHeight}" />
|
<Setter Property="Height" Value="{StaticResource SingleRowListViewItemHeight}" />
|
||||||
|
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
|
||||||
|
<Setter Property="Template" Value="{StaticResource ListViewItemWithoutVisualIndicatorTemplate}" />
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
<Style
|
<Style
|
||||||
@@ -246,14 +304,17 @@
|
|||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
<DataTemplate x:Key="TagTemplate" x:DataType="viewModels:TagViewModel">
|
<DataTemplate x:Key="TagTemplate" x:DataType="viewModels:TagViewModel">
|
||||||
|
<!--
|
||||||
|
Tags are immutable, so we don't have to worry about binding mode.
|
||||||
|
-->
|
||||||
<cpcontrols:Tag
|
<cpcontrols:Tag
|
||||||
AutomationProperties.Name="{x:Bind Text, Mode=OneWay}"
|
AutomationProperties.Name="{x:Bind Text}"
|
||||||
BackgroundColor="{x:Bind Background, Mode=OneWay}"
|
BackgroundColor="{x:Bind Background}"
|
||||||
FontSize="12"
|
FontSize="12"
|
||||||
ForegroundColor="{x:Bind Foreground, Mode=OneWay}"
|
ForegroundColor="{x:Bind Foreground}"
|
||||||
Icon="{x:Bind Icon, Mode=OneWay}"
|
Icon="{x:Bind Icon}"
|
||||||
Text="{x:Bind Text, Mode=OneWay}"
|
Text="{x:Bind Text}"
|
||||||
ToolTipService.ToolTip="{x:Bind ToolTip, Mode=OneWay}" />
|
ToolTipService.ToolTip="{x:Bind ToolTip}" />
|
||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
|
|
||||||
<cmdpalUI:ListItemTemplateSelector
|
<cmdpalUI:ListItemTemplateSelector
|
||||||
@@ -265,7 +326,7 @@
|
|||||||
|
|
||||||
<cmdpalUI:ListItemContainerStyleSelector
|
<cmdpalUI:ListItemContainerStyleSelector
|
||||||
x:Key="ListItemContainerStyleSelector"
|
x:Key="ListItemContainerStyleSelector"
|
||||||
Default="{StaticResource ListDefaultContainerStyle}"
|
Default="{StaticResource ListSingleRowItemContainerStyle}"
|
||||||
Section="{StaticResource ListSectionContainerStyle}"
|
Section="{StaticResource ListSectionContainerStyle}"
|
||||||
Separator="{StaticResource ListSeparatorContainerStyle}" />
|
Separator="{StaticResource ListSeparatorContainerStyle}" />
|
||||||
|
|
||||||
@@ -310,10 +371,7 @@
|
|||||||
Title and subtitle are intentionally in a nested Grid instead in the outer container,
|
Title and subtitle are intentionally in a nested Grid instead in the outer container,
|
||||||
to avoid pushing the following element (tags) out of bounds.
|
to avoid pushing the following element (tags) out of bounds.
|
||||||
-->
|
-->
|
||||||
<Grid
|
<Grid Grid.Column="1" ColumnSpacing="12">
|
||||||
Grid.Column="1"
|
|
||||||
VerticalAlignment="Center"
|
|
||||||
ColumnSpacing="12">
|
|
||||||
<Grid.ColumnDefinitions>
|
<Grid.ColumnDefinitions>
|
||||||
<ColumnDefinition Width="Auto" />
|
<ColumnDefinition Width="Auto" />
|
||||||
<ColumnDefinition Width="*" />
|
<ColumnDefinition Width="*" />
|
||||||
@@ -345,10 +403,11 @@
|
|||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
8px right margin is added to visually match the spacing between then icon,
|
An 8px right margin is added to visually match the spacing between the icon
|
||||||
and the left margin of the list, as there's
|
and the left margin of the list.
|
||||||
|
ItemRepeater is a lightweight control (compared to ItemsControl).
|
||||||
-->
|
-->
|
||||||
<ItemsControl
|
<ItemsRepeater
|
||||||
Grid.Column="2"
|
Grid.Column="2"
|
||||||
Margin="0,0,8,0"
|
Margin="0,0,8,0"
|
||||||
VerticalAlignment="Center"
|
VerticalAlignment="Center"
|
||||||
@@ -357,12 +416,10 @@
|
|||||||
ItemTemplate="{StaticResource TagTemplate}"
|
ItemTemplate="{StaticResource TagTemplate}"
|
||||||
ItemsSource="{x:Bind Tags, Mode=OneWay}"
|
ItemsSource="{x:Bind Tags, Mode=OneWay}"
|
||||||
Visibility="{x:Bind HasTags, Mode=OneWay}">
|
Visibility="{x:Bind HasTags, Mode=OneWay}">
|
||||||
<ItemsControl.ItemsPanel>
|
<ItemsRepeater.Layout>
|
||||||
<ItemsPanelTemplate>
|
<StackLayout Orientation="Horizontal" Spacing="4" />
|
||||||
<StackPanel Orientation="Horizontal" Spacing="4" />
|
</ItemsRepeater.Layout>
|
||||||
</ItemsPanelTemplate>
|
</ItemsRepeater>
|
||||||
</ItemsControl.ItemsPanel>
|
|
||||||
</ItemsControl>
|
|
||||||
</Grid>
|
</Grid>
|
||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using CommunityToolkit.Mvvm.Messaging;
|
using CommunityToolkit.Mvvm.Messaging;
|
||||||
using ManagedCommon;
|
using ManagedCommon;
|
||||||
|
using Microsoft.CmdPal.Core.ViewModels;
|
||||||
using Microsoft.CmdPal.UI.Helpers;
|
using Microsoft.CmdPal.UI.Helpers;
|
||||||
using Microsoft.CmdPal.UI.Messages;
|
using Microsoft.CmdPal.UI.Messages;
|
||||||
using Microsoft.CmdPal.UI.ViewModels;
|
using Microsoft.CmdPal.UI.ViewModels;
|
||||||
@@ -12,6 +13,7 @@ using Microsoft.CmdPal.UI.ViewModels.Commands;
|
|||||||
using Microsoft.CmdPal.UI.ViewModels.Messages;
|
using Microsoft.CmdPal.UI.ViewModels.Messages;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.UI.Xaml;
|
using Microsoft.UI.Xaml;
|
||||||
|
using Microsoft.UI.Xaml.Automation.Peers;
|
||||||
using Microsoft.UI.Xaml.Controls;
|
using Microsoft.UI.Xaml.Controls;
|
||||||
using Microsoft.UI.Xaml.Controls.Primitives;
|
using Microsoft.UI.Xaml.Controls.Primitives;
|
||||||
using Microsoft.UI.Xaml.Input;
|
using Microsoft.UI.Xaml.Input;
|
||||||
@@ -35,6 +37,14 @@ public sealed partial class ListPage : Page,
|
|||||||
{
|
{
|
||||||
private InputSource _lastInputSource;
|
private InputSource _lastInputSource;
|
||||||
|
|
||||||
|
private int _itemsUpdatedVersion;
|
||||||
|
private bool _suppressSelectionChanged;
|
||||||
|
|
||||||
|
private bool _scrollOnNextSelectionChange;
|
||||||
|
|
||||||
|
private ListItemViewModel? _stickySelectedItem;
|
||||||
|
private ListItemViewModel? _lastPushedToVm;
|
||||||
|
|
||||||
internal ListViewModel? ViewModel
|
internal ListViewModel? ViewModel
|
||||||
{
|
{
|
||||||
get => (ListViewModel?)GetValue(ViewModelProperty);
|
get => (ListViewModel?)GetValue(ViewModelProperty);
|
||||||
@@ -45,13 +55,7 @@ public sealed partial class ListPage : Page,
|
|||||||
public static readonly DependencyProperty ViewModelProperty =
|
public static readonly DependencyProperty ViewModelProperty =
|
||||||
DependencyProperty.Register(nameof(ViewModel), typeof(ListViewModel), typeof(ListPage), new PropertyMetadata(null, OnViewModelChanged));
|
DependencyProperty.Register(nameof(ViewModel), typeof(ListViewModel), typeof(ListPage), new PropertyMetadata(null, OnViewModelChanged));
|
||||||
|
|
||||||
private ListViewBase ItemView
|
private ListViewBase ItemView => ViewModel?.IsGridView == true ? ItemsGrid : ItemsList;
|
||||||
{
|
|
||||||
get
|
|
||||||
{
|
|
||||||
return ViewModel?.IsGridView == true ? ItemsGrid : ItemsList;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public ListPage()
|
public ListPage()
|
||||||
{
|
{
|
||||||
@@ -82,10 +86,17 @@ public sealed partial class ListPage : Page,
|
|||||||
// may return an incorrect index because item containers are not yet rendered.
|
// may return an incorrect index because item containers are not yet rendered.
|
||||||
_ = DispatcherQueue.TryEnqueue(Microsoft.UI.Dispatching.DispatcherQueuePriority.Low, () =>
|
_ = DispatcherQueue.TryEnqueue(Microsoft.UI.Dispatching.DispatcherQueuePriority.Low, () =>
|
||||||
{
|
{
|
||||||
var firstUsefulIndex = GetFirstSelectableIndex();
|
// Only do this if we truly have no selection.
|
||||||
if (firstUsefulIndex != -1)
|
if (ItemView.SelectedItem is null)
|
||||||
{
|
{
|
||||||
ItemView.SelectedIndex = firstUsefulIndex;
|
var firstUsefulIndex = GetFirstSelectableIndex();
|
||||||
|
if (firstUsefulIndex != -1)
|
||||||
|
{
|
||||||
|
using (SuppressSelectionChangedScope())
|
||||||
|
{
|
||||||
|
ItemView.SelectedIndex = firstUsefulIndex;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -118,7 +129,6 @@ public sealed partial class ListPage : Page,
|
|||||||
|
|
||||||
if (ViewModel is not null)
|
if (ViewModel is not null)
|
||||||
{
|
{
|
||||||
ViewModel.PropertyChanged -= ViewModel_PropertyChanged;
|
|
||||||
ViewModel.ItemsUpdated -= Page_ItemsUpdated;
|
ViewModel.ItemsUpdated -= Page_ItemsUpdated;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,6 +185,9 @@ public sealed partial class ListPage : Page,
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
// Click-driven selection should scroll into view (but only once).
|
||||||
|
_scrollOnNextSelectionChange = true;
|
||||||
|
|
||||||
ViewModel?.UpdateSelectedItemCommand.Execute(item);
|
ViewModel?.UpdateSelectedItemCommand.Execute(item);
|
||||||
WeakReferenceMessenger.Default.Send<FocusSearchBoxMessage>();
|
WeakReferenceMessenger.Default.Send<FocusSearchBoxMessage>();
|
||||||
}
|
}
|
||||||
@@ -196,51 +209,59 @@ public sealed partial class ListPage : Page,
|
|||||||
[System.Diagnostics.CodeAnalysis.SuppressMessage("CodeQuality", "IDE0051:Remove unused private members", Justification = "VS is too aggressive at pruning methods bound in XAML")]
|
[System.Diagnostics.CodeAnalysis.SuppressMessage("CodeQuality", "IDE0051:Remove unused private members", Justification = "VS is too aggressive at pruning methods bound in XAML")]
|
||||||
private void Items_SelectionChanged(object sender, SelectionChangedEventArgs e)
|
private void Items_SelectionChanged(object sender, SelectionChangedEventArgs e)
|
||||||
{
|
{
|
||||||
|
if (_suppressSelectionChanged)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
var vm = ViewModel;
|
var vm = ViewModel;
|
||||||
var li = ItemView.SelectedItem as ListItemViewModel;
|
var li = ItemView.SelectedItem as ListItemViewModel;
|
||||||
_ = Task.Run(() =>
|
|
||||||
|
// Transient null/separator selection can happen during in-place updates.
|
||||||
|
// Do not push null into the VM; Page_ItemsUpdated will repair selection.
|
||||||
|
if (li is null || IsSeparator(li))
|
||||||
{
|
{
|
||||||
vm?.UpdateSelectedItemCommand.Execute(li);
|
return;
|
||||||
});
|
}
|
||||||
|
|
||||||
// There's mysterious behavior here, where the selection seemingly
|
_stickySelectedItem = li;
|
||||||
// changes to _nothing_ when we're backspacing to a single character.
|
|
||||||
// And at that point, seemingly the item that's getting removed is not
|
// Do not Task.Run (it reorders selection updates).
|
||||||
// a member of FilteredItems. Very bizarre.
|
vm?.UpdateSelectedItemCommand.Execute(li);
|
||||||
//
|
|
||||||
// Might be able to fix in the future by stashing the removed item
|
// Only scroll when explicitly requested by navigation/click handlers.
|
||||||
// here, then in Page_ItemsUpdated trying to select that cached item if
|
if (_scrollOnNextSelectionChange)
|
||||||
// it's in the list (otherwise, clear the cache), but that seems
|
|
||||||
// aggressively BODGY for something that mostly just works today.
|
|
||||||
if (ItemView.SelectedItem is not null && !IsSeparator(ItemView.SelectedItem))
|
|
||||||
{
|
{
|
||||||
var items = ItemView.Items;
|
_scrollOnNextSelectionChange = false;
|
||||||
var firstUsefulIndex = GetFirstSelectableIndex();
|
|
||||||
var shouldScroll = false;
|
|
||||||
|
|
||||||
if (e.RemovedItems.Count > 0)
|
var scrollTarget = li;
|
||||||
|
|
||||||
|
// If the previous item is a separator, also scroll it into view to provide
|
||||||
|
// better context for the user
|
||||||
|
var index = ItemView.Items.IndexOf(li);
|
||||||
|
if (index > 0)
|
||||||
{
|
{
|
||||||
shouldScroll = true;
|
var prevItem = ItemView.Items[index - 1] as ListItemViewModel;
|
||||||
}
|
if (prevItem?.Type == ListItemType.SectionHeader)
|
||||||
else if (ItemView.SelectedIndex > firstUsefulIndex)
|
{
|
||||||
{
|
scrollTarget = prevItem;
|
||||||
shouldScroll = true;
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (shouldScroll)
|
if (scrollTarget is not null)
|
||||||
{
|
{
|
||||||
ItemView.ScrollIntoView(ItemView.SelectedItem);
|
ItemView.ScrollIntoView(scrollTarget);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Automation notification for screen readers
|
// Automation notification for screen readers
|
||||||
var listViewPeer = Microsoft.UI.Xaml.Automation.Peers.ListViewAutomationPeer.CreatePeerForElement(ItemView);
|
var listViewPeer = ListViewAutomationPeer.CreatePeerForElement(ItemView);
|
||||||
if (listViewPeer is not null && li is not null)
|
if (listViewPeer is not null)
|
||||||
{
|
{
|
||||||
UIHelper.AnnounceActionForAccessibility(
|
UIHelper.AnnounceActionForAccessibility(
|
||||||
ItemsList,
|
ItemsList,
|
||||||
li.Title,
|
li.Title,
|
||||||
"CommandPaletteSelectedItemChanged");
|
"CommandPaletteSelectedItemChanged");
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -251,7 +272,12 @@ public sealed partial class ListPage : Page,
|
|||||||
{
|
{
|
||||||
if (ItemView.SelectedItem != item)
|
if (ItemView.SelectedItem != item)
|
||||||
{
|
{
|
||||||
ItemView.SelectedItem = item;
|
_scrollOnNextSelectionChange = true;
|
||||||
|
|
||||||
|
using (SuppressSelectionChangedScope())
|
||||||
|
{
|
||||||
|
ItemView.SelectedItem = item;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ViewModel?.UpdateSelectedItemCommand.Execute(item);
|
ViewModel?.UpdateSelectedItemCommand.Execute(item);
|
||||||
@@ -264,7 +290,7 @@ public sealed partial class ListPage : Page,
|
|||||||
WeakReferenceMessenger.Default.Send<OpenContextMenuMessage>(
|
WeakReferenceMessenger.Default.Send<OpenContextMenuMessage>(
|
||||||
new OpenContextMenuMessage(
|
new OpenContextMenuMessage(
|
||||||
element,
|
element,
|
||||||
Microsoft.UI.Xaml.Controls.Primitives.FlyoutPlacementMode.BottomEdgeAlignedLeft,
|
FlyoutPlacementMode.BottomEdgeAlignedLeft,
|
||||||
pos,
|
pos,
|
||||||
ContextMenuFilterLocation.Top));
|
ContextMenuFilterLocation.Top));
|
||||||
});
|
});
|
||||||
@@ -274,7 +300,7 @@ public sealed partial class ListPage : Page,
|
|||||||
private void Items_Loaded(object sender, RoutedEventArgs e)
|
private void Items_Loaded(object sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
// Find the ScrollViewer in the ItemView (ItemsList or ItemsGrid)
|
// Find the ScrollViewer in the ItemView (ItemsList or ItemsGrid)
|
||||||
var listViewScrollViewer = FindScrollViewer(this.ItemView);
|
var listViewScrollViewer = FindScrollViewer(ItemView);
|
||||||
|
|
||||||
if (listViewScrollViewer is not null)
|
if (listViewScrollViewer is not null)
|
||||||
{
|
{
|
||||||
@@ -300,12 +326,37 @@ public sealed partial class ListPage : Page,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Message-driven navigation should count as keyboard.
|
||||||
|
private void MarkKeyboardNavigation() => _lastInputSource = InputSource.Keyboard;
|
||||||
|
|
||||||
|
private void PushSelectionToVm()
|
||||||
|
{
|
||||||
|
if (ViewModel is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ItemView.SelectedItem is not ListItemViewModel li || IsSeparator(li))
|
||||||
|
{
|
||||||
|
ViewModel.UpdateSelectedItemCommand.Execute(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ReferenceEquals(_lastPushedToVm, li))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_lastPushedToVm = li;
|
||||||
|
_stickySelectedItem = li;
|
||||||
|
ViewModel.UpdateSelectedItemCommand.Execute(li);
|
||||||
|
}
|
||||||
|
|
||||||
public void Receive(NavigateNextCommand message)
|
public void Receive(NavigateNextCommand message)
|
||||||
{
|
{
|
||||||
// Note: We may want to just have the notion of a 'SelectedCommand' in our VM
|
MarkKeyboardNavigation();
|
||||||
// And then have these commands manipulate that state being bound to the UI instead
|
_scrollOnNextSelectionChange = true;
|
||||||
// We may want to see how other non-list UIs need to behave to make this decision
|
|
||||||
// At least it's decoupled from the SearchBox now :)
|
|
||||||
if (ViewModel?.IsGridView == true)
|
if (ViewModel?.IsGridView == true)
|
||||||
{
|
{
|
||||||
// For grid views, use spatial navigation (down)
|
// For grid views, use spatial navigation (down)
|
||||||
@@ -316,10 +367,15 @@ public sealed partial class ListPage : Page,
|
|||||||
// For list views, use simple linear navigation
|
// For list views, use simple linear navigation
|
||||||
NavigateDown();
|
NavigateDown();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
PushSelectionToVm();
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Receive(NavigatePreviousCommand message)
|
public void Receive(NavigatePreviousCommand message)
|
||||||
{
|
{
|
||||||
|
MarkKeyboardNavigation();
|
||||||
|
_scrollOnNextSelectionChange = true;
|
||||||
|
|
||||||
if (ViewModel?.IsGridView == true)
|
if (ViewModel?.IsGridView == true)
|
||||||
{
|
{
|
||||||
// For grid views, use spatial navigation (up)
|
// For grid views, use spatial navigation (up)
|
||||||
@@ -329,14 +385,20 @@ public sealed partial class ListPage : Page,
|
|||||||
{
|
{
|
||||||
NavigateUp();
|
NavigateUp();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
PushSelectionToVm();
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Receive(NavigateLeftCommand message)
|
public void Receive(NavigateLeftCommand message)
|
||||||
{
|
{
|
||||||
|
MarkKeyboardNavigation();
|
||||||
|
_scrollOnNextSelectionChange = true;
|
||||||
|
|
||||||
// For grid views, use spatial navigation. For list views, just move up.
|
// For grid views, use spatial navigation. For list views, just move up.
|
||||||
if (ViewModel?.IsGridView == true)
|
if (ViewModel?.IsGridView == true)
|
||||||
{
|
{
|
||||||
HandleGridArrowNavigation(VirtualKey.Left);
|
HandleGridArrowNavigation(VirtualKey.Left);
|
||||||
|
PushSelectionToVm();
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -347,10 +409,14 @@ public sealed partial class ListPage : Page,
|
|||||||
|
|
||||||
public void Receive(NavigateRightCommand message)
|
public void Receive(NavigateRightCommand message)
|
||||||
{
|
{
|
||||||
|
MarkKeyboardNavigation();
|
||||||
|
_scrollOnNextSelectionChange = true;
|
||||||
|
|
||||||
// For grid views, use spatial navigation. For list views, just move down.
|
// For grid views, use spatial navigation. For list views, just move down.
|
||||||
if (ViewModel?.IsGridView == true)
|
if (ViewModel?.IsGridView == true)
|
||||||
{
|
{
|
||||||
HandleGridArrowNavigation(VirtualKey.Right);
|
HandleGridArrowNavigation(VirtualKey.Right);
|
||||||
|
PushSelectionToVm();
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -385,6 +451,9 @@ public sealed partial class ListPage : Page,
|
|||||||
|
|
||||||
public void Receive(NavigatePageDownCommand message)
|
public void Receive(NavigatePageDownCommand message)
|
||||||
{
|
{
|
||||||
|
MarkKeyboardNavigation();
|
||||||
|
_scrollOnNextSelectionChange = true;
|
||||||
|
|
||||||
var indexes = CalculateTargetIndexPageUpDownScrollTo(true);
|
var indexes = CalculateTargetIndexPageUpDownScrollTo(true);
|
||||||
if (indexes is null)
|
if (indexes is null)
|
||||||
{
|
{
|
||||||
@@ -394,15 +463,16 @@ public sealed partial class ListPage : Page,
|
|||||||
if (indexes.Value.CurrentIndex != indexes.Value.TargetIndex)
|
if (indexes.Value.CurrentIndex != indexes.Value.TargetIndex)
|
||||||
{
|
{
|
||||||
ItemView.SelectedIndex = indexes.Value.TargetIndex;
|
ItemView.SelectedIndex = indexes.Value.TargetIndex;
|
||||||
if (ItemView.SelectedItem is not null)
|
|
||||||
{
|
|
||||||
ItemView.ScrollIntoView(ItemView.SelectedItem);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
PushSelectionToVm();
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Receive(NavigatePageUpCommand message)
|
public void Receive(NavigatePageUpCommand message)
|
||||||
{
|
{
|
||||||
|
MarkKeyboardNavigation();
|
||||||
|
_scrollOnNextSelectionChange = true;
|
||||||
|
|
||||||
var indexes = CalculateTargetIndexPageUpDownScrollTo(false);
|
var indexes = CalculateTargetIndexPageUpDownScrollTo(false);
|
||||||
if (indexes is null)
|
if (indexes is null)
|
||||||
{
|
{
|
||||||
@@ -412,11 +482,9 @@ public sealed partial class ListPage : Page,
|
|||||||
if (indexes.Value.CurrentIndex != indexes.Value.TargetIndex)
|
if (indexes.Value.CurrentIndex != indexes.Value.TargetIndex)
|
||||||
{
|
{
|
||||||
ItemView.SelectedIndex = indexes.Value.TargetIndex;
|
ItemView.SelectedIndex = indexes.Value.TargetIndex;
|
||||||
if (ItemView.SelectedItem is not null)
|
|
||||||
{
|
|
||||||
ItemView.ScrollIntoView(ItemView.SelectedItem);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
PushSelectionToVm();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -519,8 +587,8 @@ public sealed partial class ListPage : Page,
|
|||||||
}
|
}
|
||||||
|
|
||||||
var targetIndex = isPageDown
|
var targetIndex = isPageDown
|
||||||
? Math.Min(itemCount - 1, currentIndex + Math.Max(1, itemsPerPage))
|
? Math.Min(itemCount - 1, currentIndex + Math.Max(1, itemsPerPage))
|
||||||
: Math.Max(0, currentIndex - Math.Max(1, itemsPerPage));
|
: Math.Max(0, currentIndex - Math.Max(1, itemsPerPage));
|
||||||
|
|
||||||
return (currentIndex, targetIndex);
|
return (currentIndex, targetIndex);
|
||||||
}
|
}
|
||||||
@@ -531,13 +599,11 @@ public sealed partial class ListPage : Page,
|
|||||||
{
|
{
|
||||||
if (e.OldValue is ListViewModel old)
|
if (e.OldValue is ListViewModel old)
|
||||||
{
|
{
|
||||||
old.PropertyChanged -= @this.ViewModel_PropertyChanged;
|
|
||||||
old.ItemsUpdated -= @this.Page_ItemsUpdated;
|
old.ItemsUpdated -= @this.Page_ItemsUpdated;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (e.NewValue is ListViewModel page)
|
if (e.NewValue is ListViewModel page)
|
||||||
{
|
{
|
||||||
page.PropertyChanged += @this.ViewModel_PropertyChanged;
|
|
||||||
page.ItemsUpdated += @this.Page_ItemsUpdated;
|
page.ItemsUpdated += @this.Page_ItemsUpdated;
|
||||||
}
|
}
|
||||||
else if (e.NewValue is null)
|
else if (e.NewValue is null)
|
||||||
@@ -549,83 +615,141 @@ public sealed partial class ListPage : Page,
|
|||||||
|
|
||||||
// Called after we've finished updating the whole list for either a
|
// Called after we've finished updating the whole list for either a
|
||||||
// GetItems or a change in the filter.
|
// GetItems or a change in the filter.
|
||||||
private void Page_ItemsUpdated(ListViewModel sender, object args)
|
private void Page_ItemsUpdated(ListViewModel sender, ItemsUpdatedEventArgs args)
|
||||||
{
|
{
|
||||||
// If for some reason, we don't have a selected item, fix that.
|
var version = Interlocked.Increment(ref _itemsUpdatedVersion);
|
||||||
//
|
var forceFirstItem = args.ForceFirstItem;
|
||||||
// It's important to do this here, because once there's no selection
|
|
||||||
// (which can happen as the list updates) we won't get an
|
// Try to handle selection immediately — items should already be available
|
||||||
// ItemView_SelectionChanged again to give us another chance to change
|
// since FilteredItems is a direct ObservableCollection bound as ItemsSource.
|
||||||
// the selection from null -> something. Better to just update the
|
if (!TrySetSelectionAfterUpdate(sender, version, forceFirstItem))
|
||||||
// selection once, at the end of all the updating.
|
|
||||||
// The selection logic must be deferred to the DispatcherQueue
|
|
||||||
// to ensure the UI has processed the updated ItemsSource binding,
|
|
||||||
// preventing ItemView.Items from appearing empty/null immediately after update.
|
|
||||||
_ = DispatcherQueue.TryEnqueue(Microsoft.UI.Dispatching.DispatcherQueuePriority.Low, () =>
|
|
||||||
{
|
{
|
||||||
var items = ItemView.Items;
|
// Fallback: binding hasn't propagated yet, defer to next tick.
|
||||||
|
_ = DispatcherQueue.TryEnqueue(
|
||||||
|
Microsoft.UI.Dispatching.DispatcherQueuePriority.Low,
|
||||||
|
() =>
|
||||||
|
{
|
||||||
|
if (version != Volatile.Read(ref _itemsUpdatedVersion))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// If the list is null or empty, clears the selection and return
|
TrySetSelectionAfterUpdate(sender, version, forceFirstItem);
|
||||||
if (items is null || items.Count == 0)
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Applies selection after an items update. Returns false if ItemView.Items
|
||||||
|
/// is not yet populated (caller should defer and retry).
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="forceFirstItem">
|
||||||
|
/// When true, always select the first selectable item and scroll to top
|
||||||
|
/// (used for filter changes and top-level fetches).
|
||||||
|
/// </param>
|
||||||
|
private bool TrySetSelectionAfterUpdate(ListViewModel sender, long version, bool forceFirstItem)
|
||||||
|
{
|
||||||
|
if (version != Volatile.Read(ref _itemsUpdatedVersion))
|
||||||
|
{
|
||||||
|
return true; // superseded by a newer update, nothing to do
|
||||||
|
}
|
||||||
|
|
||||||
|
var vm = ViewModel;
|
||||||
|
if (vm is null)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use the stable source of truth, not ItemView.Items (which can be transiently empty)
|
||||||
|
if (vm.FilteredItems.Count == 0)
|
||||||
|
{
|
||||||
|
using (SuppressSelectionChangedScope())
|
||||||
{
|
{
|
||||||
ItemView.SelectedIndex = -1;
|
ItemView.SelectedIndex = -1;
|
||||||
return;
|
_stickySelectedItem = null;
|
||||||
|
_lastPushedToVm = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Finds the first item that is not a separator
|
PushSelectionToVm();
|
||||||
var firstUsefulIndex = GetFirstSelectableIndex();
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
// If there is only separators in the list, don't select anything.
|
// If ItemView.Items hasn't caught up with the ObservableCollection yet,
|
||||||
if (firstUsefulIndex == -1)
|
// signal the caller to defer and retry.
|
||||||
|
var items = ItemView.Items;
|
||||||
|
if (items is null || items.Count == 0)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var firstUsefulIndex = GetFirstSelectableIndex();
|
||||||
|
if (firstUsefulIndex == -1)
|
||||||
|
{
|
||||||
|
using (SuppressSelectionChangedScope())
|
||||||
{
|
{
|
||||||
ItemView.SelectedIndex = -1;
|
ItemView.SelectedIndex = -1;
|
||||||
|
_stickySelectedItem = null;
|
||||||
return;
|
_lastPushedToVm = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
var shouldUpdateSelection = false;
|
PushSelectionToVm();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
// If it's a top level list update we force the reset to the top useful item
|
var shouldUpdateSelection = forceFirstItem;
|
||||||
if (!sender.IsNested)
|
|
||||||
|
if (!shouldUpdateSelection)
|
||||||
|
{
|
||||||
|
// Check if selection needs repair (item gone, null, or separator).
|
||||||
|
if (ItemView.SelectedItem is null)
|
||||||
{
|
{
|
||||||
shouldUpdateSelection = true;
|
shouldUpdateSelection = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// No current selection or current selection is null
|
|
||||||
else if (ItemView.SelectedItem is null)
|
|
||||||
{
|
|
||||||
shouldUpdateSelection = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// The current selected item is a separator
|
|
||||||
else if (IsSeparator(ItemView.SelectedItem))
|
else if (IsSeparator(ItemView.SelectedItem))
|
||||||
{
|
{
|
||||||
shouldUpdateSelection = true;
|
shouldUpdateSelection = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The selected item does not exist in the new list
|
|
||||||
else if (!items.Contains(ItemView.SelectedItem))
|
else if (!items.Contains(ItemView.SelectedItem))
|
||||||
{
|
{
|
||||||
shouldUpdateSelection = true;
|
shouldUpdateSelection = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (shouldUpdateSelection)
|
|
||||||
{
|
|
||||||
if (firstUsefulIndex != -1)
|
|
||||||
{
|
|
||||||
ItemView.SelectedIndex = firstUsefulIndex;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private void ViewModel_PropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
|
|
||||||
{
|
|
||||||
var prop = e.PropertyName;
|
|
||||||
if (prop == nameof(ViewModel.FilteredItems))
|
|
||||||
{
|
|
||||||
Debug.WriteLine($"ViewModel.FilteredItems {ItemView.SelectedItem}");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (shouldUpdateSelection)
|
||||||
|
{
|
||||||
|
using (SuppressSelectionChangedScope())
|
||||||
|
{
|
||||||
|
if (!forceFirstItem &&
|
||||||
|
_stickySelectedItem is not null &&
|
||||||
|
items.Contains(_stickySelectedItem) &&
|
||||||
|
!IsSeparator(_stickySelectedItem))
|
||||||
|
{
|
||||||
|
// Preserve sticky selection for nested dynamic updates.
|
||||||
|
ItemView.SelectedItem = _stickySelectedItem;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Select the first interactive item.
|
||||||
|
ItemView.SelectedItem = items[firstUsefulIndex];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent any pending "scroll on selection" logic from fighting this.
|
||||||
|
_scrollOnNextSelectionChange = false;
|
||||||
|
|
||||||
|
_ = DispatcherQueue.TryEnqueue(Microsoft.UI.Dispatching.DispatcherQueuePriority.Low, () =>
|
||||||
|
{
|
||||||
|
if (version != Volatile.Read(ref _itemsUpdatedVersion))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ResetScrollToTop();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
PushSelectionToVm();
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static ScrollViewer? FindScrollViewer(DependencyObject parent)
|
private static ScrollViewer? FindScrollViewer(DependencyObject parent)
|
||||||
@@ -653,7 +777,6 @@ public sealed partial class ListPage : Page,
|
|||||||
{
|
{
|
||||||
if (ItemView.Items.Count == 0)
|
if (ItemView.Items.Count == 0)
|
||||||
{
|
{
|
||||||
// No items, goodbye.
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -750,7 +873,6 @@ public sealed partial class ListPage : Page,
|
|||||||
if (bestIndex != currentIndex)
|
if (bestIndex != currentIndex)
|
||||||
{
|
{
|
||||||
ItemView.SelectedIndex = bestIndex;
|
ItemView.SelectedIndex = bestIndex;
|
||||||
ItemView.ScrollIntoView(ItemView.SelectedItem);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return;
|
return;
|
||||||
@@ -773,7 +895,6 @@ public sealed partial class ListPage : Page,
|
|||||||
if (fallback != currentIndex)
|
if (fallback != currentIndex)
|
||||||
{
|
{
|
||||||
ItemView.SelectedIndex = fallback;
|
ItemView.SelectedIndex = fallback;
|
||||||
ItemView.ScrollIntoView(ItemView.SelectedItem);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -797,7 +918,12 @@ public sealed partial class ListPage : Page,
|
|||||||
|
|
||||||
if (ItemView.SelectedItem != item)
|
if (ItemView.SelectedItem != item)
|
||||||
{
|
{
|
||||||
ItemView.SelectedItem = item;
|
_scrollOnNextSelectionChange = true;
|
||||||
|
|
||||||
|
using (SuppressSelectionChangedScope())
|
||||||
|
{
|
||||||
|
ItemView.SelectedItem = item;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!e.TryGetPosition(element, out var pos))
|
if (!e.TryGetPosition(element, out var pos))
|
||||||
@@ -811,7 +937,7 @@ public sealed partial class ListPage : Page,
|
|||||||
WeakReferenceMessenger.Default.Send<OpenContextMenuMessage>(
|
WeakReferenceMessenger.Default.Send<OpenContextMenuMessage>(
|
||||||
new OpenContextMenuMessage(
|
new OpenContextMenuMessage(
|
||||||
element,
|
element,
|
||||||
Microsoft.UI.Xaml.Controls.Primitives.FlyoutPlacementMode.BottomEdgeAlignedLeft,
|
FlyoutPlacementMode.BottomEdgeAlignedLeft,
|
||||||
pos,
|
pos,
|
||||||
ContextMenuFilterLocation.Top));
|
ContextMenuFilterLocation.Top));
|
||||||
});
|
});
|
||||||
@@ -844,6 +970,7 @@ public sealed partial class ListPage : Page,
|
|||||||
case VirtualKey.Up:
|
case VirtualKey.Up:
|
||||||
case VirtualKey.Down:
|
case VirtualKey.Down:
|
||||||
_lastInputSource = InputSource.Keyboard;
|
_lastInputSource = InputSource.Keyboard;
|
||||||
|
_scrollOnNextSelectionChange = true;
|
||||||
HandleGridArrowNavigation(e.Key);
|
HandleGridArrowNavigation(e.Key);
|
||||||
e.Handled = true;
|
e.Handled = true;
|
||||||
break;
|
break;
|
||||||
@@ -1027,6 +1154,31 @@ public sealed partial class ListPage : Page,
|
|||||||
|
|
||||||
private bool IsSeparator(object? item) => item is ListItemViewModel li && !li.IsInteractive;
|
private bool IsSeparator(object? item) => item is ListItemViewModel li && !li.IsInteractive;
|
||||||
|
|
||||||
|
private bool IsSectionHeader(object? item) => item is ListItemViewModel li && li.Type == ListItemType.SectionHeader;
|
||||||
|
|
||||||
|
private void ResetScrollToTop()
|
||||||
|
{
|
||||||
|
var scroll = FindScrollViewer(ItemView);
|
||||||
|
if (scroll is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// disableAnimation: true prevents a visible jump animation
|
||||||
|
scroll.ChangeView(horizontalOffset: null, verticalOffset: 0, zoomFactor: null, disableAnimation: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private IDisposable SuppressSelectionChangedScope()
|
||||||
|
{
|
||||||
|
_suppressSelectionChanged = true;
|
||||||
|
return new ActionOnDispose(() => _suppressSelectionChanged = false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed partial class ActionOnDispose(Action action) : IDisposable
|
||||||
|
{
|
||||||
|
public void Dispose() => action();
|
||||||
|
}
|
||||||
|
|
||||||
private enum InputSource
|
private enum InputSource
|
||||||
{
|
{
|
||||||
None,
|
None,
|
||||||
|
|||||||
@@ -277,49 +277,64 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
|
|||||||
|
|
||||||
public void Receive(ShowDetailsMessage message)
|
public void Receive(ShowDetailsMessage message)
|
||||||
{
|
{
|
||||||
if (ViewModel is not null &&
|
if (ViewModel is null || ViewModel.CurrentPage is null)
|
||||||
ViewModel.CurrentPage is not null)
|
|
||||||
{
|
{
|
||||||
if (ViewModel.CurrentPage.PageContext.TryGetTarget(out var pageContext))
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var details = message.Details;
|
||||||
|
var wasVisible = ViewModel.IsDetailsVisible;
|
||||||
|
|
||||||
|
// GH #322:
|
||||||
|
// For inexplicable reasons, if you try to change the details too fast,
|
||||||
|
// we'll explode. This seemingly only happens if you change the details
|
||||||
|
// while we're also scrolling a new list view item into view.
|
||||||
|
//
|
||||||
|
// Always debounce through the DispatcherQueue
|
||||||
|
// timer so the UI settles between updates. Use immediate=true for
|
||||||
|
// the first show so the panel appears without delay; subsequent
|
||||||
|
// updates during rapid navigation are coalesced.
|
||||||
|
_debounceTimer.Debounce(ShowDetails, interval: TimeSpan.FromMilliseconds(100), immediate: !wasVisible);
|
||||||
|
|
||||||
|
void ShowDetails()
|
||||||
|
{
|
||||||
|
// Since immediate=true means we're called synchronously from this method, we need to check
|
||||||
|
// if we're on the UI thread and re-queue if not.
|
||||||
|
if (!_queue.HasThreadAccess)
|
||||||
{
|
{
|
||||||
Task.Factory.StartNew(
|
var enqueued = _queue.TryEnqueue(ShowDetails);
|
||||||
() =>
|
if (!enqueued)
|
||||||
{
|
{
|
||||||
// TERRIBLE HACK TODO GH #245
|
Logger.LogError("Failed to enqueue show details action on UI thread");
|
||||||
// There's weird wacky bugs with debounce currently.
|
}
|
||||||
if (!ViewModel.IsDetailsVisible)
|
|
||||||
{
|
|
||||||
ViewModel.Details = message.Details;
|
|
||||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(HasHeroImage)));
|
|
||||||
ViewModel.IsDetailsVisible = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// GH #322:
|
return;
|
||||||
// For inexplicable reasons, if you try to change the details too fast,
|
}
|
||||||
// we'll explode. This seemingly only happens if you change the details
|
|
||||||
// while we're also scrolling a new list view item into view.
|
|
||||||
_debounceTimer.Debounce(
|
|
||||||
() =>
|
|
||||||
{
|
|
||||||
ViewModel.Details = message.Details;
|
|
||||||
|
|
||||||
// Trigger a re-evaluation of whether we have a hero image based on
|
try
|
||||||
// the current theme
|
{
|
||||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(HasHeroImage)));
|
ViewModel.Details = details;
|
||||||
},
|
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(HasHeroImage)));
|
||||||
interval: TimeSpan.FromMilliseconds(50),
|
ViewModel.IsDetailsVisible = true;
|
||||||
immediate: ViewModel.IsDetailsVisible == false);
|
}
|
||||||
ViewModel.IsDetailsVisible = true;
|
catch (Exception ex)
|
||||||
},
|
{
|
||||||
CancellationToken.None,
|
Logger.LogError("Failed to show detail", ex);
|
||||||
TaskCreationOptions.None,
|
|
||||||
pageContext.Scheduler);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Receive(HideDetailsMessage message) => HideDetails();
|
public void Receive(HideDetailsMessage message)
|
||||||
|
{
|
||||||
|
// Debounce the hide through the same timer used for show. If a
|
||||||
|
// ShowDetailsMessage arrives before this fires, it cancels the
|
||||||
|
// pending hide - preventing the panel from flickering closed and
|
||||||
|
// reopened during rapid item navigation.
|
||||||
|
_debounceTimer.Debounce(
|
||||||
|
() => HideDetails(),
|
||||||
|
interval: TimeSpan.FromMilliseconds(150),
|
||||||
|
immediate: false);
|
||||||
|
}
|
||||||
|
|
||||||
public void Receive(LaunchUriMessage message) => _ = global::Windows.System.Launcher.LaunchUriAsync(message.Uri);
|
public void Receive(LaunchUriMessage message) => _ = global::Windows.System.Launcher.LaunchUriAsync(message.Uri);
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// Copyright (c) Microsoft Corporation
|
// Copyright (c) Microsoft Corporation
|
||||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||||
// See the LICENSE file in the project root for more information.
|
// See the LICENSE file in the project root for more information.
|
||||||
|
|
||||||
@@ -17,6 +17,9 @@ namespace Microsoft.CmdPal.UI.ViewModels.UnitTests;
|
|||||||
[TestClass]
|
[TestClass]
|
||||||
public partial class MainListPageResultFactoryTests
|
public partial class MainListPageResultFactoryTests
|
||||||
{
|
{
|
||||||
|
private static readonly Separator _resultsSeparator = new("Results");
|
||||||
|
private static readonly Separator _fallbacksSeparator = new("Fallbacks");
|
||||||
|
|
||||||
private sealed partial class MockListItem : IListItem
|
private sealed partial class MockListItem : IListItem
|
||||||
{
|
{
|
||||||
public string Title { get; set; } = string.Empty;
|
public string Title { get; set; } = string.Empty;
|
||||||
@@ -82,18 +85,22 @@ public partial class MainListPageResultFactoryTests
|
|||||||
scoredFallback,
|
scoredFallback,
|
||||||
apps,
|
apps,
|
||||||
fallbacks,
|
fallbacks,
|
||||||
|
_resultsSeparator,
|
||||||
|
_fallbacksSeparator,
|
||||||
appResultLimit: 10);
|
appResultLimit: 10);
|
||||||
|
|
||||||
// Expected order:
|
// Expected order:
|
||||||
|
// "Results" section header
|
||||||
// 100: F1, SF1, A1
|
// 100: F1, SF1, A1
|
||||||
// 60: SF2
|
// 60: SF2
|
||||||
// 55: A2
|
// 55: A2
|
||||||
// 50: F2
|
// 50: F2
|
||||||
|
// "Fallbacks" section header
|
||||||
// Then fallbacks in original order: FB1, FB2
|
// Then fallbacks in original order: FB1, FB2
|
||||||
var titles = result.Select(r => r.Title).ToArray();
|
var titles = result.Select(r => r.Title).ToArray();
|
||||||
#pragma warning disable CA1861 // Avoid constant arrays as arguments
|
#pragma warning disable CA1861 // Avoid constant arrays as arguments
|
||||||
CollectionAssert.AreEqual(
|
CollectionAssert.AreEqual(
|
||||||
new[] { "F1", "SF1", "A1", "SF2", "A2", "F2", "Fallbacks", "FB1", "FB2" },
|
new[] { "Results", "F1", "SF1", "A1", "SF2", "A2", "F2", "Fallbacks", "FB1", "FB2" },
|
||||||
titles);
|
titles);
|
||||||
#pragma warning restore CA1861 // Avoid constant arrays as arguments
|
#pragma warning restore CA1861 // Avoid constant arrays as arguments
|
||||||
}
|
}
|
||||||
@@ -113,11 +120,14 @@ public partial class MainListPageResultFactoryTests
|
|||||||
null,
|
null,
|
||||||
apps,
|
apps,
|
||||||
null,
|
null,
|
||||||
|
_resultsSeparator,
|
||||||
|
_fallbacksSeparator,
|
||||||
2);
|
2);
|
||||||
|
|
||||||
Assert.AreEqual(2, result.Length);
|
Assert.AreEqual(3, result.Length);
|
||||||
Assert.AreEqual("A1", result[0].Title);
|
Assert.AreEqual("Results", result[0].Title);
|
||||||
Assert.AreEqual("A2", result[1].Title);
|
Assert.AreEqual("A1", result[1].Title);
|
||||||
|
Assert.AreEqual("A2", result[2].Title);
|
||||||
}
|
}
|
||||||
|
|
||||||
[TestMethod]
|
[TestMethod]
|
||||||
@@ -135,10 +145,13 @@ public partial class MainListPageResultFactoryTests
|
|||||||
null,
|
null,
|
||||||
apps,
|
apps,
|
||||||
null,
|
null,
|
||||||
|
_resultsSeparator,
|
||||||
|
_fallbacksSeparator,
|
||||||
appResultLimit: 1);
|
appResultLimit: 1);
|
||||||
|
|
||||||
Assert.AreEqual(1, result.Length);
|
Assert.AreEqual(2, result.Length);
|
||||||
Assert.AreEqual("A1", result[0].Title);
|
Assert.AreEqual("Results", result[0].Title);
|
||||||
|
Assert.AreEqual("A1", result[1].Title);
|
||||||
}
|
}
|
||||||
|
|
||||||
[TestMethod]
|
[TestMethod]
|
||||||
@@ -155,6 +168,8 @@ public partial class MainListPageResultFactoryTests
|
|||||||
null,
|
null,
|
||||||
apps,
|
apps,
|
||||||
null,
|
null,
|
||||||
|
_resultsSeparator,
|
||||||
|
_fallbacksSeparator,
|
||||||
appResultLimit: 0);
|
appResultLimit: 0);
|
||||||
|
|
||||||
Assert.AreEqual(0, result.Length);
|
Assert.AreEqual(0, result.Length);
|
||||||
@@ -181,12 +196,15 @@ public partial class MainListPageResultFactoryTests
|
|||||||
null,
|
null,
|
||||||
apps,
|
apps,
|
||||||
null,
|
null,
|
||||||
|
_resultsSeparator,
|
||||||
|
_fallbacksSeparator,
|
||||||
appResultLimit: 1);
|
appResultLimit: 1);
|
||||||
|
|
||||||
Assert.AreEqual(3, result.Length);
|
Assert.AreEqual(4, result.Length);
|
||||||
Assert.AreEqual("F1", result[0].Title);
|
Assert.AreEqual("Results", result[0].Title);
|
||||||
Assert.AreEqual("A1", result[1].Title);
|
Assert.AreEqual("F1", result[1].Title);
|
||||||
Assert.AreEqual("F2", result[2].Title);
|
Assert.AreEqual("A1", result[2].Title);
|
||||||
|
Assert.AreEqual("F2", result[3].Title);
|
||||||
}
|
}
|
||||||
|
|
||||||
[TestMethod]
|
[TestMethod]
|
||||||
@@ -203,6 +221,8 @@ public partial class MainListPageResultFactoryTests
|
|||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
fallbacks,
|
fallbacks,
|
||||||
|
_resultsSeparator,
|
||||||
|
_fallbacksSeparator,
|
||||||
appResultLimit: 10);
|
appResultLimit: 10);
|
||||||
|
|
||||||
Assert.AreEqual(3, result.Length);
|
Assert.AreEqual(3, result.Length);
|
||||||
@@ -219,6 +239,8 @@ public partial class MainListPageResultFactoryTests
|
|||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
|
_resultsSeparator,
|
||||||
|
_fallbacksSeparator,
|
||||||
appResultLimit: 10);
|
appResultLimit: 10);
|
||||||
|
|
||||||
Assert.IsNotNull(result);
|
Assert.IsNotNull(result);
|
||||||
|
|||||||
@@ -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.
|
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||||
// See the LICENSE file in the project root for more information.
|
// See the LICENSE file in the project root for more information.
|
||||||
|
|
||||||
|
using System.Collections.ObjectModel;
|
||||||
|
|
||||||
namespace Microsoft.CommandPalette.Extensions.Toolkit;
|
namespace Microsoft.CommandPalette.Extensions.Toolkit;
|
||||||
|
|
||||||
public partial class ListHelpers
|
public partial class ListHelpers
|
||||||
@@ -58,84 +60,91 @@ public partial class ListHelpers
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Modifies the contents of `original` in-place, to match those of
|
/// Modifies the contents of <paramref name="original"/> in-place, to match those of
|
||||||
/// `newContents`. The canonical use being:
|
/// <paramref name="newContents"/>.
|
||||||
/// ```cs
|
/// <example>
|
||||||
|
/// The canonical use being:
|
||||||
|
/// <code>
|
||||||
/// ListHelpers.InPlaceUpdateList(FilteredItems, FilterList(ItemsToFilter, TextToFilterOn));
|
/// ListHelpers.InPlaceUpdateList(FilteredItems, FilterList(ItemsToFilter, TextToFilterOn));
|
||||||
/// ```
|
/// </code>
|
||||||
|
/// </example>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <typeparam name="T">Any type that can be compared for equality</typeparam>
|
/// <typeparam name="T">Any type that can be compared for equality</typeparam>
|
||||||
/// <param name="original">Collection to modify</param>
|
/// <param name="original">Collection to modify</param>
|
||||||
/// <param name="newContents">The enumerable which `original` should match</param>
|
/// <param name="newContents">The enumerable which <c>original</c> should match</param>
|
||||||
public static void InPlaceUpdateList<T>(IList<T> original, IEnumerable<T> newContents)
|
public static void InPlaceUpdateList<T>(IList<T> original, IEnumerable<T> newContents)
|
||||||
where T : class
|
where T : class
|
||||||
{
|
{
|
||||||
InPlaceUpdateList(original, newContents, out _);
|
// Materialize once to avoid re-enumeration
|
||||||
}
|
var newList = newContents as IList<T> ?? newContents.ToList();
|
||||||
|
var numberOfNew = newList.Count;
|
||||||
|
|
||||||
/// <summary>
|
// Detect if we can use Move() for better ObservableCollection performance
|
||||||
/// Modifies the contents of `original` in-place, to match those of
|
var observableCollection = original as ObservableCollection<T>;
|
||||||
/// `newContents`. The canonical use being:
|
|
||||||
/// ```cs
|
|
||||||
/// ListHelpers.InPlaceUpdateList(FilteredItems, FilterList(ItemsToFilter, TextToFilterOn));
|
|
||||||
/// ```
|
|
||||||
/// </summary>
|
|
||||||
/// <typeparam name="T">Any type that can be compared for equality</typeparam>
|
|
||||||
/// <param name="original">Collection to modify</param>
|
|
||||||
/// <param name="newContents">The enumerable which `original` should match</param>
|
|
||||||
/// <param name="removedItems">List of items that were removed from the original collection</param>
|
|
||||||
public static void InPlaceUpdateList<T>(IList<T> original, IEnumerable<T> newContents, out List<T> removedItems)
|
|
||||||
where T : class
|
|
||||||
{
|
|
||||||
removedItems = [];
|
|
||||||
|
|
||||||
// we're not changing newContents - stash this so we don't re-evaluate it every time
|
|
||||||
var numberOfNew = newContents.Count();
|
|
||||||
|
|
||||||
// Short circuit - new contents should just be empty
|
// Short circuit - new contents should just be empty
|
||||||
if (numberOfNew == 0)
|
if (numberOfNew == 0)
|
||||||
{
|
{
|
||||||
removedItems.AddRange(original);
|
if (observableCollection is not null)
|
||||||
original.Clear();
|
{
|
||||||
|
// Clear() is observable collection causes a reset notification, which causes
|
||||||
|
// the ListView to discard all containers and recreate them, which is expensive and
|
||||||
|
// causes ListView to flash.
|
||||||
|
while (observableCollection.Count > 0)
|
||||||
|
{
|
||||||
|
observableCollection.RemoveAt(observableCollection.Count - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
original.Clear();
|
||||||
|
}
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Simple forward-scan merge. No HashSet needed because we don't track
|
||||||
|
// removed items — the icon-bug guard is unnecessary, and items removed
|
||||||
|
// mid-merge that appear later in newList will simply be re-inserted.
|
||||||
var i = 0;
|
var i = 0;
|
||||||
foreach (var newItem in newContents)
|
for (var newIndex = 0; newIndex < numberOfNew; newIndex++)
|
||||||
{
|
{
|
||||||
|
var newItem = newList[newIndex];
|
||||||
|
|
||||||
if (i >= original.Count)
|
if (i >= original.Count)
|
||||||
{
|
{
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Search for this item in the remaining portion of original
|
||||||
|
var foundIndex = -1;
|
||||||
for (var j = i; j < original.Count; j++)
|
for (var j = i; j < original.Count; j++)
|
||||||
{
|
{
|
||||||
var og_2 = original[j];
|
if (original[j]?.Equals(newItem) ?? false)
|
||||||
var areEqual_2 = og_2?.Equals(newItem) ?? false;
|
|
||||||
if (areEqual_2)
|
|
||||||
{
|
{
|
||||||
for (var k = i; k < j; k++)
|
foundIndex = j;
|
||||||
{
|
|
||||||
// This item from the original list was not in the new list. Remove it.
|
|
||||||
removedItems.Add(original[i]);
|
|
||||||
original.RemoveAt(i);
|
|
||||||
}
|
|
||||||
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var og = original[i];
|
if (foundIndex >= 0)
|
||||||
var areEqual = og?.Equals(newItem) ?? false;
|
|
||||||
|
|
||||||
// Is this new item already in the list?
|
|
||||||
if (areEqual)
|
|
||||||
{
|
{
|
||||||
// It is already in the list
|
// Remove all items between i and foundIndex
|
||||||
|
for (var k = foundIndex - 1; k >= i; k--)
|
||||||
|
{
|
||||||
|
original.RemoveAt(k);
|
||||||
|
foundIndex--;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the found item isn't at position i yet, move it there
|
||||||
|
if (foundIndex > i)
|
||||||
|
{
|
||||||
|
MoveItem(original, observableCollection, foundIndex, i);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
// it isn't. Add it.
|
// Not found - insert new item at position i
|
||||||
original.Insert(i, newItem);
|
original.Insert(i, newItem);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,19 +154,283 @@ public partial class ListHelpers
|
|||||||
// Remove any extra trailing items from the destination
|
// Remove any extra trailing items from the destination
|
||||||
while (original.Count > numberOfNew)
|
while (original.Count > numberOfNew)
|
||||||
{
|
{
|
||||||
// RemoveAtEnd
|
|
||||||
removedItems.Add(original[original.Count - 1]);
|
|
||||||
original.RemoveAt(original.Count - 1);
|
original.RemoveAt(original.Count - 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add any extra trailing items from the source
|
// Add any extra trailing items from the source
|
||||||
if (original.Count < numberOfNew)
|
while (i < numberOfNew)
|
||||||
{
|
{
|
||||||
var remaining = newContents.Skip(original.Count);
|
original.Add(newList[i]);
|
||||||
foreach (var item in remaining)
|
i++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Modifies the contents of <paramref name="original"/> in-place, to match those of
|
||||||
|
/// <paramref name="newContents"/>.
|
||||||
|
/// <example>
|
||||||
|
/// The canonical use being:
|
||||||
|
/// <code>
|
||||||
|
/// ListHelpers.InPlaceUpdateList(FilteredItems, FilterList(ItemsToFilter, TextToFilterOn), out var removedItems);
|
||||||
|
/// </code>
|
||||||
|
/// </example>
|
||||||
|
/// </summary>
|
||||||
|
/// <typeparam name="T">Any type that can be compared for equality</typeparam>
|
||||||
|
/// <param name="original">Collection to modify</param>
|
||||||
|
/// <param name="newContents">The enumerable which <c>original</c> should match</param>
|
||||||
|
/// <param name="removedItems">List of items that were removed from the original collection</param>
|
||||||
|
public static void InPlaceUpdateList<T>(IList<T> original, IEnumerable<T> newContents, out List<T> removedItems)
|
||||||
|
where T : class
|
||||||
|
{
|
||||||
|
removedItems = [];
|
||||||
|
|
||||||
|
// Materialize once to avoid re-enumeration
|
||||||
|
var newList = newContents as IList<T> ?? newContents.ToList();
|
||||||
|
var numberOfNew = newList.Count;
|
||||||
|
|
||||||
|
// Short circuit - new contents should just be empty
|
||||||
|
if (numberOfNew == 0)
|
||||||
|
{
|
||||||
|
while (original.Count > 0)
|
||||||
{
|
{
|
||||||
original.Add(item);
|
removedItems.Add(original[^1]);
|
||||||
|
original.RemoveAt(original.Count - 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect if we can use Move() for better ObservableCollection performance
|
||||||
|
var observableCollection = original as ObservableCollection<T>;
|
||||||
|
|
||||||
|
// Build a set of new items for O(1) existence checks.
|
||||||
|
var newSet = new HashSet<T>(numberOfNew);
|
||||||
|
for (var i = 0; i < numberOfNew; i++)
|
||||||
|
{
|
||||||
|
newSet.Add(newList[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// When there is zero overlap (e.g. navigating between pages), use
|
||||||
|
// indexed replacement instead of Insert + Remove. Each Replace reuses
|
||||||
|
// the container slot and fires one notification instead of two.
|
||||||
|
var hasOverlap = false;
|
||||||
|
for (var i = 0; i < original.Count; i++)
|
||||||
|
{
|
||||||
|
if (newSet.Contains(original[i]))
|
||||||
|
{
|
||||||
|
hasOverlap = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasOverlap)
|
||||||
|
{
|
||||||
|
var minLen = Math.Min(original.Count, numberOfNew);
|
||||||
|
for (var i = 0; i < minLen; i++)
|
||||||
|
{
|
||||||
|
removedItems.Add(original[i]);
|
||||||
|
original[i] = newList[i]; // Replace — single notification, container reused
|
||||||
|
}
|
||||||
|
|
||||||
|
while (original.Count > numberOfNew)
|
||||||
|
{
|
||||||
|
removedItems.Add(original[^1]);
|
||||||
|
original.RemoveAt(original.Count - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var i = minLen; i < numberOfNew; i++)
|
||||||
|
{
|
||||||
|
original.Add(newList[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Large collections benefit from pre-filtering (O(n) removal shrinks the
|
||||||
|
// working set), which outweighs the extra pass. Small collections are
|
||||||
|
// faster with lazy removal during the merge. Threshold determined empirically.
|
||||||
|
if (original.Count >= 5000)
|
||||||
|
{
|
||||||
|
MergeWithPreRemoval(original, newList, numberOfNew, newSet, observableCollection, removedItems);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
MergeWithLazyRemoval(original, newList, numberOfNew, newSet, observableCollection, removedItems);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fast path for small/medium collections. Removes non-matching items lazily
|
||||||
|
/// during the forward-scan merge, avoiding a separate pre-removal pass.
|
||||||
|
/// </summary>
|
||||||
|
private static void MergeWithLazyRemoval<T>(
|
||||||
|
IList<T> original,
|
||||||
|
IList<T> newList,
|
||||||
|
int numberOfNew,
|
||||||
|
HashSet<T> newSet,
|
||||||
|
ObservableCollection<T>? observableCollection,
|
||||||
|
List<T>? removedItems)
|
||||||
|
where T : class
|
||||||
|
{
|
||||||
|
var i = 0;
|
||||||
|
for (var newIndex = 0; newIndex < numberOfNew; newIndex++)
|
||||||
|
{
|
||||||
|
var newItem = newList[newIndex];
|
||||||
|
|
||||||
|
if (i >= original.Count)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search for this item in the remaining portion of original
|
||||||
|
var foundIndex = -1;
|
||||||
|
for (var j = i; j < original.Count; j++)
|
||||||
|
{
|
||||||
|
if (original[j]?.Equals(newItem) ?? false)
|
||||||
|
{
|
||||||
|
foundIndex = j;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (foundIndex >= 0)
|
||||||
|
{
|
||||||
|
// Remove only items between i and foundIndex that are NOT in newList.
|
||||||
|
// Items still needed later stay in the collection, avoiding
|
||||||
|
// unnecessary Remove+Insert cycles and extra UI notifications.
|
||||||
|
for (var k = foundIndex - 1; k >= i; k--)
|
||||||
|
{
|
||||||
|
if (!newSet.Contains(original[k]))
|
||||||
|
{
|
||||||
|
removedItems?.Add(original[k]);
|
||||||
|
original.RemoveAt(k);
|
||||||
|
foundIndex--;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the found item isn't at position i yet, move it there
|
||||||
|
if (foundIndex > i)
|
||||||
|
{
|
||||||
|
MoveItem(original, observableCollection, foundIndex, i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Not found - insert new item at position i
|
||||||
|
original.Insert(i, newItem);
|
||||||
|
}
|
||||||
|
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove any extra trailing items from the destination
|
||||||
|
while (original.Count > numberOfNew)
|
||||||
|
{
|
||||||
|
removedItems?.Add(original[^1]);
|
||||||
|
original.RemoveAt(original.Count - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add any extra trailing items from the source
|
||||||
|
while (i < numberOfNew)
|
||||||
|
{
|
||||||
|
original.Add(newList[i]);
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Path for large collections. Pre-removes non-matching items to shrink the
|
||||||
|
/// working set before merging, making linear searches faster.
|
||||||
|
/// </summary>
|
||||||
|
private static void MergeWithPreRemoval<T>(
|
||||||
|
IList<T> original,
|
||||||
|
IList<T> newList,
|
||||||
|
int numberOfNew,
|
||||||
|
HashSet<T> newSet,
|
||||||
|
ObservableCollection<T>? observableCollection,
|
||||||
|
List<T>? removedItems)
|
||||||
|
where T : class
|
||||||
|
{
|
||||||
|
// Pre-remove items that are not in newList. Iterating backwards keeps
|
||||||
|
// earlier indices stable and shrinks the working set for the merge loop.
|
||||||
|
for (var i = original.Count - 1; i >= 0; i--)
|
||||||
|
{
|
||||||
|
if (!newSet.Contains(original[i]))
|
||||||
|
{
|
||||||
|
removedItems?.Add(original[i]);
|
||||||
|
original.RemoveAt(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Forward-scan merge: move or insert items to match newList order.
|
||||||
|
// After pre-removal, original only contains items that exist in newList,
|
||||||
|
// so the merge loop is simple — just Move or Insert.
|
||||||
|
for (var i = 0; i < numberOfNew; i++)
|
||||||
|
{
|
||||||
|
var newItem = newList[i];
|
||||||
|
|
||||||
|
// If we've run out of original items, append the rest
|
||||||
|
if (i >= original.Count)
|
||||||
|
{
|
||||||
|
original.Add(newItem);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Already correct?
|
||||||
|
if (original[i]?.Equals(newItem) ?? false)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the item later in the original list
|
||||||
|
var foundIndex = -1;
|
||||||
|
for (var j = i + 1; j < original.Count; j++)
|
||||||
|
{
|
||||||
|
if (original[j]?.Equals(newItem) ?? false)
|
||||||
|
{
|
||||||
|
foundIndex = j;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (foundIndex >= 0)
|
||||||
|
{
|
||||||
|
MoveItem(original, observableCollection, foundIndex, i);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not found: insert new item at i
|
||||||
|
original.Insert(i, newItem);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove any extra trailing items from the destination
|
||||||
|
while (original.Count > numberOfNew)
|
||||||
|
{
|
||||||
|
removedItems?.Add(original[^1]);
|
||||||
|
original.RemoveAt(original.Count - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Moves an item from <paramref name="fromIndex"/> to <paramref name="toIndex"/>.
|
||||||
|
/// Uses ObservableCollection.Move() when available for a single notification,
|
||||||
|
/// otherwise falls back to RemoveAt + Insert.
|
||||||
|
/// </summary>
|
||||||
|
private static void MoveItem<T>(
|
||||||
|
IList<T> original,
|
||||||
|
ObservableCollection<T>? observableCollection,
|
||||||
|
int fromIndex,
|
||||||
|
int toIndex)
|
||||||
|
{
|
||||||
|
if (observableCollection is not null)
|
||||||
|
{
|
||||||
|
observableCollection.Move(fromIndex, toIndex);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var item = original[fromIndex];
|
||||||
|
original.RemoveAt(fromIndex);
|
||||||
|
original.Insert(toIndex, item);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user