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 @@ - - -