5 Commits
12 changed files with 592 additions and 91 deletions
+142 -1
View File
@@ -1,2 +1,143 @@
# ebbes-meme-clipboard # Ebbe's Meme Clipboard
A meme and GIF picker for Windows, inspired by the built-in Windows Emoji Picker (<kbd>Win</kbd>+<kbd>.</kbd>).
Press a global hotkey anywhere, a small popup appears, search your memes or Giphy, click one —
and it lands straight in whatever app you were just typing in. It lives in the system tray and
stays out of the way until you need it.
---
## Features
- **Global hotkey** — opens the picker over any app. Defaults to <kbd>Ctrl</kbd>+<kbd>Alt</kbd>+<kbd>M</kbd>, fully remappable (the <kbd>Win</kbd> key works too, e.g. <kbd>Win</kbd>+<kbd>Y</kbd>).
- **Your own meme library** — add images by clicking **+**, dragging files onto the window, or simply pasting with <kbd>Ctrl</kbd>+<kbd>V</kbd>. Supports JPG, PNG and GIF.
- **Giphy search** — a second tab searches Giphy directly (needs a free API key, see below). Scroll and it keeps loading more results. You can also paste a Giphy link straight into the search box to jump to that one GIF.
- **Favourites** — right-click any meme to pin it to a ★ Favourites row at the top of that tab. Kept separately per source.
- **Three insert modes** — copy to clipboard, paste into the active window, or paste *and* send instantly.
- **Animated GIFs stay animated** — the clipboard is written in several formats at once so GIFs paste as real animations in Discord, Slack and Teams, rather than as a flattened still frame.
- **Search as you type** — filters your library by filename; Giphy results are debounced so typing doesn't burn through the API rate limit.
- **Runs from the tray** — optional start-with-Windows, and a movable, dismiss-on-click-away popup.
## Installation
**Installer (recommended)** — run `EbbesMemeClipboard-Setup-<version>.exe`. It installs per-user by
default (no admin prompt) but lets you choose "just me" or "all users", and offers optional desktop
and start-with-Windows shortcuts.
**Portable** — grab `EbbesMemeClipboard.exe` and run it. No installation, no .NET runtime needed;
everything is bundled. It'll write its library and settings to `%AppData%` as usual.
> Windows may show a SmartScreen warning on first run, because the executable isn't code-signed.
> Choose *More info → Run anyway*. Signing requires a paid certificate from a certificate authority.
## Usage
| Action | How |
|---|---|
| Open / close the picker | <kbd>Ctrl</kbd>+<kbd>Alt</kbd>+<kbd>M</kbd>, or left-click the tray icon |
| Insert a meme | Click it |
| Add memes | **+** button, drag files in, or <kbd>Ctrl</kbd>+<kbd>V</kbd> |
| Favourite / unfavourite | Right-click a meme |
| Delete a meme | Right-click → **Remove** (goes to the Recycle Bin) |
| Search | Just start typing |
| Move the window | Drag the title bar |
| Close the picker | <kbd>Esc</kbd>, or click elsewhere |
| Settings / quit | Right-click the tray icon |
Pasting plain text into the window still goes to the search box — only images get imported.
### Insert modes
Set these under **Settings → Insert Mode**:
- **Copy to clipboard** — just copies; you paste it yourself.
- **Paste into active window** *(default)* — restores focus to the app you came from and pastes for you.
- **Paste and send instantly** — the above, plus <kbd>Enter</kbd>. Handy in chat apps, but it *will* send the message immediately, so it's off by default.
### Giphy setup
The Giphy tab needs your own free API key:
1. Go to [developers.giphy.com](https://developers.giphy.com), create an account and an app, and choose **API Key**.
2. Paste the key into **Settings → Giphy**.
The free tier allows 100 requests per hour, which is plenty for personal use. Giphy's terms
require the "Powered by GIPHY" attribution shown in the app whenever their results are displayed.
You can also paste a Giphy link into the search box — share links
(`giphy.com/gifs/funny-cat-<id>`), direct media links (`media.giphy.com/media/<id>/giphy.gif`)
and `i.giphy.com` image links all work, and resolve to that single GIF.
## Where your data lives
```
%AppData%\EbbesMemeClipboard\
settings.json hotkey, insert mode, Giphy API key
favorites.json favourites, per source
Library\ your imported memes + an index
%LocalAppData%\EbbesMemeClipboard\
GifCache\ downloaded Giphy GIFs (re-downloadable; safe to delete)
```
Uninstalling deliberately leaves your memes, favourites and settings in place — only the
re-downloadable cache is cleared.
## Known limitations
- **The popup appears at the mouse cursor, not the text caret.** The real Emoji Panel can follow
the caret because it's a privileged part of the Windows shell; third-party apps have no
equivalent access.
- **Auto-paste doesn't work into apps running as administrator.** Windows blocks a normal program
from sending input to an elevated window (UIPI). The meme is still copied — just press
<kbd>Ctrl</kbd>+<kbd>V</kbd> yourself.
- **No single-instance guard yet.** If two copies run at once they'll compete for the global
hotkey. Worth checking you don't have both a Startup shortcut *and* the autostart setting enabled.
- **Windows only.** See below.
## Building from source
Requires the [.NET 10 SDK](https://dotnet.microsoft.com/download).
```bash
# run it
dotnet run --project src/EbbesMemeClipboard
# portable single exe -> publish/
dotnet publish src/EbbesMemeClipboard/EbbesMemeClipboard.csproj -c Release -r win-x64 \
--self-contained true -p:PublishSingleFile=true \
-p:IncludeNativeLibrariesForSelfExtract=true -p:EnableCompressionInSingleFile=true \
-o publish
```
To build the installer you'll also need [Inno Setup 6](https://jrsoftware.org/isinfo.php). Note the
publish step deliberately disables .NET's own compression, so Inno's LZMA2 can compress the raw
bytes instead — that yields a noticeably smaller setup and a faster-starting app:
```bash
dotnet publish src/EbbesMemeClipboard/EbbesMemeClipboard.csproj -c Release -r win-x64 \
--self-contained true -p:PublishSingleFile=true \
-p:IncludeNativeLibrariesForSelfExtract=true -p:EnableCompressionInSingleFile=false \
-o publish-installer
"%LocalAppData%\Programs\Inno Setup 6\ISCC.exe" installer\EbbesMemeClipboard.iss
```
### Tech stack
.NET 10 · WPF · MVVM ([CommunityToolkit.Mvvm](https://github.com/CommunityToolkit/dotnet)) ·
[H.NotifyIcon](https://github.com/HavenDV/H.NotifyIcon) for the tray icon ·
`Microsoft.Extensions.DependencyInjection`
### Platform support
Windows only, and not portable without a rewrite. WPF doesn't exist on macOS or Linux, and the
features that make the app work — global hotkeys, synthetic paste, tray icon, clipboard formats,
autostart — are all built directly on Win32. The data and business-logic layer would carry over
to a cross-platform UI framework such as [Avalonia](https://avaloniaui.net), but every
platform-integration service and the entire UI would need reimplementing.
## License
[MIT](LICENSE)
+1 -1
View File
@@ -16,7 +16,7 @@
; anyone who just wants the portable single exe. ; anyone who just wants the portable single exe.
#define AppName "Ebbe's Meme Clipboard" #define AppName "Ebbe's Meme Clipboard"
#define AppVersion "1.1.0" #define AppVersion "1.1.5"
#define AppPublisher "Ebbe Baß" #define AppPublisher "Ebbe Baß"
#define AppExeName "EbbesMemeClipboard.exe" #define AppExeName "EbbesMemeClipboard.exe"
#define SourceExe "..\publish-installer\EbbesMemeClipboard.exe" #define SourceExe "..\publish-installer\EbbesMemeClipboard.exe"
@@ -22,6 +22,7 @@
<PackageReference Include="WPF-UI" Version="4.3.0" /> <PackageReference Include="WPF-UI" Version="4.3.0" />
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.2" /> <PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.2" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.0" /> <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.0" />
<PackageReference Include="XamlAnimatedGif" Version="2.3.2" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
@@ -14,4 +14,16 @@ public sealed class AppSettings
/// key is its own source of truth, so caching it would risk the two drifting apart. /// key is its own source of truth, so caching it would risk the two drifting apart.
/// </summary> /// </summary>
public string? GiphyApiKey { get; set; } public string? GiphyApiKey { get; set; }
/// <summary>
/// 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.
/// </summary>
public bool PlayGifPreviews { get; set; }
/// <summary>
/// Which source tab the picker reopens on. Persisted so the choice survives an app restart,
/// not just a hide/show.
/// </summary>
public MemeSource LastSource { get; set; } = MemeSource.Local;
} }
@@ -1,11 +1,13 @@
using System.Net;
using System.Net.Http; using System.Net.Http;
using System.Net.Http.Json; using System.Net.Http.Json;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using System.Text.RegularExpressions;
using EbbesMemeClipboard.Models; using EbbesMemeClipboard.Models;
namespace EbbesMemeClipboard.Services; namespace EbbesMemeClipboard.Services;
public sealed class GiphyGifProvider : IGifProvider public sealed partial class GiphyGifProvider : IGifProvider
{ {
private const string BaseUrl = "https://api.giphy.com/v1/gifs"; private const string BaseUrl = "https://api.giphy.com/v1/gifs";
private const string Rating = "pg-13"; private const string Rating = "pg-13";
@@ -20,45 +22,112 @@ public sealed class GiphyGifProvider : IGifProvider
public string NotConfiguredMessage => public string NotConfiguredMessage =>
"No Giphy API key set. Add one in Settings - a free key is available from developers.giphy.com."; "No Giphy API key set. Add one in Settings - a free key is available from developers.giphy.com.";
public string SearchPlaceholder => "Search Giphy, or paste a GIF link...";
public GiphyGifProvider(HttpClient http, ISettingsService settings) public GiphyGifProvider(HttpClient http, ISettingsService settings)
{ {
_http = http; _http = http;
_settings = settings; _settings = settings;
} }
public async Task<IReadOnlyList<GifSearchResult>> SearchAsync(string query, int limit, CancellationToken ct) public async Task<IReadOnlyList<GifSearchResult>> SearchAsync(string query, int limit, int offset, CancellationToken ct)
{ {
if (!IsConfigured) return Array.Empty<GifSearchResult>(); if (!IsConfigured) return Array.Empty<GifSearchResult>();
var apiKey = Uri.EscapeDataString(_settings.Current.GiphyApiKey!); var apiKey = Uri.EscapeDataString(_settings.Current.GiphyApiKey!);
var url = string.IsNullOrWhiteSpace(query) var url = string.IsNullOrWhiteSpace(query)
? $"{BaseUrl}/trending?api_key={apiKey}&limit={limit}&rating={Rating}" ? $"{BaseUrl}/trending?api_key={apiKey}&limit={limit}&offset={offset}&rating={Rating}"
: $"{BaseUrl}/search?api_key={apiKey}&q={Uri.EscapeDataString(query)}&limit={limit}&rating={Rating}"; : $"{BaseUrl}/search?api_key={apiKey}&q={Uri.EscapeDataString(query)}&limit={limit}&offset={offset}&rating={Rating}";
var response = await _http.GetAsync(url, ct); var response = await _http.GetAsync(url, ct);
response.EnsureSuccessStatusCode(); response.EnsureSuccessStatusCode();
var payload = await response.Content.ReadFromJsonAsync<GiphyResponse>(cancellationToken: ct); var payload = await response.Content.ReadFromJsonAsync<GiphyListResponse>(cancellationToken: ct);
if (payload?.Data is null) return Array.Empty<GifSearchResult>(); if (payload?.Data is null) return Array.Empty<GifSearchResult>();
return payload.Data return payload.Data.Select(ToResult).OfType<GifSearchResult>().ToList();
.Where(item => item.Images?.FixedWidth?.Url is not null && item.Images.Original?.Url is not null)
.Select(item => new GifSearchResult
{
Id = item.Id ?? Guid.NewGuid().ToString("N"),
Title = string.IsNullOrWhiteSpace(item.Title) ? "Giphy GIF" : item.Title,
PreviewUrl = item.Images!.FixedWidth!.Url!,
FullUrl = item.Images.Original!.Url!,
Source = MemeSource.Giphy,
})
.ToList();
} }
private sealed class GiphyResponse public bool CanResolveLink(string text) => TryExtractId(text, out _);
public async Task<GifSearchResult?> ResolveLinkAsync(string text, CancellationToken ct)
{
if (!IsConfigured || !TryExtractId(text, out var id)) return null;
var apiKey = Uri.EscapeDataString(_settings.Current.GiphyApiKey!);
var response = await _http.GetAsync($"{BaseUrl}/{Uri.EscapeDataString(id)}?api_key={apiKey}", ct);
// A link can easily point at something deleted or region-blocked; treat that as "no
// result" rather than surfacing a raw HTTP error to the user.
if (response.StatusCode is HttpStatusCode.NotFound or HttpStatusCode.BadRequest) return null;
response.EnsureSuccessStatusCode();
var payload = await response.Content.ReadFromJsonAsync<GiphySingleResponse>(cancellationToken: ct);
return payload?.Data is null ? null : ToResult(payload.Data);
}
private static GifSearchResult? ToResult(GiphyItem item)
{
var preview = item.Images?.FixedWidth?.Url ?? item.Images?.Original?.Url;
var full = item.Images?.Original?.Url ?? item.Images?.FixedWidth?.Url;
if (preview is null || full is null || item.Id is null) return null;
return new GifSearchResult
{
Id = item.Id,
Title = string.IsNullOrWhiteSpace(item.Title) ? "Giphy GIF" : item.Title,
PreviewUrl = preview,
FullUrl = full,
Source = MemeSource.Giphy,
};
}
/// <summary>
/// Pulls the GIF id out of the various shapes a Giphy link can take - the share URL
/// (giphy.com/gifs/some-slug-ID), a direct media URL, or an i.giphy.com image URL.
/// </summary>
private static bool TryExtractId(string text, out string id)
{
id = string.Empty;
if (string.IsNullOrWhiteSpace(text) || !text.Contains("giphy.com", StringComparison.OrdinalIgnoreCase))
return false;
var trimmed = text.Trim();
foreach (var regex in new[] { MediaUrlRegex(), DirectImageUrlRegex(), ShareUrlRegex() })
{
var match = regex.Match(trimmed);
if (match.Success)
{
id = match.Groups["id"].Value;
return true;
}
}
return false;
}
// media.giphy.com/media/<optional rendition token>/<id>/giphy.gif
[GeneratedRegex(@"media\d*\.giphy\.com/media/(?:.+/)?(?<id>[A-Za-z0-9]{6,})/[^/]*\.(?:gif|webp|mp4)", RegexOptions.IgnoreCase)]
private static partial Regex MediaUrlRegex();
// i.giphy.com/<id>.gif
[GeneratedRegex(@"i\.giphy\.com/(?<id>[A-Za-z0-9]{6,})\.", RegexOptions.IgnoreCase)]
private static partial Regex DirectImageUrlRegex();
// giphy.com/gifs/funny-cat-<id> (also /clips/, /stickers/, /embed/)
[GeneratedRegex(@"giphy\.com/(?:gifs|clips|stickers|embed)/(?:[^/?#]*-)?(?<id>[A-Za-z0-9]{6,})", RegexOptions.IgnoreCase)]
private static partial Regex ShareUrlRegex();
private sealed class GiphyListResponse
{ {
[JsonPropertyName("data")] public List<GiphyItem>? Data { get; set; } [JsonPropertyName("data")] public List<GiphyItem>? Data { get; set; }
} }
private sealed class GiphySingleResponse
{
[JsonPropertyName("data")] public GiphyItem? Data { get; set; }
}
private sealed class GiphyItem private sealed class GiphyItem
{ {
[JsonPropertyName("id")] public string? Id { get; set; } [JsonPropertyName("id")] public string? Id { get; set; }
@@ -12,6 +12,18 @@ public interface IGifProvider
/// <summary>Shown in the UI when IsConfigured is false, explaining how to fix it.</summary> /// <summary>Shown in the UI when IsConfigured is false, explaining how to fix it.</summary>
string NotConfiguredMessage { get; } string NotConfiguredMessage { get; }
/// <summary>An empty query returns whatever the provider considers trending.</summary> /// <summary>Placeholder shown in the search box while it's empty.</summary>
Task<IReadOnlyList<GifSearchResult>> SearchAsync(string query, int limit, CancellationToken ct); string SearchPlaceholder { get; }
/// <summary>
/// An empty query returns whatever the provider considers trending. <paramref name="offset"/>
/// pages through results for infinite scrolling.
/// </summary>
Task<IReadOnlyList<GifSearchResult>> SearchAsync(string query, int limit, int offset, CancellationToken ct);
/// <summary>True when the text looks like a link to a single item on this provider.</summary>
bool CanResolveLink(string text);
/// <summary>Resolves a link from <see cref="CanResolveLink"/> to one result, or null if it can't be found.</summary>
Task<GifSearchResult?> ResolveLinkAsync(string text, CancellationToken ct);
} }
@@ -1,3 +1,4 @@
using System.IO;
using System.Windows.Media.Imaging; using System.Windows.Media.Imaging;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using EbbesMemeClipboard.Models; using EbbesMemeClipboard.Models;
@@ -11,11 +12,10 @@ namespace EbbesMemeClipboard.ViewModels;
/// </summary> /// </summary>
public partial class MemeTileViewModel : ObservableObject 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 const int ThumbnailPixelWidth = 240;
private readonly IGifCacheService? _cache; private readonly IGifCacheService? _cache;
private readonly bool _animatePreviews;
private string? _localPath; private string? _localPath;
public LocalMemeRecord? Record { get; } public LocalMemeRecord? Record { get; }
@@ -35,20 +35,33 @@ public partial class MemeTileViewModel : ObservableObject
[ObservableProperty] [ObservableProperty]
private bool _isFavorite; private bool _isFavorite;
/// <summary>
/// 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.
/// </summary>
[ObservableProperty]
private Uri? _animationUri;
[ObservableProperty]
private Stream? _animationStream;
public string FavoriteActionLabel => IsFavorite ? "Remove from favourites" : "Add to favourites"; public string FavoriteActionLabel => IsFavorite ? "Remove from favourites" : "Add to favourites";
partial void OnIsFavoriteChanged(bool value) => OnPropertyChanged(nameof(FavoriteActionLabel)); 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; Record = record;
_localPath = fullPath; _localPath = fullPath;
_animatePreviews = animatePreviews;
} }
public MemeTileViewModel(GifSearchResult remoteResult, IGifCacheService cache) public MemeTileViewModel(GifSearchResult remoteResult, IGifCacheService cache, bool animatePreviews = false)
{ {
RemoteResult = remoteResult; RemoteResult = remoteResult;
_cache = cache; _cache = cache;
_animatePreviews = animatePreviews;
} }
/// <summary> /// <summary>
@@ -71,11 +84,23 @@ public partial class MemeTileViewModel : ObservableObject
{ {
var path = _localPath!; var path = _localPath!;
Thumbnail = await Task.Run(() => ImageDecoding.DecodeFirstFrame(path, ThumbnailPixelWidth), ct); Thumbnail = await Task.Run(() => ImageDecoding.DecodeFirstFrame(path, ThumbnailPixelWidth), ct);
if (_animatePreviews && ImageDecoding.IsGif(path))
{
AnimationUri = new Uri(path);
}
} }
else else
{ {
var bytes = await _cache!.DownloadPreviewAsync(RemoteResult!, ct); var bytes = await _cache!.DownloadPreviewAsync(RemoteResult!, ct);
Thumbnail = await Task.Run(() => ImageDecoding.DecodeFirstFrameFromBytes(bytes, ThumbnailPixelWidth), 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) catch (OperationCanceledException)
@@ -88,4 +113,7 @@ public partial class MemeTileViewModel : ObservableObject
// renders empty and stays clickable. // renders empty and stays clickable.
} }
} }
private static bool LooksLikeGif(string url) =>
url.Contains(".gif", StringComparison.OrdinalIgnoreCase);
} }
@@ -1,5 +1,7 @@
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using System.Collections.Specialized;
using System.IO; using System.IO;
using System.Windows.Data;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using EbbesMemeClipboard.Models; using EbbesMemeClipboard.Models;
@@ -9,7 +11,9 @@ namespace EbbesMemeClipboard.ViewModels;
public partial class PickerViewModel : ObservableObject public partial class PickerViewModel : ObservableObject
{ {
private const int RemoteResultLimit = 30; // 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); private static readonly TimeSpan SearchDebounce = TimeSpan.FromMilliseconds(350);
private readonly ILocalMemeLibraryService _library; private readonly ILocalMemeLibraryService _library;
@@ -23,11 +27,30 @@ public partial class PickerViewModel : ObservableObject
/// <summary>Cancels the in-flight remote search when a newer keystroke supersedes it.</summary> /// <summary>Cancels the in-flight remote search when a newer keystroke supersedes it.</summary>
private CancellationTokenSource? _searchCts; private CancellationTokenSource? _searchCts;
private int _remoteOffset;
private bool _remoteHasMore;
private bool _isLoadingMore;
/// <summary>True when the grid is showing a single item resolved from a pasted link.</summary>
private bool _showingResolvedLink;
public ObservableCollection<MemeTileViewModel> Items { get; } = new(); public ObservableCollection<MemeTileViewModel> Items { get; } = new();
/// <summary>Favourites for the currently active source only.</summary> /// <summary>Favourites for the active source, shown in the favourites submenu.</summary>
public ObservableCollection<MemeTileViewModel> Favorites { get; } = new(); public ObservableCollection<MemeTileViewModel> Favorites { get; } = new();
/// <summary>
/// Holds at most one navigation tile ("Favourites" or "Back") that is rendered as the first
/// cell of the grid, ahead of the actual memes.
/// </summary>
public ObservableCollection<FolderTileViewModel> HeaderTiles { get; } = new();
private readonly CompositeCollection _mainGrid = new();
private readonly CompositeCollection _favoritesGrid = new();
/// <summary>What the grid binds to: the nav tile followed by either the results or the favourites.</summary>
public CompositeCollection GridSource => IsInFavoritesView ? _favoritesGrid : _mainGrid;
[ObservableProperty] [ObservableProperty]
private string _searchText = string.Empty; private string _searchText = string.Empty;
@@ -43,12 +66,27 @@ public partial class PickerViewModel : ObservableObject
[ObservableProperty] [ObservableProperty]
private bool _hasFavorites; private bool _hasFavorites;
[ObservableProperty]
private bool _isInFavoritesView;
[ObservableProperty] [ObservableProperty]
private MemeSource _activeSource = MemeSource.Local; private MemeSource _activeSource = MemeSource.Local;
public bool IsLocalSource => ActiveSource == MemeSource.Local; public bool IsLocalSource => ActiveSource == MemeSource.Local;
public bool IsGiphySource => ActiveSource == MemeSource.Giphy; public bool IsGiphySource => ActiveSource == MemeSource.Giphy;
public string SearchPlaceholder => IsLocalSource
? "Search your memes..."
: ActiveProvider?.SearchPlaceholder ?? "Search...";
public bool ShowEmptyState => Items.Count == 0 && !IsBusy && IsLocalSource && !IsInFavoritesView;
public string EmptyStateText => "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 event EventHandler? RequestClose;
public PickerViewModel( public PickerViewModel(
@@ -68,16 +106,70 @@ public partial class PickerViewModel : ObservableObject
_favorites = favorites; _favorites = favorites;
_providers = providers.ToList(); _providers = providers.ToList();
RefreshLocalItems(); Items.CollectionChanged += OnItemsChanged;
// 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;
_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(); RefreshFavorites();
} }
partial void OnSearchTextChanged(string value) => _ = RefreshAsync(); private void OnItemsChanged(object? sender, NotifyCollectionChangedEventArgs e) =>
OnPropertyChanged(nameof(ShowEmptyState));
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 OnIsInFavoritesViewChanged(bool value)
{
OnPropertyChanged(nameof(GridSource));
OnPropertyChanged(nameof(ShowEmptyState));
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) partial void OnActiveSourceChanged(MemeSource value)
{ {
OnPropertyChanged(nameof(IsLocalSource)); OnPropertyChanged(nameof(IsLocalSource));
OnPropertyChanged(nameof(IsGiphySource)); OnPropertyChanged(nameof(IsGiphySource));
OnPropertyChanged(nameof(SearchPlaceholder));
OnPropertyChanged(nameof(ShowEmptyState));
_settings.Current.LastSource = value;
_ = _settings.SaveAsync();
IsInFavoritesView = false;
SearchText = string.Empty; SearchText = string.Empty;
RefreshFavorites(); RefreshFavorites();
// Setting SearchText above only triggers a refresh if the value actually changed, so // Setting SearchText above only triggers a refresh if the value actually changed, so
@@ -88,6 +180,9 @@ public partial class PickerViewModel : ObservableObject
[RelayCommand] [RelayCommand]
private void SelectSource(MemeSource source) => ActiveSource = source; private void SelectSource(MemeSource source) => ActiveSource = source;
[RelayCommand]
private void ToggleFavoritesView() => IsInFavoritesView = !IsInFavoritesView;
[RelayCommand] [RelayCommand]
private async Task SelectItemAsync(MemeTileViewModel? tile) private async Task SelectItemAsync(MemeTileViewModel? tile)
{ {
@@ -155,13 +250,14 @@ public partial class PickerViewModel : ObservableObject
{ {
if (tile?.Record is null) return; if (tile?.Record is null) return;
var name = tile.DisplayName;
await _library.RemoveAsync(tile.Record); await _library.RemoveAsync(tile.Record);
// Drop the matching favourite too, otherwise it lingers pointing at a deleted file. // Drop the matching favourite too, otherwise it lingers pointing at a deleted file.
await _favorites.RemoveAsync(MemeSource.Local, tile.Record.Id); await _favorites.RemoveAsync(MemeSource.Local, tile.Record.Id);
RefreshLocalItems(); RefreshLocalItems();
RefreshFavorites(); RefreshFavorites();
StatusMessage = $"Removed {tile.DisplayName}"; StatusMessage = $"Removed {name}";
} }
[RelayCommand] [RelayCommand]
@@ -256,12 +352,53 @@ public partial class PickerViewModel : ObservableObject
public void OnShown() public void OnShown()
{ {
IsInFavoritesView = false;
SearchText = string.Empty; SearchText = string.Empty;
if (IsLocalSource)
{
RefreshLocalItems();
}
RefreshFavorites(); RefreshFavorites();
// Rebuild rather than just reset: picks up memes added since last time, and lets a
// changed "animate GIF previews" setting take effect.
_ = RefreshAsync();
}
/// <summary>
/// Appends the next page of remote results. Called as the grid is scrolled near the bottom,
/// so browsing feels continuous instead of stopping dead at the first batch.
/// </summary>
public async Task LoadMoreAsync()
{
if (IsLocalSource || _showingResolvedLink || !_remoteHasMore || _isLoadingMore) return;
var provider = ActiveProvider;
if (provider is null || !provider.IsConfigured) return;
_isLoadingMore = true;
var queryAtStart = SearchText;
try
{
var results = await provider.SearchAsync(queryAtStart, RemoteResultLimit, _remoteOffset, CancellationToken.None);
// The query may have changed while this page was in flight; if so the newer search
// owns the grid and these results are stale.
if (!string.Equals(queryAtStart, SearchText, StringComparison.Ordinal)) return;
foreach (var result in results)
{
AddRemoteTile(result);
}
_remoteOffset += results.Count;
_remoteHasMore = results.Count >= RemoteResultLimit;
StatusMessage = $"{Items.Count} result(s).";
}
catch (Exception ex)
{
StatusMessage = $"Couldn't load more: {ex.Message}";
_remoteHasMore = false;
}
finally
{
_isLoadingMore = false;
}
} }
private async Task RefreshAsync() private async Task RefreshAsync()
@@ -271,13 +408,17 @@ public partial class PickerViewModel : ObservableObject
_searchCts?.Dispose(); _searchCts?.Dispose();
_searchCts = null; _searchCts = null;
_remoteOffset = 0;
_remoteHasMore = false;
_showingResolvedLink = false;
if (IsLocalSource) if (IsLocalSource)
{ {
RefreshLocalItems(); RefreshLocalItems();
return; return;
} }
var provider = _providers.FirstOrDefault(p => p.Source == ActiveSource); var provider = ActiveProvider;
if (provider is null) return; if (provider is null) return;
if (!provider.IsConfigured) if (!provider.IsConfigured)
@@ -297,20 +438,25 @@ public partial class PickerViewModel : ObservableObject
await Task.Delay(SearchDebounce, cts.Token); await Task.Delay(SearchDebounce, cts.Token);
IsBusy = true; IsBusy = true;
StatusMessage = "Searching...";
var results = await provider.SearchAsync(SearchText, RemoteResultLimit, cts.Token); if (provider.CanResolveLink(SearchText))
{
await ResolveLinkAsync(provider, cts.Token);
return;
}
StatusMessage = "Searching...";
var results = await provider.SearchAsync(SearchText, RemoteResultLimit, 0, cts.Token);
cts.Token.ThrowIfCancellationRequested(); cts.Token.ThrowIfCancellationRequested();
Items.Clear(); Items.Clear();
foreach (var result in results) foreach (var result in results)
{ {
var tile = new MemeTileViewModel(result, _gifCache); AddRemoteTile(result, cts.Token);
Items.Add(tile);
_ = tile.LoadThumbnailAsync(cts.Token);
} }
SyncFavoriteFlags(); _remoteOffset = results.Count;
_remoteHasMore = results.Count >= RemoteResultLimit;
StatusMessage = results.Count == 0 ? "No results." : $"{results.Count} result(s)."; StatusMessage = results.Count == 0 ? "No results." : $"{results.Count} result(s).";
} }
catch (OperationCanceledException) catch (OperationCanceledException)
@@ -330,6 +476,37 @@ public partial class PickerViewModel : ObservableObject
} }
} }
private async Task ResolveLinkAsync(IGifProvider provider, CancellationToken ct)
{
StatusMessage = "Opening link...";
var result = await provider.ResolveLinkAsync(SearchText, ct);
ct.ThrowIfCancellationRequested();
Items.Clear();
if (result is null)
{
StatusMessage = "Couldn't find that GIF - check the link.";
return;
}
AddRemoteTile(result, ct);
// A single resolved item has nothing to page through.
_showingResolvedLink = true;
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 RefreshLocalItems() private void RefreshLocalItems()
{ {
var records = string.IsNullOrWhiteSpace(SearchText) var records = string.IsNullOrWhiteSpace(SearchText)
@@ -339,12 +516,14 @@ public partial class PickerViewModel : ObservableObject
Items.Clear(); Items.Clear();
foreach (var record in records) foreach (var record in records)
{ {
var tile = new MemeTileViewModel(record, _library.GetFullPath(record)); var tile = new MemeTileViewModel(record, _library.GetFullPath(record), AnimatePreviews)
{
IsFavorite = _favorites.IsFavorite(MemeSource.Local, record.Id),
};
Items.Add(tile); Items.Add(tile);
_ = tile.LoadThumbnailAsync(); _ = tile.LoadThumbnailAsync();
} }
SyncFavoriteFlags();
StatusMessage = string.Empty; StatusMessage = string.Empty;
} }
@@ -359,10 +538,10 @@ public partial class PickerViewModel : ObservableObject
if (fav.Source == MemeSource.Local) if (fav.Source == MemeSource.Local)
{ {
var record = _library.GetAll().FirstOrDefault(r => r.Id == fav.Key); var record = _library.GetAll().FirstOrDefault(r => r.Id == fav.Key);
// Favourite pointing at a meme that's since been deleted - skip it rather than // Favourite pointing at a meme that has since been deleted - skip it rather
// rendering a broken tile. // than rendering a broken tile.
if (record is null) continue; if (record is null) continue;
tile = new MemeTileViewModel(record, _library.GetFullPath(record)); tile = new MemeTileViewModel(record, _library.GetFullPath(record), AnimatePreviews);
} }
else else
{ {
@@ -376,7 +555,8 @@ public partial class PickerViewModel : ObservableObject
FullUrl = fav.FullUrl, FullUrl = fav.FullUrl,
Source = fav.Source, Source = fav.Source,
}, },
_gifCache); _gifCache,
AnimatePreviews);
} }
tile.IsFavorite = true; tile.IsFavorite = true;
@@ -385,6 +565,8 @@ public partial class PickerViewModel : ObservableObject
} }
HasFavorites = Favorites.Count > 0; HasFavorites = Favorites.Count > 0;
// Last favourite removed while inside the submenu: nothing left to show, so step out.
if (!HasFavorites) IsInFavoritesView = false;
} }
/// <summary>Keeps the star state on the main grid in step with the favourites store.</summary> /// <summary>Keeps the star state on the main grid in step with the favourites store.</summary>
@@ -396,3 +578,6 @@ public partial class PickerViewModel : ObservableObject
} }
} }
} }
/// <summary>A navigation tile occupying the first grid cell (opens or leaves the favourites submenu).</summary>
public sealed record FolderTileViewModel(string Glyph, string Label);
@@ -26,6 +26,9 @@ public partial class SettingsViewModel : ObservableObject
[ObservableProperty] [ObservableProperty]
private string _giphyApiKey = string.Empty; private string _giphyApiKey = string.Empty;
[ObservableProperty]
private bool _playGifPreviews;
public bool IsCopyOnly public bool IsCopyOnly
{ {
get => InsertMode == InsertMode.CopyOnly; get => InsertMode == InsertMode.CopyOnly;
@@ -52,6 +55,7 @@ public partial class SettingsViewModel : ObservableObject
_insertMode = _settings.Current.InsertMode; _insertMode = _settings.Current.InsertMode;
_giphyApiKey = _settings.Current.GiphyApiKey ?? string.Empty; _giphyApiKey = _settings.Current.GiphyApiKey ?? string.Empty;
_playGifPreviews = _settings.Current.PlayGifPreviews;
_hotkeyDisplay = HotkeyFormatter.Format(_settings.Current.HotkeyModifiers, _settings.Current.HotkeyKey); _hotkeyDisplay = HotkeyFormatter.Format(_settings.Current.HotkeyModifiers, _settings.Current.HotkeyKey);
// Read from the registry rather than a stored setting, so the checkbox reflects reality // Read from the registry rather than a stored setting, so the checkbox reflects reality
// even if the entry was removed outside the app. // 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 OnAutostartEnabledChanged(bool value) => _autostart.SetEnabled(value);
partial void OnPlayGifPreviewsChanged(bool value)
{
_settings.Current.PlayGifPreviews = value;
_ = _settings.SaveAsync();
}
partial void OnGiphyApiKeyChanged(string value) partial void OnGiphyApiKeyChanged(string value)
{ {
_settings.Current.GiphyApiKey = string.IsNullOrWhiteSpace(value) ? null : value.Trim(); _settings.Current.GiphyApiKey = string.IsNullOrWhiteSpace(value) ? null : value.Trim();
+55 -44
View File
@@ -4,6 +4,7 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:EbbesMemeClipboard.ViewModels" xmlns:vm="clr-namespace:EbbesMemeClipboard.ViewModels"
xmlns:models="clr-namespace:EbbesMemeClipboard.Models" xmlns:models="clr-namespace:EbbesMemeClipboard.Models"
xmlns:gif="clr-namespace:XamlAnimatedGif;assembly=XamlAnimatedGif"
Title="Meme Clipboard" Title="Meme Clipboard"
Width="420" Height="520" Width="420" Height="520"
WindowStyle="None" WindowStyle="None"
@@ -78,7 +79,7 @@
<!-- Shared by the favourites row and the main grid so both behave identically. <!-- Shared by the favourites row and the main grid so both behave identically.
No fixed Width: the tile stretches to fill its UniformGrid cell, so a row always No fixed Width: the tile stretches to fill its UniformGrid cell, so a row always
spans the full panel width instead of leaving a ragged gap on the right. --> spans the full panel width instead of leaving a ragged gap on the right. -->
<DataTemplate x:Key="MemeTileTemplate" DataType="{x:Type vm:MemeTileViewModel}"> <DataTemplate DataType="{x:Type vm:MemeTileViewModel}">
<Grid Height="118" Margin="4"> <Grid Height="118" Margin="4">
<Button Padding="0" <Button Padding="0"
Style="{StaticResource FlatButtonStyle}" Style="{StaticResource FlatButtonStyle}"
@@ -98,7 +99,13 @@
CommandParameter="{Binding PlacementTarget.DataContext, RelativeSource={RelativeSource AncestorType=ContextMenu}}"/> CommandParameter="{Binding PlacementTarget.DataContext, RelativeSource={RelativeSource AncestorType=ContextMenu}}"/>
</ContextMenu> </ContextMenu>
</Button.ContextMenu> </Button.ContextMenu>
<Image Source="{Binding Thumbnail}" Stretch="Uniform" Margin="5"/> <!-- Source is the static first frame; the gif attached properties take over only when
preview playback is enabled (both stay null otherwise). -->
<Image Stretch="Uniform" Margin="5"
Source="{Binding Thumbnail}"
gif:AnimationBehavior.SourceUri="{Binding AnimationUri}"
gif:AnimationBehavior.SourceStream="{Binding AnimationStream}"
gif:AnimationBehavior.RepeatBehavior="Forever"/>
</Button> </Button>
<!-- Star overlay: marks favourites at a glance without needing a second grid. --> <!-- Star overlay: marks favourites at a glance without needing a second grid. -->
@@ -108,6 +115,19 @@
Visibility="{Binding IsFavorite, Converter={StaticResource BoolToVisibility}}"/> Visibility="{Binding IsFavorite, Converter={StaticResource BoolToVisibility}}"/>
</Grid> </Grid>
</DataTemplate> </DataTemplate>
<!-- Navigation tile in the first grid cell: opens / leaves the favourites submenu. -->
<DataTemplate DataType="{x:Type vm:FolderTileViewModel}">
<Button Height="118" Margin="4"
Style="{StaticResource FlatButtonStyle}"
Command="{Binding DataContext.ToggleFavoritesViewCommand, ElementName=RootWindow}">
<StackPanel>
<TextBlock Text="{Binding Glyph}" FontSize="34" Foreground="#FFC72C"
HorizontalAlignment="Center"/>
<TextBlock Text="{Binding Label}" FontSize="12" Foreground="#C4C6CC"
FontWeight="SemiBold" HorizontalAlignment="Center" Margin="0,4,0,0"/>
</StackPanel>
</Button>
</DataTemplate>
</Window.Resources> </Window.Resources>
<Border Background="#F0202225" CornerRadius="10" BorderBrush="#3A3B3E" BorderThickness="1"> <Border Background="#F0202225" CornerRadius="10" BorderBrush="#3A3B3E" BorderThickness="1">
@@ -145,18 +165,38 @@
Command="{Binding SelectSourceCommand}" Command="{Binding SelectSourceCommand}"
CommandParameter="{x:Static models:MemeSource.Giphy}"/> CommandParameter="{x:Static models:MemeSource.Giphy}"/>
</StackPanel> </StackPanel>
<Grid Grid.Row="2" Margin="0,0,0,10"> <Grid Grid.Row="2" Margin="0,0,0,10">
<Grid.ColumnDefinitions> <Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/> <ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/> <ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions> </Grid.ColumnDefinitions>
<Border Grid.Column="0" Background="#2B2D31" CornerRadius="8"> <Border Grid.Column="0" Background="#2B2D31" CornerRadius="8">
<TextBox x:Name="SearchBox" <Grid>
Text="{Binding SearchText, UpdateSourceTrigger=PropertyChanged}" <TextBox x:Name="SearchBox"
Background="Transparent" Foreground="White" BorderThickness="0" Text="{Binding SearchText, UpdateSourceTrigger=PropertyChanged}"
Padding="10,7" FontSize="14" Background="Transparent" Foreground="White" BorderThickness="0"
CaretBrush="White"/> Padding="10,7" FontSize="14"
CaretBrush="White"/>
<!-- WPF has no native placeholder, so this sits behind the caret and
hides as soon as anything is typed. Not hit-testable, so clicking
it still focuses the box underneath. -->
<TextBlock Text="{Binding SearchPlaceholder}"
Foreground="#6E7078" FontSize="14"
Margin="11,0,10,0" VerticalAlignment="Center"
IsHitTestVisible="False"
TextTrimming="CharacterEllipsis">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Setter Property="Visibility" Value="Collapsed"/>
<Style.Triggers>
<DataTrigger Binding="{Binding SearchText}" Value="">
<Setter Property="Visibility" Value="Visible"/>
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
</Grid>
</Border> </Border>
<Button Grid.Column="1" Content="+" Width="36" Height="36" Margin="8,0,0,0" <Button Grid.Column="1" Content="+" Width="36" Height="36" Margin="8,0,0,0"
FontSize="18" Style="{StaticResource FlatButtonStyle}" FontSize="18" Style="{StaticResource FlatButtonStyle}"
@@ -165,48 +205,19 @@
ToolTip="Add memes"/> ToolTip="Add memes"/>
</Grid> </Grid>
<ScrollViewer Grid.Row="3" VerticalScrollBarVisibility="Auto" Padding="0,0,4,0"> <ScrollViewer x:Name="ResultsScrollViewer"
Grid.Row="3" VerticalScrollBarVisibility="Auto" Padding="0,0,4,0"
ScrollChanged="ResultsScrollViewer_OnScrollChanged">
<StackPanel> <StackPanel>
<!-- Favourites for the active source. Scrolls with the content rather than
being pinned, so it doesn't permanently eat height in a 520px window. -->
<StackPanel Visibility="{Binding HasFavorites, Converter={StaticResource BoolToVisibility}}">
<TextBlock Text="★ Favourites" Foreground="#9A9CA3" FontSize="11"
FontWeight="SemiBold" Margin="4,0,0,4"/>
<ItemsControl ItemsSource="{Binding Favorites}"
ItemTemplate="{StaticResource MemeTileTemplate}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<UniformGrid Columns="3"/>
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
</ItemsControl>
<Border Height="1" Background="#3A3B3E" Margin="4,10,4,10"/>
</StackPanel>
<Grid> <Grid>
<TextBlock Text="No memes yet. Click +, drag files in, or paste with Ctrl+V." <TextBlock Text="{Binding EmptyStateText}"
Foreground="#7B7D85" FontSize="13" Foreground="#7B7D85" FontSize="13"
HorizontalAlignment="Center" VerticalAlignment="Center" HorizontalAlignment="Center" VerticalAlignment="Center"
TextWrapping="Wrap" TextAlignment="Center" Width="260" TextWrapping="Wrap" TextAlignment="Center" Width="260"
Margin="0,40,0,0"> Margin="0,40,0,0"
<TextBlock.Style> Visibility="{Binding ShowEmptyState, Converter={StaticResource BoolToVisibility}}"/>
<Style TargetType="TextBlock">
<Setter Property="Visibility" Value="Collapsed"/>
<Style.Triggers>
<MultiDataTrigger>
<MultiDataTrigger.Conditions>
<Condition Binding="{Binding Items.Count}" Value="0"/>
<Condition Binding="{Binding IsLocalSource}" Value="True"/>
</MultiDataTrigger.Conditions>
<Setter Property="Visibility" Value="Visible"/>
</MultiDataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
<ItemsControl ItemsSource="{Binding Items}" <ItemsControl ItemsSource="{Binding GridSource}">
ItemTemplate="{StaticResource MemeTileTemplate}">
<ItemsControl.ItemsPanel> <ItemsControl.ItemsPanel>
<ItemsPanelTemplate> <ItemsPanelTemplate>
<UniformGrid Columns="3"/> <UniformGrid Columns="3"/>
@@ -29,6 +29,8 @@ public partial class PickerWindow : Window
_autoPaste.CaptureForegroundWindow(); _autoPaste.CaptureForegroundWindow();
PositionNearCursor(); PositionNearCursor();
_viewModel.OnShown(); _viewModel.OnShown();
// Always reopen at the top rather than wherever the last session was left scrolled to.
ResultsScrollViewer.ScrollToTop();
Show(); Show();
Activate(); Activate();
SearchBox.Focus(); SearchBox.Focus();
@@ -88,6 +90,15 @@ public partial class PickerWindow : Window
{ {
Activate(); Activate();
} }
// 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.SearchText)
or nameof(PickerViewModel.IsInFavoritesView))
{
ResultsScrollViewer.ScrollToTop();
}
} }
private void PickerWindow_OnDeactivated(object? sender, EventArgs e) private void PickerWindow_OnDeactivated(object? sender, EventArgs e)
@@ -99,6 +110,22 @@ public partial class PickerWindow : Window
Hide(); Hide();
} }
/// <summary>
/// Loads the next page of remote results once the user scrolls near the bottom, so Giphy
/// browsing continues instead of stopping at the first batch. The view model guards against
/// overlapping or unnecessary calls, so firing this on every scroll tick is safe.
/// </summary>
private void ResultsScrollViewer_OnScrollChanged(object sender, ScrollChangedEventArgs e)
{
if (sender is not ScrollViewer viewer || viewer.ScrollableHeight <= 0) return;
const double triggerDistanceFromBottom = 250;
if (viewer.VerticalOffset >= viewer.ScrollableHeight - triggerDistanceFromBottom)
{
_ = _viewModel.LoadMoreAsync();
}
}
private void PickerWindow_OnContextMenuOpening(object sender, ContextMenuEventArgs e) => _isContextMenuOpen = true; private void PickerWindow_OnContextMenuOpening(object sender, ContextMenuEventArgs e) => _isContextMenuOpen = true;
private void PickerWindow_OnContextMenuClosing(object sender, ContextMenuEventArgs e) => _isContextMenuOpen = false; private void PickerWindow_OnContextMenuClosing(object sender, ContextMenuEventArgs e) => _isContextMenuOpen = false;
@@ -37,6 +37,11 @@
<CheckBox Content="Start automatically when Windows starts" Foreground="White" <CheckBox Content="Start automatically when Windows starts" Foreground="White"
IsChecked="{Binding AutostartEnabled}"/> IsChecked="{Binding AutostartEnabled}"/>
<TextBlock Text="Previews" Foreground="White" FontSize="14" FontWeight="Bold" Margin="0,24,0,8"/>
<CheckBox Content="Play GIF previews in the picker" Foreground="White"
IsChecked="{Binding PlayGifPreviews}"/>
<TextBlock Text="Animates GIF thumbnails instead of showing a still frame. Uses more CPU and memory, especially with lots of Giphy results."
Foreground="#7B7D85" FontSize="10" TextWrapping="Wrap" Margin="20,4,0,0"/>
<TextBlock Text="Giphy" Foreground="White" FontSize="14" FontWeight="Bold" Margin="0,24,0,8"/> <TextBlock Text="Giphy" Foreground="White" FontSize="14" FontWeight="Bold" Margin="0,24,0,8"/>
<Border Background="#2B2D31" CornerRadius="6"> <Border Background="#2B2D31" CornerRadius="6">
<TextBox Text="{Binding GiphyApiKey, UpdateSourceTrigger=PropertyChanged}" <TextBox Text="{Binding GiphyApiKey, UpdateSourceTrigger=PropertyChanged}"