mirror of
https://github.com/microsoft/PowerToys.git
synced 2026-04-03 01:36:31 +02:00
860 lines
28 KiB
C#
860 lines
28 KiB
C#
// 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.Collections.ObjectModel;
|
|
using System.Runtime.CompilerServices;
|
|
using CommunityToolkit.Mvvm.Input;
|
|
using CommunityToolkit.Mvvm.Messaging;
|
|
using Microsoft.CmdPal.Common;
|
|
using Microsoft.CmdPal.Common.Helpers;
|
|
using Microsoft.CmdPal.UI.ViewModels.Messages;
|
|
using Microsoft.CmdPal.UI.ViewModels.Models;
|
|
using Microsoft.CommandPalette.Extensions;
|
|
using Microsoft.CommandPalette.Extensions.Toolkit;
|
|
using Windows.Foundation;
|
|
|
|
namespace Microsoft.CmdPal.UI.ViewModels;
|
|
|
|
public partial class ListViewModel : PageViewModel, IDisposable
|
|
{
|
|
private readonly TaskFactory filterTaskFactory = new(new ConcurrentExclusiveSchedulerPair().ExclusiveScheduler);
|
|
|
|
private readonly Dictionary<IListItem, ListItemViewModel> _vmCache = new(new ProxyReferenceEqualityComparer());
|
|
|
|
// TODO: Do we want a base "ItemsPageViewModel" for anything that's going to have items?
|
|
|
|
// Observable from MVVM Toolkit will auto create public properties that use INotifyPropertyChange change
|
|
// https://learn.microsoft.com/dotnet/communitytoolkit/mvvm/observablegroupedcollections for grouping support
|
|
public ObservableCollection<ListItemViewModel> FilteredItems { get; } = [];
|
|
|
|
public FiltersViewModel? Filters { get; set; }
|
|
|
|
private ObservableCollection<ListItemViewModel> Items { get; set; } = [];
|
|
|
|
private readonly ExtensionObject<IListPage> _model;
|
|
|
|
private readonly Lock _listLock = new();
|
|
private readonly IContextMenuFactory _contextMenuFactory;
|
|
|
|
private InterlockedBoolean _isLoading;
|
|
private bool _isFetching;
|
|
|
|
public event TypedEventHandler<ListViewModel, ItemsUpdatedEventArgs>? ItemsUpdated;
|
|
|
|
public bool ShowEmptyContent =>
|
|
IsInitialized &&
|
|
FilteredItems.Count == 0 &&
|
|
(!_isFetching) &&
|
|
IsLoading == false;
|
|
|
|
public bool IsGridView { get; private set; }
|
|
|
|
public IGridPropertiesViewModel? GridProperties { get; private set; }
|
|
|
|
// Remember - "observable" properties from the model (via PropChanged)
|
|
// cannot be marked [ObservableProperty]
|
|
public bool ShowDetails { get; private set; }
|
|
|
|
private string _modelPlaceholderText = string.Empty;
|
|
|
|
public override string PlaceholderText => _modelPlaceholderText;
|
|
|
|
public string SearchText { get; private set; } = string.Empty;
|
|
|
|
public string InitialSearchText { get; private set; } = string.Empty;
|
|
|
|
public CommandItemViewModel EmptyContent { get; private set; }
|
|
|
|
public bool IsMainPage { get; init; }
|
|
|
|
private bool _isDynamic;
|
|
|
|
private Task? _initializeItemsTask;
|
|
|
|
// For cancelling the task to load the properties from the items in the list
|
|
private CancellationTokenSource? _cancellationTokenSource;
|
|
|
|
// For cancelling the task for calling GetItems on the extension
|
|
private CancellationTokenSource? _fetchItemsCancellationTokenSource;
|
|
|
|
// For cancelling ongoing calls to update the extension's SearchText
|
|
private CancellationTokenSource? filterCancellationTokenSource;
|
|
|
|
private ListItemViewModel? _lastSelectedItem;
|
|
|
|
// For cancelling a deferred SafeSlowInit when the user navigates rapidly
|
|
private CancellationTokenSource? _selectedItemCts;
|
|
|
|
public override bool IsInitialized
|
|
{
|
|
get => base.IsInitialized; protected set
|
|
{
|
|
base.IsInitialized = value;
|
|
UpdateEmptyContent();
|
|
}
|
|
}
|
|
|
|
public ListViewModel(IListPage model, TaskScheduler scheduler, AppExtensionHost host, ICommandProviderContext providerContext, IContextMenuFactory contextMenuFactory)
|
|
: base(model, scheduler, host, providerContext)
|
|
{
|
|
_model = new(model);
|
|
_contextMenuFactory = contextMenuFactory;
|
|
EmptyContent = new(new(null), PageContext, contextMenuFactory: null);
|
|
}
|
|
|
|
private void FiltersPropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
|
|
{
|
|
if (e.PropertyName == nameof(FiltersViewModel.Filters))
|
|
{
|
|
var filtersViewModel = sender as FiltersViewModel;
|
|
var hasFilters = filtersViewModel?.Filters.Length > 0;
|
|
HasFilters = hasFilters;
|
|
UpdateProperty(nameof(HasFilters));
|
|
}
|
|
}
|
|
|
|
// TODO: Does this need to hop to a _different_ thread, so that we don't block the extension while we're fetching?
|
|
private void Model_ItemsChanged(object sender, IItemsChangedEventArgs args) => FetchItems();
|
|
|
|
protected override void OnSearchTextBoxUpdated(string searchTextBox)
|
|
{
|
|
// Dynamic pages will handler their own filtering. They will tell us if
|
|
// something needs to change, by raising ItemsChanged.
|
|
if (_isDynamic)
|
|
{
|
|
filterCancellationTokenSource?.Cancel();
|
|
filterCancellationTokenSource?.Dispose();
|
|
filterCancellationTokenSource = new CancellationTokenSource();
|
|
|
|
// Hop off to an exclusive scheduler background thread to update the
|
|
// extension. We do this to ensure that all filter update requests
|
|
// are serialized and in-order, so providers know to cancel previous
|
|
// requests when a new one comes in. Otherwise, they may execute
|
|
// concurrently.
|
|
_ = filterTaskFactory.StartNew(
|
|
() =>
|
|
{
|
|
filterCancellationTokenSource.Token.ThrowIfCancellationRequested();
|
|
|
|
try
|
|
{
|
|
if (_model.Unsafe is IDynamicListPage dynamic)
|
|
{
|
|
dynamic.SearchText = searchTextBox;
|
|
}
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
ShowException(ex, _model?.Unsafe?.Name);
|
|
}
|
|
},
|
|
filterCancellationTokenSource.Token,
|
|
TaskCreationOptions.None,
|
|
filterTaskFactory.Scheduler!);
|
|
}
|
|
else
|
|
{
|
|
// But for all normal pages, we should run our fuzzy match on them.
|
|
lock (_listLock)
|
|
{
|
|
ApplyFilterUnderLock();
|
|
}
|
|
|
|
ItemsUpdated?.Invoke(this, new ItemsUpdatedEventArgs(true));
|
|
UpdateEmptyContent();
|
|
_isLoading.Clear();
|
|
}
|
|
}
|
|
|
|
public void UpdateCurrentFilter(string currentFilterId)
|
|
{
|
|
// We're getting called on the UI thread.
|
|
// Hop off to a BG thread to update the extension.
|
|
_ = Task.Run(() =>
|
|
{
|
|
try
|
|
{
|
|
if (_model.Unsafe is IListPage listPage)
|
|
{
|
|
listPage.Filters?.CurrentFilterId = currentFilterId;
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
ShowException(ex, _model?.Unsafe?.Name);
|
|
}
|
|
});
|
|
}
|
|
|
|
//// Run on background thread, from InitializeAsync or Model_ItemsChanged
|
|
private void FetchItems()
|
|
{
|
|
// Cancel any previous FetchItems operation
|
|
_fetchItemsCancellationTokenSource?.Cancel();
|
|
_fetchItemsCancellationTokenSource?.Dispose();
|
|
_fetchItemsCancellationTokenSource = new CancellationTokenSource();
|
|
|
|
var cancellationToken = _fetchItemsCancellationTokenSource.Token;
|
|
|
|
_isFetching = true;
|
|
|
|
// Collect all the items into new viewmodels
|
|
List<ListItemViewModel> newViewModels = [];
|
|
|
|
try
|
|
{
|
|
// Check for cancellation before starting expensive operations
|
|
if (cancellationToken.IsCancellationRequested)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var newItems = _model.Unsafe!.GetItems();
|
|
|
|
// Check for cancellation after getting items from extension
|
|
if (cancellationToken.IsCancellationRequested)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var showsTitle = GridProperties?.ShowTitle ?? true;
|
|
var showsSubtitle = GridProperties?.ShowSubtitle ?? true;
|
|
var created = 0;
|
|
var reused = 0;
|
|
foreach (var item in newItems)
|
|
{
|
|
try
|
|
{
|
|
if (item is null)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
// Check for cancellation during item processing
|
|
if (cancellationToken.IsCancellationRequested)
|
|
{
|
|
return;
|
|
}
|
|
|
|
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), _contextMenuFactory);
|
|
|
|
// If an item fails to load, silently ignore it.
|
|
if (viewModel.SafeFastInit())
|
|
{
|
|
viewModel.LayoutShowsTitle = showsTitle;
|
|
viewModel.LayoutShowsSubtitle = showsSubtitle;
|
|
|
|
_vmCache[item] = viewModel;
|
|
newViewModels.Add(viewModel);
|
|
created++;
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
CoreLogger.LogError("Failed to load item:\n", ex + ToString());
|
|
}
|
|
}
|
|
|
|
#if DEBUG
|
|
CoreLogger.LogInfo($"[ListViewModel] FetchItems: {created} created, {reused} reused, {_vmCache.Count} cached");
|
|
#endif
|
|
|
|
// Check for cancellation before initializing first twenty items
|
|
if (cancellationToken.IsCancellationRequested)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var firstTwenty = newViewModels.Take(20);
|
|
foreach (var item in firstTwenty)
|
|
{
|
|
if (cancellationToken.IsCancellationRequested)
|
|
{
|
|
return;
|
|
}
|
|
|
|
item?.SafeInitializeProperties();
|
|
}
|
|
|
|
// Cancel any ongoing search
|
|
_cancellationTokenSource?.Cancel();
|
|
|
|
// Check for cancellation before updating the list
|
|
if (cancellationToken.IsCancellationRequested)
|
|
{
|
|
return;
|
|
}
|
|
|
|
List<ListItemViewModel> removedItems;
|
|
lock (_listLock)
|
|
{
|
|
// Now that we have new ViewModels for everything from the
|
|
// extension, smartly update our list of VMs
|
|
ListHelpers.InPlaceUpdateList(Items, newViewModels, out removedItems);
|
|
|
|
_vmCache.Clear();
|
|
foreach (var vm in newViewModels)
|
|
{
|
|
if (vm.Model.Unsafe is { } li)
|
|
{
|
|
_vmCache[li] = vm;
|
|
}
|
|
}
|
|
|
|
// DO NOT ThrowIfCancellationRequested AFTER THIS! If you do,
|
|
// you'll clean up list items that we've now transferred into
|
|
// .Items
|
|
}
|
|
|
|
// If we removed items, we need to clean them up, to remove our event handlers
|
|
foreach (var removedItem in removedItems)
|
|
{
|
|
removedItem.SafeCleanup();
|
|
}
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
// Cancellation is expected, don't treat as error
|
|
|
|
// However, if we were cancelled, we didn't actually add these items to
|
|
// our Items list. Before we release them to the GC, make sure we clean
|
|
// them up
|
|
foreach (var vm in newViewModels)
|
|
{
|
|
vm.SafeCleanup();
|
|
}
|
|
|
|
return;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
// TODO: Move this within the for loop, so we can catch issues with individual items
|
|
// Create a special ListItemViewModel for errors and use an ItemTemplateSelector in the ListPage to display error items differently.
|
|
ShowException(ex, _model?.Unsafe?.Name);
|
|
throw;
|
|
}
|
|
finally
|
|
{
|
|
_isFetching = false;
|
|
}
|
|
|
|
_cancellationTokenSource = new CancellationTokenSource();
|
|
|
|
_initializeItemsTask = new Task(() =>
|
|
{
|
|
InitializeItemsTask(_cancellationTokenSource.Token);
|
|
});
|
|
_initializeItemsTask.Start();
|
|
|
|
DoOnUiThread(
|
|
() =>
|
|
{
|
|
lock (_listLock)
|
|
{
|
|
// Now that our Items contains everything we want, it's time for us to
|
|
// re-evaluate our Filter on those items.
|
|
if (!_isDynamic)
|
|
{
|
|
// A static list? Great! Just run the filter.
|
|
ApplyFilterUnderLock();
|
|
}
|
|
else
|
|
{
|
|
// A dynamic list? Even better! Just stick everything into
|
|
// FilteredItems. The extension already did any filtering it cared about.
|
|
var snapshot = Items.Where(i => !i.IsInErrorState).ToList();
|
|
ListHelpers.InPlaceUpdateList(FilteredItems, snapshot);
|
|
}
|
|
|
|
UpdateEmptyContent();
|
|
}
|
|
|
|
ItemsUpdated?.Invoke(this, new ItemsUpdatedEventArgs(IsRootPage));
|
|
_isLoading.Clear();
|
|
});
|
|
}
|
|
|
|
private void InitializeItemsTask(CancellationToken ct)
|
|
{
|
|
// Were we already canceled?
|
|
if (ct.IsCancellationRequested)
|
|
{
|
|
return;
|
|
}
|
|
|
|
ListItemViewModel[] iterable;
|
|
lock (_listLock)
|
|
{
|
|
iterable = Items.ToArray();
|
|
}
|
|
|
|
foreach (var item in iterable)
|
|
{
|
|
if (ct.IsCancellationRequested)
|
|
{
|
|
return;
|
|
}
|
|
|
|
// TODO: GH #502
|
|
// We should probably remove the item from the list if it
|
|
// entered the error state. I had issues doing that without having
|
|
// multiple threads muck with `Items` (and possibly FilteredItems!)
|
|
// at once.
|
|
item.SafeInitializeProperties();
|
|
|
|
if (ct.IsCancellationRequested)
|
|
{
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Apply our current filter text to the list of items, and update
|
|
/// FilteredItems to match the results.
|
|
/// </summary>
|
|
private void ApplyFilterUnderLock() => ListHelpers.InPlaceUpdateList(FilteredItems, FilterList(Items, SearchTextBox));
|
|
|
|
/// <summary>
|
|
/// Helper to generate a weighting for a given list item, based on title,
|
|
/// subtitle, etc. Largely a copy of the version in ListHelpers, but
|
|
/// operating on ViewModels instead of extension objects.
|
|
/// </summary>
|
|
private static int ScoreListItem(string query, CommandItemViewModel listItem)
|
|
{
|
|
if (string.IsNullOrEmpty(query))
|
|
{
|
|
return 1;
|
|
}
|
|
|
|
var nameMatch = FuzzyStringMatcher.ScoreFuzzy(query, listItem.Title);
|
|
var descriptionMatch = FuzzyStringMatcher.ScoreFuzzy(query, listItem.Subtitle);
|
|
return new[] { nameMatch, (descriptionMatch - 4) / 2, 0 }.Max();
|
|
}
|
|
|
|
private struct ScoredListItemViewModel
|
|
{
|
|
public int Score;
|
|
public ListItemViewModel ViewModel;
|
|
}
|
|
|
|
// Similarly stolen from ListHelpers.FilterList
|
|
public static IEnumerable<ListItemViewModel> FilterList(IEnumerable<ListItemViewModel> items, string query)
|
|
{
|
|
var scores = items
|
|
.Where(i => !i.IsInErrorState)
|
|
.Select(li => new ScoredListItemViewModel() { ViewModel = li, Score = ScoreListItem(query, li) })
|
|
.Where(score => score.Score > 0)
|
|
.OrderByDescending(score => score.Score);
|
|
return scores
|
|
.Select(score => score.ViewModel);
|
|
}
|
|
|
|
// InvokeItemCommand is what this will be in Xaml due to source generator
|
|
// This is what gets invoked when the user presses <enter>
|
|
[RelayCommand]
|
|
private void InvokeItem(ListItemViewModel? item)
|
|
{
|
|
if (item is not null)
|
|
{
|
|
WeakReferenceMessenger.Default.Send<PerformCommandMessage>(new(item.Command.Model, item.Model));
|
|
}
|
|
else if (ShowEmptyContent && EmptyContent.PrimaryCommand?.Model.Unsafe is not null)
|
|
{
|
|
WeakReferenceMessenger.Default.Send<PerformCommandMessage>(new(
|
|
EmptyContent.PrimaryCommand.Command.Model,
|
|
EmptyContent.PrimaryCommand.Model));
|
|
}
|
|
}
|
|
|
|
// This is what gets invoked when the user presses <ctrl+enter>
|
|
[RelayCommand]
|
|
private void InvokeSecondaryCommand(ListItemViewModel? item)
|
|
{
|
|
if (item is not null)
|
|
{
|
|
if (item.SecondaryCommand is not null)
|
|
{
|
|
WeakReferenceMessenger.Default.Send<PerformCommandMessage>(new(item.SecondaryCommand.Command.Model, item.Model));
|
|
}
|
|
}
|
|
else if (ShowEmptyContent && EmptyContent.SecondaryCommand?.Model.Unsafe is not null)
|
|
{
|
|
WeakReferenceMessenger.Default.Send<PerformCommandMessage>(new(
|
|
EmptyContent.SecondaryCommand.Command.Model,
|
|
EmptyContent.SecondaryCommand.Model));
|
|
}
|
|
}
|
|
|
|
[RelayCommand]
|
|
private void UpdateSelectedItem(ListItemViewModel? item)
|
|
{
|
|
if (_lastSelectedItem is not null)
|
|
{
|
|
_lastSelectedItem.PropertyChanged -= SelectedItemPropertyChanged;
|
|
}
|
|
|
|
if (item is not null)
|
|
{
|
|
SetSelectedItem(item);
|
|
}
|
|
else
|
|
{
|
|
ClearSelectedItem();
|
|
}
|
|
}
|
|
|
|
private void SetSelectedItem(ListItemViewModel item)
|
|
{
|
|
_lastSelectedItem = item;
|
|
_lastSelectedItem.PropertyChanged += SelectedItemPropertyChanged;
|
|
|
|
WeakReferenceMessenger.Default.Send<UpdateCommandBarMessage>(new(item));
|
|
|
|
// Cancel any in-flight slow init from a previous selection and defer
|
|
// the expensive work (extension IPC for MoreCommands, details) so
|
|
// rapid arrow-key navigation skips intermediate items entirely.
|
|
_selectedItemCts?.Cancel();
|
|
var cts = _selectedItemCts = new CancellationTokenSource();
|
|
var ct = cts.Token;
|
|
|
|
_ = Task.Run(
|
|
() =>
|
|
{
|
|
if (ct.IsCancellationRequested)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (!item.SafeSlowInit())
|
|
{
|
|
if (ct.IsCancellationRequested)
|
|
{
|
|
return;
|
|
}
|
|
|
|
WeakReferenceMessenger.Default.Send<HideDetailsMessage>();
|
|
|
|
return;
|
|
}
|
|
|
|
if (ct.IsCancellationRequested)
|
|
{
|
|
return;
|
|
}
|
|
|
|
// SafeSlowInit completed on a background thread — details
|
|
// messages will be marshalled to the UI thread by the receiver.
|
|
if (ShowDetails && item.HasDetails)
|
|
{
|
|
WeakReferenceMessenger.Default.Send<ShowDetailsMessage>(new(item.Details));
|
|
}
|
|
else
|
|
{
|
|
WeakReferenceMessenger.Default.Send<HideDetailsMessage>();
|
|
}
|
|
|
|
TextToSuggest = item.TextToSuggest;
|
|
WeakReferenceMessenger.Default.Send<UpdateSuggestionMessage>(new(item.TextToSuggest));
|
|
},
|
|
ct);
|
|
}
|
|
|
|
private void SelectedItemPropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
|
|
{
|
|
var item = _lastSelectedItem;
|
|
if (item is null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
// already on the UI thread here
|
|
switch (e.PropertyName)
|
|
{
|
|
case nameof(item.Command):
|
|
case nameof(item.SecondaryCommand):
|
|
case nameof(item.AllCommands):
|
|
case nameof(item.Name):
|
|
WeakReferenceMessenger.Default.Send<UpdateCommandBarMessage>(new(item));
|
|
break;
|
|
case nameof(item.Details):
|
|
if (ShowDetails && item.HasDetails)
|
|
{
|
|
WeakReferenceMessenger.Default.Send<ShowDetailsMessage>(new(item.Details));
|
|
}
|
|
else
|
|
{
|
|
WeakReferenceMessenger.Default.Send<HideDetailsMessage>();
|
|
}
|
|
|
|
break;
|
|
case nameof(item.TextToSuggest):
|
|
TextToSuggest = item.TextToSuggest;
|
|
break;
|
|
}
|
|
}
|
|
|
|
private void ClearSelectedItem()
|
|
{
|
|
_selectedItemCts?.Cancel();
|
|
|
|
WeakReferenceMessenger.Default.Send<UpdateCommandBarMessage>(new(null));
|
|
WeakReferenceMessenger.Default.Send<HideDetailsMessage>();
|
|
WeakReferenceMessenger.Default.Send<UpdateSuggestionMessage>(new(string.Empty));
|
|
TextToSuggest = string.Empty;
|
|
}
|
|
|
|
public override void InitializeProperties()
|
|
{
|
|
base.InitializeProperties();
|
|
|
|
var model = _model.Unsafe;
|
|
if (model is null)
|
|
{
|
|
return; // throw?
|
|
}
|
|
|
|
_isDynamic = model is IDynamicListPage;
|
|
|
|
IsGridView = model.GridProperties is not null;
|
|
UpdateProperty(nameof(IsGridView));
|
|
|
|
GridProperties = LoadGridPropertiesViewModel(model.GridProperties);
|
|
GridProperties?.InitializeProperties();
|
|
UpdateProperty(nameof(GridProperties));
|
|
ApplyLayoutToItems();
|
|
|
|
ShowDetails = model.ShowDetails;
|
|
UpdateProperty(nameof(ShowDetails));
|
|
|
|
_modelPlaceholderText = model.PlaceholderText;
|
|
UpdateProperty(nameof(PlaceholderText));
|
|
|
|
InitialSearchText = SearchText = model.SearchText;
|
|
UpdateProperty(nameof(SearchText));
|
|
UpdateProperty(nameof(InitialSearchText));
|
|
|
|
EmptyContent = new(new(model.EmptyContent), PageContext, _contextMenuFactory);
|
|
EmptyContent.SlowInitializeProperties();
|
|
|
|
Filters?.PropertyChanged -= FiltersPropertyChanged;
|
|
Filters = new(new(model.Filters), PageContext);
|
|
Filters?.PropertyChanged += FiltersPropertyChanged;
|
|
|
|
Filters?.InitializeProperties();
|
|
UpdateProperty(nameof(Filters));
|
|
|
|
FetchItems();
|
|
model.ItemsChanged += Model_ItemsChanged;
|
|
}
|
|
|
|
private static IGridPropertiesViewModel? LoadGridPropertiesViewModel(IGridProperties? gridProperties)
|
|
{
|
|
return gridProperties switch
|
|
{
|
|
IMediumGridLayout mediumGridLayout => new MediumGridPropertiesViewModel(mediumGridLayout),
|
|
IGalleryGridLayout galleryGridLayout => new GalleryGridPropertiesViewModel(galleryGridLayout),
|
|
ISmallGridLayout smallGridLayout => new SmallGridPropertiesViewModel(smallGridLayout),
|
|
_ => null,
|
|
};
|
|
}
|
|
|
|
public void LoadMoreIfNeeded()
|
|
{
|
|
var model = _model.Unsafe;
|
|
if (model is null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (!_isLoading.Set())
|
|
{
|
|
return;
|
|
|
|
// NOTE: May miss newly available items until next scroll if model
|
|
// state changes between our check and this reset
|
|
}
|
|
|
|
_ = Task.Run(() =>
|
|
{
|
|
// Execute all COM calls on background thread to avoid reentrancy issues with UI
|
|
// with the UI thread when COM starts inner message pump
|
|
try
|
|
{
|
|
if (model.HasMoreItems)
|
|
{
|
|
model.LoadMore();
|
|
|
|
// _isLoading flag will be set as a result of LoadMore,
|
|
// which must raise ItemsChanged to end the loading.
|
|
}
|
|
else
|
|
{
|
|
_isLoading.Clear();
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_isLoading.Clear();
|
|
ShowException(ex, model.Name);
|
|
}
|
|
});
|
|
}
|
|
|
|
protected override void FetchProperty(string propertyName)
|
|
{
|
|
base.FetchProperty(propertyName);
|
|
|
|
var model = _model.Unsafe;
|
|
if (model is null)
|
|
{
|
|
return; // throw?
|
|
}
|
|
|
|
switch (propertyName)
|
|
{
|
|
case nameof(GridProperties):
|
|
IsGridView = model.GridProperties is not null;
|
|
GridProperties = LoadGridPropertiesViewModel(model.GridProperties);
|
|
GridProperties?.InitializeProperties();
|
|
UpdateProperty(nameof(IsGridView));
|
|
ApplyLayoutToItems();
|
|
break;
|
|
case nameof(ShowDetails):
|
|
ShowDetails = model.ShowDetails;
|
|
break;
|
|
case nameof(PlaceholderText):
|
|
_modelPlaceholderText = model.PlaceholderText;
|
|
break;
|
|
case nameof(SearchText):
|
|
SearchText = model.SearchText;
|
|
break;
|
|
case nameof(EmptyContent):
|
|
EmptyContent = new(new(model.EmptyContent), PageContext, contextMenuFactory: null);
|
|
EmptyContent.SlowInitializeProperties();
|
|
break;
|
|
case nameof(Filters):
|
|
Filters?.PropertyChanged -= FiltersPropertyChanged;
|
|
Filters = new(new(model.Filters), PageContext);
|
|
Filters?.PropertyChanged += FiltersPropertyChanged;
|
|
Filters?.InitializeProperties();
|
|
break;
|
|
case nameof(IsLoading):
|
|
UpdateEmptyContent();
|
|
break;
|
|
}
|
|
|
|
UpdateProperty(propertyName);
|
|
}
|
|
|
|
private void UpdateEmptyContent()
|
|
{
|
|
UpdateProperty(nameof(ShowEmptyContent));
|
|
if (!ShowEmptyContent || EmptyContent.Model.Unsafe is null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
UpdateProperty(nameof(EmptyContent));
|
|
|
|
DoOnUiThread(
|
|
() =>
|
|
{
|
|
WeakReferenceMessenger.Default.Send<UpdateCommandBarMessage>(new(EmptyContent));
|
|
});
|
|
}
|
|
|
|
private void ApplyLayoutToItems()
|
|
{
|
|
lock (_listLock)
|
|
{
|
|
var showsTitle = GridProperties?.ShowTitle ?? true;
|
|
var showsSubtitle = GridProperties?.ShowSubtitle ?? true;
|
|
|
|
foreach (var item in Items)
|
|
{
|
|
item.LayoutShowsTitle = showsTitle;
|
|
item.LayoutShowsSubtitle = showsSubtitle;
|
|
}
|
|
}
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
GC.SuppressFinalize(this);
|
|
_cancellationTokenSource?.Cancel();
|
|
_cancellationTokenSource?.Dispose();
|
|
_cancellationTokenSource = null;
|
|
|
|
filterCancellationTokenSource?.Cancel();
|
|
filterCancellationTokenSource?.Dispose();
|
|
filterCancellationTokenSource = null;
|
|
|
|
_fetchItemsCancellationTokenSource?.Cancel();
|
|
_fetchItemsCancellationTokenSource?.Dispose();
|
|
_fetchItemsCancellationTokenSource = null;
|
|
|
|
_selectedItemCts?.Cancel();
|
|
_selectedItemCts?.Dispose();
|
|
_selectedItemCts = null;
|
|
}
|
|
|
|
protected override void UnsafeCleanup()
|
|
{
|
|
base.UnsafeCleanup();
|
|
|
|
EmptyContent?.SafeCleanup();
|
|
EmptyContent = new(new(null), PageContext, contextMenuFactory: null); // necessary?
|
|
|
|
_cancellationTokenSource?.Cancel();
|
|
filterCancellationTokenSource?.Cancel();
|
|
_fetchItemsCancellationTokenSource?.Cancel();
|
|
_selectedItemCts?.Cancel();
|
|
|
|
lock (_listLock)
|
|
{
|
|
foreach (var item in Items)
|
|
{
|
|
item.SafeCleanup();
|
|
}
|
|
|
|
Items.Clear();
|
|
foreach (var item in FilteredItems)
|
|
{
|
|
item.SafeCleanup();
|
|
}
|
|
|
|
FilteredItems.Clear();
|
|
}
|
|
|
|
Filters?.PropertyChanged -= FiltersPropertyChanged;
|
|
Filters?.SafeCleanup();
|
|
|
|
var model = _model.Unsafe;
|
|
if (model is not null)
|
|
{
|
|
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);
|
|
}
|
|
}
|