Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
52ca49ac85 | ||
|
|
62a257b538 | ||
|
|
b1ffb3924a | ||
|
|
9b0ffcccdf |
@@ -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)
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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="★ 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}"
|
||||||
|
|||||||
Reference in New Issue
Block a user