chore: first version
This commit is contained in:
@@ -0,0 +1,555 @@
|
||||
using Microsoft.CommandPalette.Extensions;
|
||||
using Microsoft.CommandPalette.Extensions.Toolkit;
|
||||
using Olive.Commands;
|
||||
using Olive.Helpers;
|
||||
using Olive.Klipy;
|
||||
using Olive.Services;
|
||||
using Windows.Foundation;
|
||||
using Windows.System;
|
||||
|
||||
namespace Olive.Pages;
|
||||
|
||||
internal sealed partial class GifPickerPage : IDynamicListPage
|
||||
{
|
||||
private const int PageSize = 30;
|
||||
private const int InitialBatchPages = 1;
|
||||
private const int LoadMoreBatchPages = 1;
|
||||
private static readonly TimeSpan SearchDebounceDelay = TimeSpan.FromMilliseconds(500);
|
||||
|
||||
private readonly Lock _itemsLock = new();
|
||||
private readonly GifCache _cache = new();
|
||||
private readonly SettingsManager _settingsManager;
|
||||
private readonly List<IListItem> _items = [];
|
||||
private readonly IIconInfo _icon = IconHelpers.FromRelativePath("Assets\\StoreLogo.png");
|
||||
private readonly IGridProperties _gridProperties = new GalleryGridLayout { ShowTitle = true, ShowSubtitle = false };
|
||||
private CancellationTokenSource? _searchCancellation;
|
||||
private string _searchText = string.Empty;
|
||||
private string _activeSearch = string.Empty;
|
||||
private string _title = "Olive";
|
||||
private int _searchVersion;
|
||||
private int _loadRequestId;
|
||||
private int _nextPage = 1;
|
||||
private int _loadedItemCount;
|
||||
private bool _hasMoreItems;
|
||||
private bool _isLoading;
|
||||
private bool _isLoadingMore;
|
||||
private ICommandItem _emptyContent;
|
||||
|
||||
public GifPickerPage(SettingsManager settingsManager)
|
||||
{
|
||||
_settingsManager = settingsManager;
|
||||
_emptyContent = InitialContent();
|
||||
}
|
||||
|
||||
public event TypedEventHandler<object, IItemsChangedEventArgs>? ItemsChanged;
|
||||
|
||||
public event TypedEventHandler<object, IPropChangedEventArgs>? PropChanged;
|
||||
|
||||
public IIconInfo Icon => _icon;
|
||||
|
||||
public string Id => string.Empty;
|
||||
|
||||
public string Name => "Olive GIF Picker";
|
||||
|
||||
public string Title => _title;
|
||||
|
||||
public OptionalColor AccentColor => default;
|
||||
|
||||
public bool IsLoading
|
||||
{
|
||||
get => _isLoading;
|
||||
private set
|
||||
{
|
||||
if (_isLoading == value)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_isLoading = value;
|
||||
RaisePropChanged(nameof(IsLoading));
|
||||
}
|
||||
}
|
||||
|
||||
public string PlaceholderText => "Search for GIFs...";
|
||||
|
||||
public bool ShowDetails => false;
|
||||
|
||||
public bool HasMoreItems
|
||||
{
|
||||
get => _hasMoreItems;
|
||||
private set
|
||||
{
|
||||
if (_hasMoreItems == value)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_hasMoreItems = value;
|
||||
RaisePropChanged(nameof(HasMoreItems));
|
||||
}
|
||||
}
|
||||
|
||||
public IFilters? Filters => null;
|
||||
|
||||
public IGridProperties GridProperties => _gridProperties;
|
||||
|
||||
public ICommandItem EmptyContent
|
||||
{
|
||||
get => _emptyContent;
|
||||
private set
|
||||
{
|
||||
_emptyContent = value;
|
||||
RaisePropChanged(nameof(EmptyContent));
|
||||
}
|
||||
}
|
||||
|
||||
public string? SearchText
|
||||
{
|
||||
get => _searchText;
|
||||
set
|
||||
{
|
||||
value ??= string.Empty;
|
||||
if (StringComparer.Ordinal.Equals(_searchText, value))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_searchText = value;
|
||||
UpdateSearchText(value);
|
||||
}
|
||||
}
|
||||
|
||||
public IListItem[] GetItems()
|
||||
{
|
||||
lock (_itemsLock)
|
||||
{
|
||||
return BuildDisplayItemsLocked();
|
||||
}
|
||||
}
|
||||
|
||||
public void LoadMore()
|
||||
{
|
||||
if (_isLoadingMore || !HasMoreItems || string.IsNullOrWhiteSpace(_activeSearch))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var apiKey = _settingsManager.KlipyApiKey;
|
||||
if (string.IsNullOrWhiteSpace(apiKey))
|
||||
{
|
||||
HasMoreItems = false;
|
||||
EmptyContent = MissingApiKeyContent();
|
||||
RaiseItemsChanged(DisplayItemCount());
|
||||
return;
|
||||
}
|
||||
|
||||
var version = _searchVersion;
|
||||
_ = LoadPageBatchAsync(apiKey, _activeSearch, version, LoadMoreBatchPages, CancellationToken.None);
|
||||
}
|
||||
|
||||
private void UpdateSearchText(string newSearch)
|
||||
{
|
||||
CancelSearch();
|
||||
|
||||
var trimmedSearch = newSearch.Trim();
|
||||
if (string.IsNullOrWhiteSpace(trimmedSearch))
|
||||
{
|
||||
Interlocked.Increment(ref _searchVersion);
|
||||
Interlocked.Increment(ref _loadRequestId);
|
||||
IsLoading = false;
|
||||
_isLoadingMore = false;
|
||||
HasMoreItems = false;
|
||||
_activeSearch = string.Empty;
|
||||
_nextPage = 1;
|
||||
ReplaceItems([]);
|
||||
UpdateTitle();
|
||||
EmptyContent = _settingsManager.HasKlipyApiKey ? InitialContent() : MissingApiKeyContent();
|
||||
return;
|
||||
}
|
||||
|
||||
var version = Interlocked.Increment(ref _searchVersion);
|
||||
Interlocked.Increment(ref _loadRequestId);
|
||||
var cancellation = new CancellationTokenSource();
|
||||
_searchCancellation = cancellation;
|
||||
|
||||
_ = SearchAfterDebounceAsync(trimmedSearch, version, cancellation.Token);
|
||||
}
|
||||
|
||||
private void CancelSearch()
|
||||
{
|
||||
var cancellation = _searchCancellation;
|
||||
_searchCancellation = null;
|
||||
if (cancellation is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
cancellation.Cancel();
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
}
|
||||
|
||||
cancellation.Dispose();
|
||||
}
|
||||
|
||||
private async Task SearchAfterDebounceAsync(string search, int version, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await Task.Delay(SearchDebounceDelay, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var apiKey = _settingsManager.KlipyApiKey;
|
||||
if (string.IsNullOrWhiteSpace(apiKey))
|
||||
{
|
||||
if (version != _searchVersion || cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
IsLoading = false;
|
||||
HasMoreItems = false;
|
||||
EmptyContent = MissingApiKeyContent();
|
||||
ReplaceItems([]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (version != _searchVersion || cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_activeSearch = search;
|
||||
_nextPage = 1;
|
||||
_isLoadingMore = false;
|
||||
HasMoreItems = false;
|
||||
IsLoading = true;
|
||||
EmptyContent = LoadingContent(search);
|
||||
UpdateTitle();
|
||||
|
||||
await LoadPageBatchAsync(apiKey, search, version, InitialBatchPages, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (version == _searchVersion)
|
||||
{
|
||||
ShowSearchError(ex, resetItems: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LoadPageBatchAsync(string apiKey, string search, int version, int pageCount, CancellationToken cancellationToken)
|
||||
{
|
||||
if (_isLoadingMore)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var loadRequestId = Interlocked.Increment(ref _loadRequestId);
|
||||
var startPage = _nextPage;
|
||||
var resetItems = startPage == 1;
|
||||
|
||||
try
|
||||
{
|
||||
_isLoadingMore = true;
|
||||
IsLoading = true;
|
||||
|
||||
var gifs = new List<KlipyGif>(PageSize * pageCount);
|
||||
var nextPage = startPage;
|
||||
var hasMore = false;
|
||||
|
||||
for (var i = 0; i < pageCount && version == _searchVersion && !cancellationToken.IsCancellationRequested; i++)
|
||||
{
|
||||
var page = startPage + i;
|
||||
var result = await KlipyClient.SearchAsync(apiKey, search, page, PageSize, cancellationToken).ConfigureAwait(false);
|
||||
if (version != _searchVersion || cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
gifs.AddRange(result.Gifs);
|
||||
nextPage = page + 1;
|
||||
hasMore = result.HasMore;
|
||||
if (!hasMore)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
_nextPage = nextPage;
|
||||
HasMoreItems = hasMore;
|
||||
|
||||
if (resetItems && gifs.Count == 0)
|
||||
{
|
||||
EmptyContent = new CommandItem(new NoOpCommand())
|
||||
{
|
||||
Title = "No GIF found",
|
||||
Subtitle = $"Try another search for \"{search}\".",
|
||||
Icon = Icon,
|
||||
};
|
||||
ReplaceItems([]);
|
||||
return;
|
||||
}
|
||||
|
||||
var newItems = gifs.Select(CreateItem).ToArray();
|
||||
if (resetItems)
|
||||
{
|
||||
ReplaceItems(newItems);
|
||||
}
|
||||
else
|
||||
{
|
||||
AppendItems(newItems);
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is HttpRequestException or InvalidOperationException or TaskCanceledException)
|
||||
{
|
||||
if (version == _searchVersion)
|
||||
{
|
||||
ShowSearchError(ex, resetItems);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (version == _searchVersion)
|
||||
{
|
||||
ShowSearchError(ex, resetItems);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (loadRequestId == _loadRequestId)
|
||||
{
|
||||
_isLoadingMore = false;
|
||||
}
|
||||
|
||||
if (version == _searchVersion && loadRequestId == _loadRequestId)
|
||||
{
|
||||
IsLoading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void ShowSearchError(Exception ex, bool resetItems)
|
||||
{
|
||||
IsLoading = false;
|
||||
HasMoreItems = false;
|
||||
EmptyContent = new CommandItem(new NoOpCommand())
|
||||
{
|
||||
Title = "Search failed",
|
||||
Subtitle = ex.Message,
|
||||
Icon = Icon,
|
||||
};
|
||||
|
||||
if (resetItems)
|
||||
{
|
||||
ReplaceItems([]);
|
||||
}
|
||||
else
|
||||
{
|
||||
RaiseItemsChanged(DisplayItemCount());
|
||||
}
|
||||
}
|
||||
|
||||
private ListItem CreateItem(KlipyGif gif)
|
||||
{
|
||||
return new ListItem(new CopyGifCommand(gif, _cache))
|
||||
{
|
||||
Title = string.Empty,
|
||||
Subtitle = string.Empty,
|
||||
Icon = new IconInfo(gif.ThumbnailGifUrl.ToString()),
|
||||
MoreCommands = CreateLoadMoreContextItems(),
|
||||
};
|
||||
}
|
||||
|
||||
private IContextItem[] CreateLoadMoreContextItems()
|
||||
{
|
||||
return [
|
||||
CreateLoadMoreContextItem("Load 30 more GIFs", VirtualKey.L),
|
||||
];
|
||||
}
|
||||
|
||||
private CommandContextItem CreateLoadMoreContextItem(string title, VirtualKey key)
|
||||
{
|
||||
return new CommandContextItem(new LoadMoreGifsCommand(LoadMore))
|
||||
{
|
||||
Title = title,
|
||||
Subtitle = "Append the next 30 results without moving the current selection",
|
||||
Icon = Icon,
|
||||
RequestedShortcut = KeyChordHelpers.FromModifiers(true, false, false, false, key, 0),
|
||||
};
|
||||
}
|
||||
|
||||
private CommandItem InitialContent()
|
||||
{
|
||||
return new CommandItem(new NoOpCommand())
|
||||
{
|
||||
Title = _settingsManager.HasKlipyApiKey ? "Search GIFs" : "Klipy API key missing",
|
||||
Subtitle = _settingsManager.HasKlipyApiKey
|
||||
? "Type something like thanks, excited, cat, or confused."
|
||||
: "Open Olive settings and set the Klipy API key.",
|
||||
Icon = Icon,
|
||||
};
|
||||
}
|
||||
|
||||
private CommandItem LoadingContent(string search)
|
||||
{
|
||||
return new CommandItem(new NoOpCommand())
|
||||
{
|
||||
Title = "Loading GIFs...",
|
||||
Subtitle = $"Searching Klipy for \"{search}\"",
|
||||
Icon = Icon,
|
||||
};
|
||||
}
|
||||
|
||||
private CommandItem MissingApiKeyContent()
|
||||
{
|
||||
return new CommandItem(new NoOpCommand())
|
||||
{
|
||||
Title = "Klipy API key missing",
|
||||
Subtitle = "Open Olive settings and paste your Klipy API key.",
|
||||
Icon = Icon,
|
||||
};
|
||||
}
|
||||
|
||||
private void ReplaceItems(IListItem[] items)
|
||||
{
|
||||
int count;
|
||||
lock (_itemsLock)
|
||||
{
|
||||
_items.Clear();
|
||||
_items.AddRange(items);
|
||||
_loadedItemCount = items.Length;
|
||||
AddPlaceholderItemsLocked();
|
||||
count = DisplayItemCountLocked();
|
||||
}
|
||||
|
||||
RaiseItemsChanged(count);
|
||||
UpdateTitle();
|
||||
}
|
||||
|
||||
private void AppendItems(IListItem[] items)
|
||||
{
|
||||
int count;
|
||||
lock (_itemsLock)
|
||||
{
|
||||
RemovePlaceholderItemsLocked();
|
||||
_items.AddRange(items);
|
||||
_loadedItemCount += items.Length;
|
||||
AddPlaceholderItemsLocked();
|
||||
count = DisplayItemCountLocked();
|
||||
}
|
||||
|
||||
RaiseItemsChanged(count);
|
||||
UpdateTitle();
|
||||
}
|
||||
|
||||
private IListItem[] BuildDisplayItemsLocked()
|
||||
{
|
||||
return [.. _items];
|
||||
}
|
||||
|
||||
private void AddPlaceholderItemsLocked()
|
||||
{
|
||||
if (!HasMoreItems)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
for (var i = 0; i < PageSize * LoadMoreBatchPages; i++)
|
||||
{
|
||||
_items.Add(CreateVirtualLoadingItem());
|
||||
}
|
||||
}
|
||||
|
||||
private void RemovePlaceholderItemsLocked()
|
||||
{
|
||||
if (_items.Count > _loadedItemCount)
|
||||
{
|
||||
_items.RemoveRange(_loadedItemCount, _items.Count - _loadedItemCount);
|
||||
}
|
||||
}
|
||||
|
||||
private ListItem CreateVirtualLoadingItem()
|
||||
{
|
||||
return new ListItem(new LoadMoreGifsCommand(LoadMore))
|
||||
{
|
||||
Title = "Loading more GIFs...",
|
||||
Subtitle = "Scroll here or press Ctrl+L to load the next 30",
|
||||
Icon = Icon,
|
||||
MoreCommands = CreateLoadMoreContextItems(),
|
||||
};
|
||||
}
|
||||
|
||||
private int DisplayItemCount()
|
||||
{
|
||||
lock (_itemsLock)
|
||||
{
|
||||
return DisplayItemCountLocked();
|
||||
}
|
||||
}
|
||||
|
||||
private int DisplayItemCountLocked()
|
||||
{
|
||||
return _items.Count;
|
||||
}
|
||||
|
||||
private void RaiseItemsChanged(int count)
|
||||
{
|
||||
ItemsChanged?.Invoke(this, new ItemsChangedEventArgs(count));
|
||||
}
|
||||
|
||||
private void RaisePropChanged(string propertyName)
|
||||
{
|
||||
PropChanged?.Invoke(this, new PropChangedEventArgs(propertyName));
|
||||
}
|
||||
|
||||
private void UpdateTitle()
|
||||
{
|
||||
var loadedCount = LoadedItemCount();
|
||||
var title = string.IsNullOrWhiteSpace(_activeSearch) || loadedCount == 0
|
||||
? "Olive"
|
||||
: HasMoreItems
|
||||
? $"Olive - {loadedCount} GIFs loaded"
|
||||
: $"Olive - {loadedCount} GIFs loaded (all)";
|
||||
|
||||
if (StringComparer.Ordinal.Equals(_title, title))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_title = title;
|
||||
RaisePropChanged(nameof(Title));
|
||||
}
|
||||
|
||||
private int LoadedItemCount()
|
||||
{
|
||||
lock (_itemsLock)
|
||||
{
|
||||
return _loadedItemCount;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed partial class LoadMoreGifsCommand : InvokableCommand
|
||||
{
|
||||
private readonly Action _loadMore;
|
||||
|
||||
public LoadMoreGifsCommand(Action loadMore)
|
||||
{
|
||||
_loadMore = loadMore;
|
||||
Name = "Load 30 more GIFs";
|
||||
Icon = IconHelpers.FromRelativePath("Assets\\StoreLogo.png");
|
||||
}
|
||||
|
||||
public override ICommandResult Invoke()
|
||||
{
|
||||
_loadMore();
|
||||
return CommandResult.KeepOpen();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user