diff --git a/src/EbbesMemeClipboard/EbbesMemeClipboard.csproj b/src/EbbesMemeClipboard/EbbesMemeClipboard.csproj
index 16ee60f..933941a 100644
--- a/src/EbbesMemeClipboard/EbbesMemeClipboard.csproj
+++ b/src/EbbesMemeClipboard/EbbesMemeClipboard.csproj
@@ -22,6 +22,7 @@
+
diff --git a/src/EbbesMemeClipboard/Models/AppSettings.cs b/src/EbbesMemeClipboard/Models/AppSettings.cs
index aee7342..4c39160 100644
--- a/src/EbbesMemeClipboard/Models/AppSettings.cs
+++ b/src/EbbesMemeClipboard/Models/AppSettings.cs
@@ -14,4 +14,10 @@ public sealed class AppSettings
/// key is its own source of truth, so caching it would risk the two drifting apart.
///
public string? GiphyApiKey { get; set; }
+
+ ///
+ /// Animate GIF thumbnails in the grid. Off by default: playing a screenful of GIFs at once
+ /// costs noticeably more CPU and memory than showing static first frames.
+ ///
+ public bool PlayGifPreviews { get; set; }
}
diff --git a/src/EbbesMemeClipboard/ViewModels/MemeTileViewModel.cs b/src/EbbesMemeClipboard/ViewModels/MemeTileViewModel.cs
index f9135a9..2d4f20b 100644
--- a/src/EbbesMemeClipboard/ViewModels/MemeTileViewModel.cs
+++ b/src/EbbesMemeClipboard/ViewModels/MemeTileViewModel.cs
@@ -1,3 +1,4 @@
+using System.IO;
using System.Windows.Media.Imaging;
using CommunityToolkit.Mvvm.ComponentModel;
using EbbesMemeClipboard.Models;
@@ -11,11 +12,10 @@ namespace EbbesMemeClipboard.ViewModels;
///
public partial class MemeTileViewModel : ObservableObject
{
- // Comfortably above the ~126px logical tile width so thumbnails stay sharp at 150-200%
- // display scaling, without decoding anything near full resolution.
private const int ThumbnailPixelWidth = 240;
private readonly IGifCacheService? _cache;
+ private readonly bool _animatePreviews;
private string? _localPath;
public LocalMemeRecord? Record { get; }
@@ -35,20 +35,33 @@ public partial class MemeTileViewModel : ObservableObject
[ObservableProperty]
private bool _isFavorite;
+ ///
+ /// Animation sources for XamlAnimatedGif. Only one is ever set, and only when GIF preview
+ /// playback is switched on - otherwise both stay null and the static Thumbnail shows.
+ /// A local file animates straight from disk; a remote one needs its bytes kept in memory.
+ ///
+ [ObservableProperty]
+ private Uri? _animationUri;
+
+ [ObservableProperty]
+ private Stream? _animationStream;
+
public string FavoriteActionLabel => IsFavorite ? "Remove from favourites" : "Add to favourites";
partial void OnIsFavoriteChanged(bool value) => OnPropertyChanged(nameof(FavoriteActionLabel));
- public MemeTileViewModel(LocalMemeRecord record, string fullPath)
+ public MemeTileViewModel(LocalMemeRecord record, string fullPath, bool animatePreviews = false)
{
Record = record;
_localPath = fullPath;
+ _animatePreviews = animatePreviews;
}
- public MemeTileViewModel(GifSearchResult remoteResult, IGifCacheService cache)
+ public MemeTileViewModel(GifSearchResult remoteResult, IGifCacheService cache, bool animatePreviews = false)
{
RemoteResult = remoteResult;
_cache = cache;
+ _animatePreviews = animatePreviews;
}
///
@@ -71,11 +84,23 @@ public partial class MemeTileViewModel : ObservableObject
{
var path = _localPath!;
Thumbnail = await Task.Run(() => ImageDecoding.DecodeFirstFrame(path, ThumbnailPixelWidth), ct);
+
+ if (_animatePreviews && ImageDecoding.IsGif(path))
+ {
+ AnimationUri = new Uri(path);
+ }
}
else
{
var bytes = await _cache!.DownloadPreviewAsync(RemoteResult!, ct);
Thumbnail = await Task.Run(() => ImageDecoding.DecodeFirstFrameFromBytes(bytes, ThumbnailPixelWidth), ct);
+
+ // Remote previews have no file on disk, so animation has to run off the bytes
+ // we already fetched for the thumbnail.
+ if (_animatePreviews && LooksLikeGif(RemoteResult!.PreviewUrl))
+ {
+ AnimationStream = new MemoryStream(bytes);
+ }
}
}
catch (OperationCanceledException)
@@ -88,4 +113,7 @@ public partial class MemeTileViewModel : ObservableObject
// renders empty and stays clickable.
}
}
+
+ private static bool LooksLikeGif(string url) =>
+ url.Contains(".gif", StringComparison.OrdinalIgnoreCase);
}
diff --git a/src/EbbesMemeClipboard/ViewModels/PickerViewModel.cs b/src/EbbesMemeClipboard/ViewModels/PickerViewModel.cs
index 3941ea3..de72872 100644
--- a/src/EbbesMemeClipboard/ViewModels/PickerViewModel.cs
+++ b/src/EbbesMemeClipboard/ViewModels/PickerViewModel.cs
@@ -1,4 +1,5 @@
using System.Collections.ObjectModel;
+using System.Collections.Specialized;
using System.IO;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
@@ -9,7 +10,7 @@ namespace EbbesMemeClipboard.ViewModels;
public partial class PickerViewModel : ObservableObject
{
- // 50 is Giphy's maximum per request; more results come from paging via offset as the user
+ // 50 is the Giphy per-request maximum; more results come from paging via offset as the user
// scrolls rather than from a single bigger call.
private const int RemoteResultLimit = 50;
private static readonly TimeSpan SearchDebounce = TimeSpan.FromMilliseconds(350);
@@ -34,9 +35,6 @@ public partial class PickerViewModel : ObservableObject
public ObservableCollection Items { get; } = new();
- /// Favourites for the currently active source only.
- public ObservableCollection Favorites { get; } = new();
-
[ObservableProperty]
private string _searchText = string.Empty;
@@ -49,8 +47,12 @@ public partial class PickerViewModel : ObservableObject
[ObservableProperty]
private bool _isDialogOpen;
+ ///
+ /// Favourites act as a filtered view of the active source rather than an inline row, so they
+ /// get the full grid and the search box narrows within them.
+ ///
[ObservableProperty]
- private bool _hasFavorites;
+ private bool _showingFavorites;
[ObservableProperty]
private MemeSource _activeSource = MemeSource.Local;
@@ -58,12 +60,25 @@ public partial class PickerViewModel : ObservableObject
public bool IsLocalSource => ActiveSource == MemeSource.Local;
public bool IsGiphySource => ActiveSource == MemeSource.Giphy;
- public string SearchPlaceholder => IsLocalSource
- ? "Search your memes..."
- : ActiveProvider?.SearchPlaceholder ?? "Search...";
+ /// Importing only applies to the local library, and not while browsing favourites.
+ public bool CanImport => IsLocalSource && !ShowingFavorites;
+
+ public string SearchPlaceholder => ShowingFavorites
+ ? "Search favourites..."
+ : IsLocalSource
+ ? "Search your memes..."
+ : ActiveProvider?.SearchPlaceholder ?? "Search...";
+
+ public bool ShowEmptyState => Items.Count == 0 && !IsBusy && (ShowingFavorites || IsLocalSource);
+
+ public string EmptyStateText => ShowingFavorites
+ ? "No favourites here yet. Right-click any meme to add one."
+ : "No memes yet. Click +, drag files in, or paste with Ctrl+V.";
private IGifProvider? ActiveProvider => _providers.FirstOrDefault(p => p.Source == ActiveSource);
+ private bool AnimatePreviews => _settings.Current.PlayGifPreviews;
+
public event EventHandler? RequestClose;
public PickerViewModel(
@@ -83,19 +98,35 @@ public partial class PickerViewModel : ObservableObject
_favorites = favorites;
_providers = providers.ToList();
+ Items.CollectionChanged += OnItemsChanged;
RefreshLocalItems();
- RefreshFavorites();
}
+ private void OnItemsChanged(object? sender, NotifyCollectionChangedEventArgs e) =>
+ OnPropertyChanged(nameof(ShowEmptyState));
+
partial void OnSearchTextChanged(string value) => _ = RefreshAsync();
+ partial void OnIsBusyChanged(bool value) => OnPropertyChanged(nameof(ShowEmptyState));
+
+ partial void OnShowingFavoritesChanged(bool value)
+ {
+ OnPropertyChanged(nameof(SearchPlaceholder));
+ OnPropertyChanged(nameof(CanImport));
+ OnPropertyChanged(nameof(EmptyStateText));
+ OnPropertyChanged(nameof(ShowEmptyState));
+ SearchText = string.Empty;
+ _ = RefreshAsync();
+ }
+
partial void OnActiveSourceChanged(MemeSource value)
{
OnPropertyChanged(nameof(IsLocalSource));
OnPropertyChanged(nameof(IsGiphySource));
OnPropertyChanged(nameof(SearchPlaceholder));
+ OnPropertyChanged(nameof(CanImport));
+ OnPropertyChanged(nameof(ShowEmptyState));
SearchText = string.Empty;
- RefreshFavorites();
// Setting SearchText above only triggers a refresh if the value actually changed, so
// refresh explicitly here to cover switching tabs with an already-empty box.
_ = RefreshAsync();
@@ -104,6 +135,9 @@ public partial class PickerViewModel : ObservableObject
[RelayCommand]
private void SelectSource(MemeSource source) => ActiveSource = source;
+ [RelayCommand]
+ private void ToggleFavoritesView() => ShowingFavorites = !ShowingFavorites;
+
[RelayCommand]
private async Task SelectItemAsync(MemeTileViewModel? tile)
{
@@ -146,7 +180,11 @@ public partial class PickerViewModel : ObservableObject
if (_favorites.IsFavorite(tile.Source, tile.FavoriteKey))
{
await _favorites.RemoveAsync(tile.Source, tile.FavoriteKey);
+ tile.IsFavorite = false;
StatusMessage = $"Removed {tile.DisplayName} from favourites";
+
+ // In the favourites view an unfavourited tile no longer belongs on screen.
+ if (ShowingFavorites) Items.Remove(tile);
}
else
{
@@ -159,11 +197,9 @@ public partial class PickerViewModel : ObservableObject
FullUrl = tile.RemoteResult?.FullUrl,
DateAdded = DateTimeOffset.Now,
});
+ tile.IsFavorite = true;
StatusMessage = $"Added {tile.DisplayName} to favourites";
}
-
- RefreshFavorites();
- SyncFavoriteFlags();
}
[RelayCommand]
@@ -171,13 +207,13 @@ public partial class PickerViewModel : ObservableObject
{
if (tile?.Record is null) return;
+ var name = tile.DisplayName;
await _library.RemoveAsync(tile.Record);
// Drop the matching favourite too, otherwise it lingers pointing at a deleted file.
await _favorites.RemoveAsync(MemeSource.Local, tile.Record.Id);
- RefreshLocalItems();
- RefreshFavorites();
- StatusMessage = $"Removed {tile.DisplayName}";
+ await RefreshAsync();
+ StatusMessage = $"Removed {name}";
}
[RelayCommand]
@@ -242,6 +278,7 @@ public partial class PickerViewModel : ObservableObject
return;
}
+ ShowingFavorites = false;
ActiveSource = MemeSource.Local;
RefreshLocalItems();
StatusMessage = $"Pasted {name}";
@@ -265,6 +302,7 @@ public partial class PickerViewModel : ObservableObject
}
var imported = await _library.ImportAsync(candidates);
+ ShowingFavorites = false;
ActiveSource = MemeSource.Local;
RefreshLocalItems();
StatusMessage = $"Added {imported.Count} meme(s).";
@@ -273,11 +311,9 @@ public partial class PickerViewModel : ObservableObject
public void OnShown()
{
SearchText = string.Empty;
- if (IsLocalSource)
- {
- RefreshLocalItems();
- }
- RefreshFavorites();
+ // Rebuild rather than just reset: picks up memes added since last time, and lets a
+ // changed "animate GIF previews" setting take effect.
+ _ = RefreshAsync();
}
///
@@ -286,7 +322,7 @@ public partial class PickerViewModel : ObservableObject
///
public async Task LoadMoreAsync()
{
- if (IsLocalSource || _showingResolvedLink || !_remoteHasMore || _isLoadingMore) return;
+ if (IsLocalSource || ShowingFavorites || _showingResolvedLink || !_remoteHasMore || _isLoadingMore) return;
var provider = ActiveProvider;
if (provider is null || !provider.IsConfigured) return;
@@ -303,14 +339,11 @@ public partial class PickerViewModel : ObservableObject
foreach (var result in results)
{
- var tile = new MemeTileViewModel(result, _gifCache);
- Items.Add(tile);
- _ = tile.LoadThumbnailAsync();
+ AddRemoteTile(result);
}
_remoteOffset += results.Count;
_remoteHasMore = results.Count >= RemoteResultLimit;
- SyncFavoriteFlags();
StatusMessage = $"{Items.Count} result(s).";
}
catch (Exception ex)
@@ -326,7 +359,7 @@ public partial class PickerViewModel : ObservableObject
private async Task RefreshAsync()
{
- // Any pending remote search is stale the moment the query or tab changes.
+ // Any pending remote search is stale the moment the query, tab or view changes.
_searchCts?.Cancel();
_searchCts?.Dispose();
_searchCts = null;
@@ -335,6 +368,12 @@ public partial class PickerViewModel : ObservableObject
_remoteHasMore = false;
_showingResolvedLink = false;
+ if (ShowingFavorites)
+ {
+ RefreshFavoriteItems();
+ return;
+ }
+
if (IsLocalSource)
{
RefreshLocalItems();
@@ -375,15 +414,11 @@ public partial class PickerViewModel : ObservableObject
Items.Clear();
foreach (var result in results)
{
- var tile = new MemeTileViewModel(result, _gifCache);
- Items.Add(tile);
- _ = tile.LoadThumbnailAsync(cts.Token);
+ AddRemoteTile(result, cts.Token);
}
_remoteOffset = results.Count;
_remoteHasMore = results.Count >= RemoteResultLimit;
-
- SyncFavoriteFlags();
StatusMessage = results.Count == 0 ? "No results." : $"{results.Count} result(s).";
}
catch (OperationCanceledException)
@@ -417,16 +452,33 @@ public partial class PickerViewModel : ObservableObject
return;
}
- var tile = new MemeTileViewModel(result, _gifCache);
- Items.Add(tile);
- _ = tile.LoadThumbnailAsync(ct);
+ AddRemoteTile(result, ct);
// A single resolved item has nothing to page through.
_showingResolvedLink = true;
- SyncFavoriteFlags();
StatusMessage = "Found 1 GIF from link.";
}
+ private void AddRemoteTile(GifSearchResult result, CancellationToken ct = default)
+ {
+ var tile = new MemeTileViewModel(result, _gifCache, AnimatePreviews)
+ {
+ IsFavorite = _favorites.IsFavorite(result.Source, result.Id),
+ };
+ Items.Add(tile);
+ _ = tile.LoadThumbnailAsync(ct);
+ }
+
+ private void AddLocalTile(LocalMemeRecord record)
+ {
+ var tile = new MemeTileViewModel(record, _library.GetFullPath(record), AnimatePreviews)
+ {
+ IsFavorite = _favorites.IsFavorite(MemeSource.Local, record.Id),
+ };
+ Items.Add(tile);
+ _ = tile.LoadThumbnailAsync();
+ }
+
private void RefreshLocalItems()
{
var records = string.IsNullOrWhiteSpace(SearchText)
@@ -436,60 +488,48 @@ public partial class PickerViewModel : ObservableObject
Items.Clear();
foreach (var record in records)
{
- var tile = new MemeTileViewModel(record, _library.GetFullPath(record));
- Items.Add(tile);
- _ = tile.LoadThumbnailAsync();
+ AddLocalTile(record);
}
- SyncFavoriteFlags();
StatusMessage = string.Empty;
}
- private void RefreshFavorites()
+ /// Favourites for the active source, narrowed by the search box like any other view.
+ private void RefreshFavoriteItems()
{
- Favorites.Clear();
+ Items.Clear();
+ var query = SearchText;
foreach (var fav in _favorites.GetForSource(ActiveSource))
{
- MemeTileViewModel tile;
-
if (fav.Source == MemeSource.Local)
{
var record = _library.GetAll().FirstOrDefault(r => r.Id == fav.Key);
- // Favourite pointing at a meme that's since been deleted - skip it rather than
- // rendering a broken tile.
+ // Favourite pointing at a meme that has since been deleted - skip it rather
+ // than rendering a broken tile.
if (record is null) continue;
- tile = new MemeTileViewModel(record, _library.GetFullPath(record));
+ if (!Matches(record.OriginalFileName, query)) continue;
+ AddLocalTile(record);
}
else
{
if (fav.PreviewUrl is null || fav.FullUrl is null) continue;
- tile = new MemeTileViewModel(
- new GifSearchResult
- {
- Id = fav.Key,
- Title = fav.Title ?? "Favourite",
- PreviewUrl = fav.PreviewUrl,
- FullUrl = fav.FullUrl,
- Source = fav.Source,
- },
- _gifCache);
+ if (!Matches(fav.Title ?? string.Empty, query)) continue;
+
+ AddRemoteTile(new GifSearchResult
+ {
+ Id = fav.Key,
+ Title = fav.Title ?? "Favourite",
+ PreviewUrl = fav.PreviewUrl,
+ FullUrl = fav.FullUrl,
+ Source = fav.Source,
+ });
}
-
- tile.IsFavorite = true;
- Favorites.Add(tile);
- _ = tile.LoadThumbnailAsync();
}
- HasFavorites = Favorites.Count > 0;
+ StatusMessage = Items.Count == 0 ? string.Empty : $"{Items.Count} favourite(s).";
}
- /// Keeps the star state on the main grid in step with the favourites store.
- private void SyncFavoriteFlags()
- {
- foreach (var tile in Items)
- {
- tile.IsFavorite = _favorites.IsFavorite(tile.Source, tile.FavoriteKey);
- }
- }
+ private static bool Matches(string text, string query) =>
+ string.IsNullOrWhiteSpace(query) || text.Contains(query, StringComparison.OrdinalIgnoreCase);
}
diff --git a/src/EbbesMemeClipboard/ViewModels/SettingsViewModel.cs b/src/EbbesMemeClipboard/ViewModels/SettingsViewModel.cs
index 46adaf7..6c67729 100644
--- a/src/EbbesMemeClipboard/ViewModels/SettingsViewModel.cs
+++ b/src/EbbesMemeClipboard/ViewModels/SettingsViewModel.cs
@@ -26,6 +26,9 @@ public partial class SettingsViewModel : ObservableObject
[ObservableProperty]
private string _giphyApiKey = string.Empty;
+ [ObservableProperty]
+ private bool _playGifPreviews;
+
public bool IsCopyOnly
{
get => InsertMode == InsertMode.CopyOnly;
@@ -52,6 +55,7 @@ public partial class SettingsViewModel : ObservableObject
_insertMode = _settings.Current.InsertMode;
_giphyApiKey = _settings.Current.GiphyApiKey ?? string.Empty;
+ _playGifPreviews = _settings.Current.PlayGifPreviews;
_hotkeyDisplay = HotkeyFormatter.Format(_settings.Current.HotkeyModifiers, _settings.Current.HotkeyKey);
// Read from the registry rather than a stored setting, so the checkbox reflects reality
// even if the entry was removed outside the app.
@@ -69,6 +73,12 @@ public partial class SettingsViewModel : ObservableObject
partial void OnAutostartEnabledChanged(bool value) => _autostart.SetEnabled(value);
+ partial void OnPlayGifPreviewsChanged(bool value)
+ {
+ _settings.Current.PlayGifPreviews = value;
+ _ = _settings.SaveAsync();
+ }
+
partial void OnGiphyApiKeyChanged(string value)
{
_settings.Current.GiphyApiKey = string.IsNullOrWhiteSpace(value) ? null : value.Trim();
diff --git a/src/EbbesMemeClipboard/Views/PickerWindow.xaml b/src/EbbesMemeClipboard/Views/PickerWindow.xaml
index b62c715..aa1f7ac 100644
--- a/src/EbbesMemeClipboard/Views/PickerWindow.xaml
+++ b/src/EbbesMemeClipboard/Views/PickerWindow.xaml
@@ -4,6 +4,7 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:EbbesMemeClipboard.ViewModels"
xmlns:models="clr-namespace:EbbesMemeClipboard.Models"
+ xmlns:gif="clr-namespace:XamlAnimatedGif;assembly=XamlAnimatedGif"
Title="Meme Clipboard"
Width="420" Height="520"
WindowStyle="None"
@@ -98,7 +99,13 @@
CommandParameter="{Binding PlacementTarget.DataContext, RelativeSource={RelativeSource AncestorType=ContextMenu}}"/>
-
+
+
@@ -133,18 +140,27 @@
-
-
+ Tag="{Binding ShowingFavorites}"
+ Command="{Binding ToggleFavoritesViewCommand}"
+ ToolTip="Show only your favourites from this source"/>
+
@@ -182,61 +198,29 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
diff --git a/src/EbbesMemeClipboard/Views/SettingsWindow.xaml b/src/EbbesMemeClipboard/Views/SettingsWindow.xaml
index ff00c51..27ff3d4 100644
--- a/src/EbbesMemeClipboard/Views/SettingsWindow.xaml
+++ b/src/EbbesMemeClipboard/Views/SettingsWindow.xaml
@@ -37,6 +37,11 @@
+
+
+