3 Commits
12 changed files with 481 additions and 148 deletions
+5 -1
View File
@@ -12,7 +12,7 @@ stays out of the way until you need it.
- **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>). - **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. - **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). - **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. - **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. - **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. - **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.
@@ -65,6 +65,10 @@ The Giphy tab needs your own free API key:
The free tier allows 100 requests per hour, which is plenty for personal use. Giphy's terms 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. 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 ## Where your data lives
``` ```
+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,19 @@ 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 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.
/// </summary>
public MemeSource LastSource { get; set; } = MemeSource.Local;
public bool LastShowingFavorites { get; set; } = true;
} }
@@ -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,4 +1,5 @@
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using System.Collections.Specialized;
using System.IO; using System.IO;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
@@ -9,7 +10,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,10 +26,14 @@ 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;
public ObservableCollection<MemeTileViewModel> Items { get; } = new(); private int _remoteOffset;
private bool _remoteHasMore;
private bool _isLoadingMore;
/// <summary>Favourites for the currently active source only.</summary> /// <summary>True when the grid is showing a single item resolved from a pasted link.</summary>
public ObservableCollection<MemeTileViewModel> Favorites { get; } = new(); private bool _showingResolvedLink;
public ObservableCollection<MemeTileViewModel> Items { get; } = new();
[ObservableProperty] [ObservableProperty]
private string _searchText = string.Empty; private string _searchText = string.Empty;
@@ -40,8 +47,12 @@ public partial class PickerViewModel : ObservableObject
[ObservableProperty] [ObservableProperty]
private bool _isDialogOpen; private bool _isDialogOpen;
/// <summary>
/// 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.
/// </summary>
[ObservableProperty] [ObservableProperty]
private bool _hasFavorites; private bool _showingFavorites;
[ObservableProperty] [ObservableProperty]
private MemeSource _activeSource = MemeSource.Local; private MemeSource _activeSource = MemeSource.Local;
@@ -49,6 +60,25 @@ public partial class PickerViewModel : ObservableObject
public bool IsLocalSource => ActiveSource == MemeSource.Local; public bool IsLocalSource => ActiveSource == MemeSource.Local;
public bool IsGiphySource => ActiveSource == MemeSource.Giphy; public bool IsGiphySource => ActiveSource == MemeSource.Giphy;
/// <summary>Importing only applies to the local library, and not while browsing favourites.</summary>
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 event EventHandler? RequestClose;
public PickerViewModel( public PickerViewModel(
@@ -68,26 +98,64 @@ public partial class PickerViewModel : ObservableObject
_favorites = favorites; _favorites = favorites;
_providers = providers.ToList(); _providers = providers.ToList();
RefreshLocalItems(); Items.CollectionChanged += OnItemsChanged;
RefreshFavorites();
// 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.
_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();
} }
private void OnItemsChanged(object? sender, NotifyCollectionChangedEventArgs e) =>
OnPropertyChanged(nameof(ShowEmptyState));
partial void OnSearchTextChanged(string value) => _ = RefreshAsync(); 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;
PersistViewState();
_ = RefreshAsync();
}
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(CanImport));
OnPropertyChanged(nameof(ShowEmptyState));
SearchText = string.Empty; SearchText = string.Empty;
RefreshFavorites(); PersistViewState();
// 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
// refresh explicitly here to cover switching tabs with an already-empty box. // refresh explicitly here to cover switching tabs with an already-empty box.
_ = RefreshAsync(); _ = RefreshAsync();
} }
private void PersistViewState()
{
_settings.Current.LastSource = ActiveSource;
_settings.Current.LastShowingFavorites = ShowingFavorites;
_ = _settings.SaveAsync();
}
[RelayCommand] [RelayCommand]
private void SelectSource(MemeSource source) => ActiveSource = source; private void SelectSource(MemeSource source) => ActiveSource = source;
[RelayCommand]
private void ToggleFavoritesView() => ShowingFavorites = !ShowingFavorites;
[RelayCommand] [RelayCommand]
private async Task SelectItemAsync(MemeTileViewModel? tile) private async Task SelectItemAsync(MemeTileViewModel? tile)
{ {
@@ -130,7 +198,11 @@ public partial class PickerViewModel : ObservableObject
if (_favorites.IsFavorite(tile.Source, tile.FavoriteKey)) if (_favorites.IsFavorite(tile.Source, tile.FavoriteKey))
{ {
await _favorites.RemoveAsync(tile.Source, tile.FavoriteKey); await _favorites.RemoveAsync(tile.Source, tile.FavoriteKey);
tile.IsFavorite = false;
StatusMessage = $"Removed {tile.DisplayName} from favourites"; StatusMessage = $"Removed {tile.DisplayName} from favourites";
// In the favourites view an unfavourited tile no longer belongs on screen.
if (ShowingFavorites) Items.Remove(tile);
} }
else else
{ {
@@ -143,11 +215,9 @@ public partial class PickerViewModel : ObservableObject
FullUrl = tile.RemoteResult?.FullUrl, FullUrl = tile.RemoteResult?.FullUrl,
DateAdded = DateTimeOffset.Now, DateAdded = DateTimeOffset.Now,
}); });
tile.IsFavorite = true;
StatusMessage = $"Added {tile.DisplayName} to favourites"; StatusMessage = $"Added {tile.DisplayName} to favourites";
} }
RefreshFavorites();
SyncFavoriteFlags();
} }
[RelayCommand] [RelayCommand]
@@ -155,13 +225,13 @@ 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(); await RefreshAsync();
RefreshFavorites(); StatusMessage = $"Removed {name}";
StatusMessage = $"Removed {tile.DisplayName}";
} }
[RelayCommand] [RelayCommand]
@@ -226,6 +296,7 @@ public partial class PickerViewModel : ObservableObject
return; return;
} }
ShowingFavorites = false;
ActiveSource = MemeSource.Local; ActiveSource = MemeSource.Local;
RefreshLocalItems(); RefreshLocalItems();
StatusMessage = $"Pasted {name}"; StatusMessage = $"Pasted {name}";
@@ -249,6 +320,7 @@ public partial class PickerViewModel : ObservableObject
} }
var imported = await _library.ImportAsync(candidates); var imported = await _library.ImportAsync(candidates);
ShowingFavorites = false;
ActiveSource = MemeSource.Local; ActiveSource = MemeSource.Local;
RefreshLocalItems(); RefreshLocalItems();
StatusMessage = $"Added {imported.Count} meme(s)."; StatusMessage = $"Added {imported.Count} meme(s).";
@@ -257,27 +329,76 @@ public partial class PickerViewModel : ObservableObject
public void OnShown() public void OnShown()
{ {
SearchText = string.Empty; SearchText = string.Empty;
if (IsLocalSource) // 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 || ShowingFavorites || _showingResolvedLink || !_remoteHasMore || _isLoadingMore) return;
var provider = ActiveProvider;
if (provider is null || !provider.IsConfigured) return;
_isLoadingMore = true;
var queryAtStart = SearchText;
try
{ {
RefreshLocalItems(); 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;
} }
RefreshFavorites();
} }
private async Task RefreshAsync() 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?.Cancel();
_searchCts?.Dispose(); _searchCts?.Dispose();
_searchCts = null; _searchCts = null;
_remoteOffset = 0;
_remoteHasMore = false;
_showingResolvedLink = false;
if (ShowingFavorites)
{
RefreshFavoriteItems();
return;
}
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 +418,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 +456,47 @@ 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 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() private void RefreshLocalItems()
{ {
var records = string.IsNullOrWhiteSpace(SearchText) var records = string.IsNullOrWhiteSpace(SearchText)
@@ -339,60 +506,48 @@ 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)); AddLocalTile(record);
Items.Add(tile);
_ = tile.LoadThumbnailAsync();
} }
SyncFavoriteFlags();
StatusMessage = string.Empty; StatusMessage = string.Empty;
} }
private void RefreshFavorites() /// <summary>Favourites for the active source, narrowed by the search box like any other view.</summary>
private void RefreshFavoriteItems()
{ {
Favorites.Clear(); Items.Clear();
var query = SearchText;
foreach (var fav in _favorites.GetForSource(ActiveSource)) foreach (var fav in _favorites.GetForSource(ActiveSource))
{ {
MemeTileViewModel tile;
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)); if (!Matches(record.OriginalFileName, query)) continue;
AddLocalTile(record);
} }
else else
{ {
if (fav.PreviewUrl is null || fav.FullUrl is null) continue; if (fav.PreviewUrl is null || fav.FullUrl is null) continue;
tile = new MemeTileViewModel( if (!Matches(fav.Title ?? string.Empty, query)) continue;
new GifSearchResult
{ AddRemoteTile(new GifSearchResult
Id = fav.Key, {
Title = fav.Title ?? "Favourite", Id = fav.Key,
PreviewUrl = fav.PreviewUrl, Title = fav.Title ?? "Favourite",
FullUrl = fav.FullUrl, PreviewUrl = fav.PreviewUrl,
Source = fav.Source, FullUrl = fav.FullUrl,
}, Source = fav.Source,
_gifCache); });
} }
tile.IsFavorite = true;
Favorites.Add(tile);
_ = tile.LoadThumbnailAsync();
} }
HasFavorites = Favorites.Count > 0; StatusMessage = Items.Count == 0 ? string.Empty : $"{Items.Count} favourite(s).";
} }
/// <summary>Keeps the star state on the main grid in step with the favourites store.</summary> private static bool Matches(string text, string query) =>
private void SyncFavoriteFlags() string.IsNullOrWhiteSpace(query) || text.Contains(query, StringComparison.OrdinalIgnoreCase);
{
foreach (var tile in Items)
{
tile.IsFavorite = _favorites.IsFavorite(tile.Source, tile.FavoriteKey);
}
}
} }
@@ -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();
+71 -64
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"
@@ -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. -->
@@ -133,18 +140,27 @@
</Grid> </Grid>
</Border> </Border>
<StackPanel Grid.Row="1" Orientation="Horizontal" Margin="0,4,0,8"> <Grid Grid.Row="1" Margin="0,4,0,8">
<Button Content="Local" <StackPanel Orientation="Horizontal" HorizontalAlignment="Left">
<Button Content="Local"
Style="{StaticResource TabButtonStyle}"
Tag="{Binding IsLocalSource}"
Command="{Binding SelectSourceCommand}"
CommandParameter="{x:Static models:MemeSource.Local}"/>
<Button Content="Giphy" Margin="6,0,0,0"
Style="{StaticResource TabButtonStyle}"
Tag="{Binding IsGiphySource}"
Command="{Binding SelectSourceCommand}"
CommandParameter="{x:Static models:MemeSource.Giphy}"/>
</StackPanel>
<!-- Right-aligned and separated from the source tabs on purpose: it filters
the active source rather than being a source of its own. -->
<Button Content="&#9733; Favourites" HorizontalAlignment="Right"
Style="{StaticResource TabButtonStyle}" Style="{StaticResource TabButtonStyle}"
Tag="{Binding IsLocalSource}" Tag="{Binding ShowingFavorites}"
Command="{Binding SelectSourceCommand}" Command="{Binding ToggleFavoritesViewCommand}"
CommandParameter="{x:Static models:MemeSource.Local}"/> ToolTip="Show only your favourites from this source"/>
<Button Content="Giphy" Margin="6,0,0,0" </Grid>
Style="{StaticResource TabButtonStyle}"
Tag="{Binding IsGiphySource}"
Command="{Binding SelectSourceCommand}"
CommandParameter="{x:Static models:MemeSource.Giphy}"/>
</StackPanel>
<Grid Grid.Row="2" Margin="0,0,0,10"> <Grid Grid.Row="2" Margin="0,0,0,10">
<Grid.ColumnDefinitions> <Grid.ColumnDefinitions>
@@ -152,69 +168,60 @@
<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"
Text="{Binding SearchText, UpdateSourceTrigger=PropertyChanged}"
Background="Transparent" Foreground="White" BorderThickness="0"
Padding="10,7" FontSize="14"
CaretBrush="White"/>
</Border>
<Button Grid.Column="1" Content="+" Width="36" Height="36" Margin="8,0,0,0"
FontSize="18" Style="{StaticResource FlatButtonStyle}"
Command="{Binding ImportFilesCommand}"
Visibility="{Binding IsLocalSource, Converter={StaticResource BoolToVisibility}}"
ToolTip="Add memes"/>
</Grid>
<ScrollViewer Grid.Row="3" VerticalScrollBarVisibility="Auto" Padding="0,0,4,0">
<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." <TextBox x:Name="SearchBox"
Foreground="#7B7D85" FontSize="13" Text="{Binding SearchText, UpdateSourceTrigger=PropertyChanged}"
HorizontalAlignment="Center" VerticalAlignment="Center" Background="Transparent" Foreground="White" BorderThickness="0"
TextWrapping="Wrap" TextAlignment="Center" Width="260" Padding="10,7" FontSize="14"
Margin="0,40,0,0"> 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> <TextBlock.Style>
<Style TargetType="TextBlock"> <Style TargetType="TextBlock">
<Setter Property="Visibility" Value="Collapsed"/> <Setter Property="Visibility" Value="Collapsed"/>
<Style.Triggers> <Style.Triggers>
<MultiDataTrigger> <DataTrigger Binding="{Binding SearchText}" Value="">
<MultiDataTrigger.Conditions>
<Condition Binding="{Binding Items.Count}" Value="0"/>
<Condition Binding="{Binding IsLocalSource}" Value="True"/>
</MultiDataTrigger.Conditions>
<Setter Property="Visibility" Value="Visible"/> <Setter Property="Visibility" Value="Visible"/>
</MultiDataTrigger> </DataTrigger>
</Style.Triggers> </Style.Triggers>
</Style> </Style>
</TextBlock.Style> </TextBlock.Style>
</TextBlock> </TextBlock>
<ItemsControl ItemsSource="{Binding Items}"
ItemTemplate="{StaticResource MemeTileTemplate}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<UniformGrid Columns="3"/>
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
</ItemsControl>
</Grid> </Grid>
</StackPanel> </Border>
<Button Grid.Column="1" Content="+" Width="36" Height="36" Margin="8,0,0,0"
FontSize="18" Style="{StaticResource FlatButtonStyle}"
Command="{Binding ImportFilesCommand}"
Visibility="{Binding CanImport, Converter={StaticResource BoolToVisibility}}"
ToolTip="Add memes"/>
</Grid>
<ScrollViewer x:Name="ResultsScrollViewer"
Grid.Row="3" VerticalScrollBarVisibility="Auto" Padding="0,0,4,0"
ScrollChanged="ResultsScrollViewer_OnScrollChanged">
<Grid>
<TextBlock Text="{Binding EmptyStateText}"
Foreground="#7B7D85" FontSize="13"
HorizontalAlignment="Center" VerticalAlignment="Center"
TextWrapping="Wrap" TextAlignment="Center" Width="260"
Margin="0,40,0,0"
Visibility="{Binding ShowEmptyState, Converter={StaticResource BoolToVisibility}}"/>
<ItemsControl ItemsSource="{Binding Items}"
ItemTemplate="{StaticResource MemeTileTemplate}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<UniformGrid Columns="3"/>
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
</ItemsControl>
</Grid>
</ScrollViewer> </ScrollViewer>
<Grid Grid.Row="4" Margin="2,8,0,0"> <Grid Grid.Row="4" Margin="2,8,0,0">
@@ -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.ShowingFavorites)
or nameof(PickerViewModel.SearchText))
{
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}"