diff --git a/src/EbbesMemeClipboard/Models/AppSettings.cs b/src/EbbesMemeClipboard/Models/AppSettings.cs
index 35733e1..3d3b2a7 100644
--- a/src/EbbesMemeClipboard/Models/AppSettings.cs
+++ b/src/EbbesMemeClipboard/Models/AppSettings.cs
@@ -22,11 +22,8 @@ public sealed class AppSettings
public bool PlayGifPreviews { get; set; }
///
- /// Which tab the picker reopens on. Persisted so the choice survives an app restart, not
- /// just a hide/show. Defaults to the favourites view, since that is where the memes you
- /// actually reach for most often live.
+ /// Which source tab the picker reopens on. Persisted so the choice survives an app restart,
+ /// not just a hide/show.
///
public MemeSource LastSource { get; set; } = MemeSource.Local;
-
- public bool LastShowingFavorites { get; set; } = true;
}
diff --git a/src/EbbesMemeClipboard/ViewModels/PickerViewModel.cs b/src/EbbesMemeClipboard/ViewModels/PickerViewModel.cs
index 1b53839..65bf87c 100644
--- a/src/EbbesMemeClipboard/ViewModels/PickerViewModel.cs
+++ b/src/EbbesMemeClipboard/ViewModels/PickerViewModel.cs
@@ -1,6 +1,7 @@
using System.Collections.ObjectModel;
using System.Collections.Specialized;
using System.IO;
+using System.Windows.Data;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using EbbesMemeClipboard.Models;
@@ -35,6 +36,21 @@ public partial class PickerViewModel : ObservableObject
public ObservableCollection Items { get; } = new();
+ /// Favourites for the active source, shown in the favourites submenu.
+ public ObservableCollection Favorites { get; } = new();
+
+ ///
+ /// Holds at most one navigation tile ("Favourites" or "Back") that is rendered as the first
+ /// cell of the grid, ahead of the actual memes.
+ ///
+ public ObservableCollection HeaderTiles { get; } = new();
+
+ private readonly CompositeCollection _mainGrid = new();
+ private readonly CompositeCollection _favoritesGrid = new();
+
+ /// What the grid binds to: the nav tile followed by either the results or the favourites.
+ public CompositeCollection GridSource => IsInFavoritesView ? _favoritesGrid : _mainGrid;
+
[ObservableProperty]
private string _searchText = string.Empty;
@@ -47,12 +63,11 @@ 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 _showingFavorites;
+ private bool _hasFavorites;
+
+ [ObservableProperty]
+ private bool _isInFavoritesView;
[ObservableProperty]
private MemeSource _activeSource = MemeSource.Local;
@@ -60,20 +75,13 @@ public partial class PickerViewModel : ObservableObject
public bool IsLocalSource => ActiveSource == MemeSource.Local;
public bool IsGiphySource => ActiveSource == MemeSource.Giphy;
- /// Importing only applies to the local library, and not while browsing favourites.
- public bool CanImport => IsLocalSource && !ShowingFavorites;
+ public string SearchPlaceholder => IsLocalSource
+ ? "Search your memes..."
+ : ActiveProvider?.SearchPlaceholder ?? "Search...";
- public string SearchPlaceholder => ShowingFavorites
- ? "Search favourites..."
- : IsLocalSource
- ? "Search your memes..."
- : ActiveProvider?.SearchPlaceholder ?? "Search...";
+ public bool ShowEmptyState => Items.Count == 0 && !IsBusy && IsLocalSource && !IsInFavoritesView;
- 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.";
+ public string EmptyStateText => "No memes yet. Click +, drag files in, or paste with Ctrl+V.";
private IGifProvider? ActiveProvider => _providers.FirstOrDefault(p => p.Source == ActiveSource);
@@ -100,33 +108,55 @@ public partial class PickerViewModel : ObservableObject
Items.CollectionChanged += OnItemsChanged;
- // Restore the tab the picker was last left on. Assigned to the backing fields directly
- // so restoring doesn't count as a user change and immediately re-save.
+ // Restore persisted view state. Assigned to the backing fields directly so restoring
+ // doesn't count as a user change and immediately re-save.
_activeSource = _settings.Current.LastSource;
- _showingFavorites = _settings.Current.LastShowingFavorites;
- // Seed the grid for the restored view. Remote sources are skipped here because that
- // needs an async call - OnShown covers it when the window is actually opened.
- if (_showingFavorites) RefreshFavoriteItems();
- else if (IsLocalSource) RefreshLocalItems();
+ _mainGrid.Add(new CollectionContainer { Collection = HeaderTiles });
+ _mainGrid.Add(new CollectionContainer { Collection = Items });
+ _favoritesGrid.Add(new CollectionContainer { Collection = HeaderTiles });
+ _favoritesGrid.Add(new CollectionContainer { Collection = Favorites });
+
+ // Seed the grid. Remote sources are skipped here because that needs an async call -
+ // OnShown covers it when the window is actually opened.
+ if (IsLocalSource) RefreshLocalItems();
+ RefreshFavorites();
}
private void OnItemsChanged(object? sender, NotifyCollectionChangedEventArgs e) =>
OnPropertyChanged(nameof(ShowEmptyState));
- partial void OnSearchTextChanged(string value) => _ = RefreshAsync();
+ partial void OnSearchTextChanged(string value)
+ {
+ // A query is a request to search, so it always leaves the favourites submenu.
+ if (!string.IsNullOrWhiteSpace(value)) IsInFavoritesView = false;
+ UpdateHeaderTile();
+ _ = RefreshAsync();
+ }
+
+ partial void OnHasFavoritesChanged(bool value) => UpdateHeaderTile();
partial void OnIsBusyChanged(bool value) => OnPropertyChanged(nameof(ShowEmptyState));
- partial void OnShowingFavoritesChanged(bool value)
+ partial void OnIsInFavoritesViewChanged(bool value)
{
- OnPropertyChanged(nameof(SearchPlaceholder));
- OnPropertyChanged(nameof(CanImport));
- OnPropertyChanged(nameof(EmptyStateText));
+ OnPropertyChanged(nameof(GridSource));
OnPropertyChanged(nameof(ShowEmptyState));
- SearchText = string.Empty;
- PersistViewState();
- _ = RefreshAsync();
+ UpdateHeaderTile();
+ }
+
+ private void UpdateHeaderTile()
+ {
+ HeaderTiles.Clear();
+
+ if (IsInFavoritesView)
+ {
+ HeaderTiles.Add(new FolderTileViewModel("\u25C4", "Back"));
+ }
+ else if (HasFavorites && string.IsNullOrWhiteSpace(SearchText))
+ {
+ HeaderTiles.Add(new FolderTileViewModel("\u2605", "Favourites"));
+ }
}
partial void OnActiveSourceChanged(MemeSource value)
@@ -134,27 +164,24 @@ public partial class PickerViewModel : ObservableObject
OnPropertyChanged(nameof(IsLocalSource));
OnPropertyChanged(nameof(IsGiphySource));
OnPropertyChanged(nameof(SearchPlaceholder));
- OnPropertyChanged(nameof(CanImport));
OnPropertyChanged(nameof(ShowEmptyState));
+
+ _settings.Current.LastSource = value;
+ _ = _settings.SaveAsync();
+
+ IsInFavoritesView = false;
SearchText = string.Empty;
- PersistViewState();
+ 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();
}
- private void PersistViewState()
- {
- _settings.Current.LastSource = ActiveSource;
- _settings.Current.LastShowingFavorites = ShowingFavorites;
- _ = _settings.SaveAsync();
- }
-
[RelayCommand]
private void SelectSource(MemeSource source) => ActiveSource = source;
[RelayCommand]
- private void ToggleFavoritesView() => ShowingFavorites = !ShowingFavorites;
+ private void ToggleFavoritesView() => IsInFavoritesView = !IsInFavoritesView;
[RelayCommand]
private async Task SelectItemAsync(MemeTileViewModel? tile)
@@ -198,11 +225,7 @@ 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
{
@@ -215,9 +238,11 @@ public partial class PickerViewModel : ObservableObject
FullUrl = tile.RemoteResult?.FullUrl,
DateAdded = DateTimeOffset.Now,
});
- tile.IsFavorite = true;
StatusMessage = $"Added {tile.DisplayName} to favourites";
}
+
+ RefreshFavorites();
+ SyncFavoriteFlags();
}
[RelayCommand]
@@ -230,7 +255,8 @@ public partial class PickerViewModel : ObservableObject
// Drop the matching favourite too, otherwise it lingers pointing at a deleted file.
await _favorites.RemoveAsync(MemeSource.Local, tile.Record.Id);
- await RefreshAsync();
+ RefreshLocalItems();
+ RefreshFavorites();
StatusMessage = $"Removed {name}";
}
@@ -296,7 +322,6 @@ public partial class PickerViewModel : ObservableObject
return;
}
- ShowingFavorites = false;
ActiveSource = MemeSource.Local;
RefreshLocalItems();
StatusMessage = $"Pasted {name}";
@@ -320,7 +345,6 @@ public partial class PickerViewModel : ObservableObject
}
var imported = await _library.ImportAsync(candidates);
- ShowingFavorites = false;
ActiveSource = MemeSource.Local;
RefreshLocalItems();
StatusMessage = $"Added {imported.Count} meme(s).";
@@ -328,7 +352,9 @@ public partial class PickerViewModel : ObservableObject
public void OnShown()
{
+ IsInFavoritesView = false;
SearchText = string.Empty;
+ RefreshFavorites();
// Rebuild rather than just reset: picks up memes added since last time, and lets a
// changed "animate GIF previews" setting take effect.
_ = RefreshAsync();
@@ -340,7 +366,7 @@ public partial class PickerViewModel : ObservableObject
///
public async Task LoadMoreAsync()
{
- if (IsLocalSource || ShowingFavorites || _showingResolvedLink || !_remoteHasMore || _isLoadingMore) return;
+ if (IsLocalSource || _showingResolvedLink || !_remoteHasMore || _isLoadingMore) return;
var provider = ActiveProvider;
if (provider is null || !provider.IsConfigured) return;
@@ -377,7 +403,7 @@ public partial class PickerViewModel : ObservableObject
private async Task RefreshAsync()
{
- // Any pending remote search is stale the moment the query, tab or view changes.
+ // Any pending remote search is stale the moment the query or tab changes.
_searchCts?.Cancel();
_searchCts?.Dispose();
_searchCts = null;
@@ -386,12 +412,6 @@ public partial class PickerViewModel : ObservableObject
_remoteHasMore = false;
_showingResolvedLink = false;
- if (ShowingFavorites)
- {
- RefreshFavoriteItems();
- return;
- }
-
if (IsLocalSource)
{
RefreshLocalItems();
@@ -487,16 +507,6 @@ public partial class PickerViewModel : ObservableObject
_ = 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)
@@ -506,48 +516,68 @@ public partial class PickerViewModel : ObservableObject
Items.Clear();
foreach (var record in records)
{
- AddLocalTile(record);
+ var tile = new MemeTileViewModel(record, _library.GetFullPath(record), AnimatePreviews)
+ {
+ IsFavorite = _favorites.IsFavorite(MemeSource.Local, record.Id),
+ };
+ Items.Add(tile);
+ _ = tile.LoadThumbnailAsync();
}
StatusMessage = string.Empty;
}
- /// Favourites for the active source, narrowed by the search box like any other view.
- private void RefreshFavoriteItems()
+ private void RefreshFavorites()
{
- Items.Clear();
+ Favorites.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 has since been deleted - skip it rather
// than rendering a broken tile.
if (record is null) continue;
- if (!Matches(record.OriginalFileName, query)) continue;
- AddLocalTile(record);
+ tile = new MemeTileViewModel(record, _library.GetFullPath(record), AnimatePreviews);
}
else
{
if (fav.PreviewUrl is null || fav.FullUrl is null) continue;
- 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 = new MemeTileViewModel(
+ new GifSearchResult
+ {
+ Id = fav.Key,
+ Title = fav.Title ?? "Favourite",
+ PreviewUrl = fav.PreviewUrl,
+ FullUrl = fav.FullUrl,
+ Source = fav.Source,
+ },
+ _gifCache,
+ AnimatePreviews);
}
+
+ tile.IsFavorite = true;
+ Favorites.Add(tile);
+ _ = tile.LoadThumbnailAsync();
}
- StatusMessage = Items.Count == 0 ? string.Empty : $"{Items.Count} favourite(s).";
+ HasFavorites = Favorites.Count > 0;
+ // Last favourite removed while inside the submenu: nothing left to show, so step out.
+ if (!HasFavorites) IsInFavoritesView = false;
}
- private static bool Matches(string text, string query) =>
- string.IsNullOrWhiteSpace(query) || text.Contains(query, StringComparison.OrdinalIgnoreCase);
+ /// 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);
+ }
+ }
}
+
+/// A navigation tile occupying the first grid cell (opens or leaves the favourites submenu).
+public sealed record FolderTileViewModel(string Glyph, string Label);
diff --git a/src/EbbesMemeClipboard/Views/PickerWindow.xaml b/src/EbbesMemeClipboard/Views/PickerWindow.xaml
index 1a3641b..9c7018c 100644
--- a/src/EbbesMemeClipboard/Views/PickerWindow.xaml
+++ b/src/EbbesMemeClipboard/Views/PickerWindow.xaml
@@ -79,7 +79,7 @@
-
+
+
+
+
+
@@ -140,28 +153,18 @@
-
-
-
-
-
-
-
-
+ Tag="{Binding IsLocalSource}"
+ Command="{Binding SelectSourceCommand}"
+ CommandParameter="{x:Static models:MemeSource.Local}"/>
+
+
@@ -198,30 +201,31 @@
-
-
+
+
+
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
diff --git a/src/EbbesMemeClipboard/Views/PickerWindow.xaml.cs b/src/EbbesMemeClipboard/Views/PickerWindow.xaml.cs
index ebefc94..af6590a 100644
--- a/src/EbbesMemeClipboard/Views/PickerWindow.xaml.cs
+++ b/src/EbbesMemeClipboard/Views/PickerWindow.xaml.cs
@@ -94,8 +94,8 @@ public partial class PickerWindow : Window
// Switching tab/view or typing replaces the whole grid, so a retained scroll offset
// would leave the user part-way down a completely different set of results.
if (e.PropertyName is nameof(PickerViewModel.ActiveSource)
- or nameof(PickerViewModel.ShowingFavorites)
- or nameof(PickerViewModel.SearchText))
+ or nameof(PickerViewModel.SearchText)
+ or nameof(PickerViewModel.IsInFavoritesView))
{
ResultsScrollViewer.ScrollToTop();
}