7 Commits
20 changed files with 982 additions and 151 deletions
+42 -2
View File
@@ -26,7 +26,7 @@ Everything below is **implemented and verified working by actually running the a
- Tray icon with menu: **Open / Settings / Exit**
- Popup picker: search box, thumbnail grid, drag-to-move via top handle strip,
Esc / click-away to dismiss, positions near cursor clamped to the current monitor
- Add memes: `+` button (file dialog), drag-and-drop onto picker, jpg/png/gif
- Add memes: `+` button (file dialog), drag-and-drop onto picker, **Ctrl+V paste**, jpg/png/gif
- Search filters by filename (case-insensitive substring)
- Right-click a tile → **Remove** (deletes to Recycle Bin, recoverable — verified)
- Multi-format clipboard write (see "Clipboard formats" below)
@@ -77,13 +77,44 @@ dotnet run --project src/EbbesMemeClipboard
# build
dotnet build
# publish self-contained single-file binary -> publish/EbbesMemeClipboard.exe (~78 MB)
# portable single exe -> publish/EbbesMemeClipboard.exe (~75 MB, compressed)
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
# installer build: NOTE compression deliberately OFF (see below) -> publish-installer/ (~172 MB)
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
# then compile the wizard -> installer/output/EbbesMemeClipboard-Setup-<version>.exe (~51 MB)
"$LOCALAPPDATA/Programs/Inno Setup 6/ISCC.exe" installer/EbbesMemeClipboard.iss
```
### Installer (Inno Setup)
Script: `installer/EbbesMemeClipboard.iss` (tracked; `installer/output/` is gitignored).
Inno Setup 6 installs **per-user** to `%LocalAppData%\Programs\Inno Setup 6\`, not Program
Files — that tripped up the first attempt to locate `ISCC.exe`.
- Per-user install by default (no UAC), with a wizard page letting the user pick
"just me" vs "all users" (`PrivilegesRequired=lowest` +
`PrivilegesRequiredOverridesAllowed=dialog`).
- Optional unchecked tasks: desktop icon, start-with-Windows.
- **Don't double-compress.** Publishing with `EnableCompressionInSingleFile=false` and
letting Inno's LZMA2 do the work gives a ~51 MB setup vs ~70 MB, and the installed app
starts faster because there's no decompression at launch.
- **Uninstall only removes the autostart entry if it points at the installed copy.** The
app's own Settings toggle writes the same `Run` value name, so an unconditional delete
would wipe out a portable build's autostart. This was a real bug caught in testing.
- User data under `%AppData%\EbbesMemeClipboard` is deliberately preserved on uninstall;
only the re-downloadable `GifCache` is removed.
- Verified end-to-end: silent install → files/shortcut/uninstall-entry present → app runs →
silent uninstall → everything removed, user data and unrelated autostart intact.
- An installer does **not** fix SmartScreen warnings; only code signing does.
Note: the published single-file exe takes **~10 seconds on first launch** before the tray
icon appears (native library self-extraction to TEMP). Not a bug. Subsequent launches are
faster.
@@ -160,6 +191,15 @@ code-behind fields. Look elements up by `Tag`/traversal instead.
| `"PNG"` (registered format) | **Chromium-based apps (Teams, Slack, Discord, browsers) prefer this over CF_DIB** and may paste nothing at all without it. **Deliberately omitted for GIFs** — see below |
| `CF_HTML` | The other route web-based compose boxes check; carries a base64 `data:` URI |
**Reading the clipboard (Ctrl+V to import) uses the same knowledge in reverse.**
`ClipboardService.TryReadImage()` checks formats in descending fidelity order: FileDrop →
CF_HTML `data:` URI → `"PNG"` → CF_DIB bitmap. The order is not cosmetic: a GIF is only
still animated in the first two, so a naive implementation that reached for the bitmap
first would silently flatten every pasted GIF. Verified byte-identical (SHA-256) import for
a pasted GIF file. Ctrl+V is handled in `PickerWindow.OnPreviewKeyDown` and only swallows
the keystroke when an image is actually present — plain text still pastes into the search
box as normal.
**The `"PNG"` format is skipped for GIFs on purpose.** A PNG can only hold one static frame,
and since Chromium *prefers* that format over all others, offering it for a GIF is exactly
what made animated GIFs paste as a still first frame. Omitting it lets those targets fall
+5
View File
@@ -17,7 +17,12 @@ bin/
obj/
*.user
publish/
publish-installer/
# Compiled installer output (the .iss script itself IS tracked)
installer/output/
# Local app data seeded during manual testing
diag.log
/installer/output/
+142 -1
View File
@@ -1,2 +1,143 @@
# ebbes-meme-clipboard
# Ebbe's Meme Clipboard
A meme and GIF picker for Windows, inspired by the built-in Windows Emoji Picker (<kbd>Win</kbd>+<kbd>.</kbd>).
Press a global hotkey anywhere, a small popup appears, search your memes or Giphy, click one —
and it lands straight in whatever app you were just typing in. It lives in the system tray and
stays out of the way until you need it.
---
## Features
- **Global hotkey** — opens the picker over any app. Defaults to <kbd>Ctrl</kbd>+<kbd>Alt</kbd>+<kbd>M</kbd>, fully remappable (the <kbd>Win</kbd> key works too, e.g. <kbd>Win</kbd>+<kbd>Y</kbd>).
- **Your own meme library** — add images by clicking **+**, dragging files onto the window, or simply pasting with <kbd>Ctrl</kbd>+<kbd>V</kbd>. Supports JPG, PNG and GIF.
- **Giphy search** — a second tab searches Giphy directly (needs a free API key, see below). Scroll and it keeps loading more results. You can also paste a Giphy link straight into the search box to jump to that one GIF.
- **Favourites** — right-click any meme to pin it to a ★ Favourites row at the top of that tab. Kept separately per source.
- **Three insert modes** — copy to clipboard, paste into the active window, or paste *and* send instantly.
- **Animated GIFs stay animated** — the clipboard is written in several formats at once so GIFs paste as real animations in Discord, Slack and Teams, rather than as a flattened still frame.
- **Search as you type** — filters your library by filename; Giphy results are debounced so typing doesn't burn through the API rate limit.
- **Runs from the tray** — optional start-with-Windows, and a movable, dismiss-on-click-away popup.
## Installation
**Installer (recommended)** — run `EbbesMemeClipboard-Setup-<version>.exe`. It installs per-user by
default (no admin prompt) but lets you choose "just me" or "all users", and offers optional desktop
and start-with-Windows shortcuts.
**Portable** — grab `EbbesMemeClipboard.exe` and run it. No installation, no .NET runtime needed;
everything is bundled. It'll write its library and settings to `%AppData%` as usual.
> Windows may show a SmartScreen warning on first run, because the executable isn't code-signed.
> Choose *More info → Run anyway*. Signing requires a paid certificate from a certificate authority.
## Usage
| Action | How |
|---|---|
| Open / close the picker | <kbd>Ctrl</kbd>+<kbd>Alt</kbd>+<kbd>M</kbd>, or left-click the tray icon |
| Insert a meme | Click it |
| Add memes | **+** button, drag files in, or <kbd>Ctrl</kbd>+<kbd>V</kbd> |
| Favourite / unfavourite | Right-click a meme |
| Delete a meme | Right-click → **Remove** (goes to the Recycle Bin) |
| Search | Just start typing |
| Move the window | Drag the title bar |
| Close the picker | <kbd>Esc</kbd>, or click elsewhere |
| Settings / quit | Right-click the tray icon |
Pasting plain text into the window still goes to the search box — only images get imported.
### Insert modes
Set these under **Settings → Insert Mode**:
- **Copy to clipboard** — just copies; you paste it yourself.
- **Paste into active window** *(default)* — restores focus to the app you came from and pastes for you.
- **Paste and send instantly** — the above, plus <kbd>Enter</kbd>. Handy in chat apps, but it *will* send the message immediately, so it's off by default.
### Giphy setup
The Giphy tab needs your own free API key:
1. Go to [developers.giphy.com](https://developers.giphy.com), create an account and an app, and choose **API Key**.
2. Paste the key into **Settings → Giphy**.
The free tier allows 100 requests per hour, which is plenty for personal use. Giphy's terms
require the "Powered by GIPHY" attribution shown in the app whenever their results are displayed.
You can also paste a Giphy link into the search box — share links
(`giphy.com/gifs/funny-cat-<id>`), direct media links (`media.giphy.com/media/<id>/giphy.gif`)
and `i.giphy.com` image links all work, and resolve to that single GIF.
## Where your data lives
```
%AppData%\EbbesMemeClipboard\
settings.json hotkey, insert mode, Giphy API key
favorites.json favourites, per source
Library\ your imported memes + an index
%LocalAppData%\EbbesMemeClipboard\
GifCache\ downloaded Giphy GIFs (re-downloadable; safe to delete)
```
Uninstalling deliberately leaves your memes, favourites and settings in place — only the
re-downloadable cache is cleared.
## Known limitations
- **The popup appears at the mouse cursor, not the text caret.** The real Emoji Panel can follow
the caret because it's a privileged part of the Windows shell; third-party apps have no
equivalent access.
- **Auto-paste doesn't work into apps running as administrator.** Windows blocks a normal program
from sending input to an elevated window (UIPI). The meme is still copied — just press
<kbd>Ctrl</kbd>+<kbd>V</kbd> yourself.
- **No single-instance guard yet.** If two copies run at once they'll compete for the global
hotkey. Worth checking you don't have both a Startup shortcut *and* the autostart setting enabled.
- **Windows only.** See below.
## Building from source
Requires the [.NET 10 SDK](https://dotnet.microsoft.com/download).
```bash
# run it
dotnet run --project src/EbbesMemeClipboard
# portable single exe -> publish/
dotnet publish src/EbbesMemeClipboard/EbbesMemeClipboard.csproj -c Release -r win-x64 \
--self-contained true -p:PublishSingleFile=true \
-p:IncludeNativeLibrariesForSelfExtract=true -p:EnableCompressionInSingleFile=true \
-o publish
```
To build the installer you'll also need [Inno Setup 6](https://jrsoftware.org/isinfo.php). Note the
publish step deliberately disables .NET's own compression, so Inno's LZMA2 can compress the raw
bytes instead — that yields a noticeably smaller setup and a faster-starting app:
```bash
dotnet publish src/EbbesMemeClipboard/EbbesMemeClipboard.csproj -c Release -r win-x64 \
--self-contained true -p:PublishSingleFile=true \
-p:IncludeNativeLibrariesForSelfExtract=true -p:EnableCompressionInSingleFile=false \
-o publish-installer
"%LocalAppData%\Programs\Inno Setup 6\ISCC.exe" installer\EbbesMemeClipboard.iss
```
### Tech stack
.NET 10 · WPF · MVVM ([CommunityToolkit.Mvvm](https://github.com/CommunityToolkit/dotnet)) ·
[H.NotifyIcon](https://github.com/HavenDV/H.NotifyIcon) for the tray icon ·
`Microsoft.Extensions.DependencyInjection`
### Platform support
Windows only, and not portable without a rewrite. WPF doesn't exist on macOS or Linux, and the
features that make the app work — global hotkeys, synthetic paste, tray icon, clipboard formats,
autostart — are all built directly on Win32. The data and business-logic layer would carry over
to a cross-platform UI framework such as [Avalonia](https://avaloniaui.net), but every
platform-integration service and the entire UI would need reimplementing.
## License
[MIT](LICENSE)
+108
View File
@@ -0,0 +1,108 @@
; Inno Setup script for Ebbe's Meme Clipboard.
;
; Build with:
; "%LocalAppData%\Programs\Inno Setup 6\ISCC.exe" installer\EbbesMemeClipboard.iss
;
; Expects an installer-flavoured publish first (note: compression DISABLED on purpose):
; 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
;
; Leaving .NET's own single-file compression off lets Inno's LZMA2 compress the raw bytes
; instead, which it does far better: ~51 MB setup vs ~70 MB when double-compressing. The
; installed exe is larger on disk (172 MB vs 75 MB) but starts faster, since there's no
; decompression on launch. The separate publish\ folder keeps the compressed build for
; anyone who just wants the portable single exe.
#define AppName "Ebbe's Meme Clipboard"
#define AppVersion "1.1.5"
#define AppPublisher "Ebbe Baß"
#define AppExeName "EbbesMemeClipboard.exe"
#define SourceExe "..\publish-installer\EbbesMemeClipboard.exe"
[Setup]
AppId={{8E4C1F02-6B3A-4D77-9C21-5A0E7F3B9D64}
AppName={#AppName}
AppVersion={#AppVersion}
AppVerName={#AppName} {#AppVersion}
AppPublisher={#AppPublisher}
DefaultDirName={autopf}\Ebbes Meme Clipboard
DefaultGroupName={#AppName}
UninstallDisplayName={#AppName}
UninstallDisplayIcon={app}\{#AppExeName}
OutputDir=.\output
OutputBaseFilename=EbbesMemeClipboard-Setup-{#AppVersion}
SetupIconFile=..\src\EbbesMemeClipboard\Assets\tray-icon.ico
Compression=lzma2/max
SolidCompression=yes
WizardStyle=modern
DisableProgramGroupPage=yes
ArchitecturesAllowed=x64compatible
ArchitecturesInstallIn64BitMode=x64compatible
; Default to a per-user install so no UAC prompt is needed, but show a dialog letting the
; user pick "just me" or "all users". {autopf} then resolves to LocalAppData\Programs or
; Program Files to match whichever they chose.
PrivilegesRequired=lowest
PrivilegesRequiredOverridesAllowed=dialog
; The app has no single-instance mutex, so a running copy would hold a lock on the exe and
; break an upgrade. Restart Manager closes it first and reopens it afterwards.
CloseApplications=yes
RestartApplications=yes
[Languages]
Name: "english"; MessagesFile: "compiler:Default.isl"
Name: "german"; MessagesFile: "compiler:Languages\German.isl"
[Tasks]
Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked
Name: "autostart"; Description: "Start {#AppName} when Windows starts"; GroupDescription: "Startup:"; Flags: unchecked
[Files]
Source: "{#SourceExe}"; DestDir: "{app}"; Flags: ignoreversion
[Icons]
Name: "{group}\{#AppName}"; Filename: "{app}\{#AppExeName}"
Name: "{group}\{cm:UninstallProgram,{#AppName}}"; Filename: "{uninstallexe}"
Name: "{autodesktop}\{#AppName}"; Filename: "{app}\{#AppExeName}"; Tasks: desktopicon
[Registry]
; Same value name the app's own Settings toggle manages, so the installer checkbox and the
; in-app checkbox stay in agreement instead of fighting over two separate entries.
Root: HKCU; Subkey: "Software\Microsoft\Windows\CurrentVersion\Run"; ValueType: string; \
ValueName: "EbbesMemeClipboard"; ValueData: """{app}\{#AppExeName}"""; \
Flags: uninsdeletevalue; Tasks: autostart
[Run]
Filename: "{app}\{#AppExeName}"; Description: "{cm:LaunchProgram,{#AppName}}"; \
Flags: nowait postinstall skipifsilent
[UninstallDelete]
; The app writes its library/settings under %AppData% and a re-downloadable cache under
; %LocalAppData%. Only the cache is removed automatically - the user's own memes, favourites
; and settings are deliberately left behind so an uninstall/reinstall doesn't destroy them.
Type: filesandordirs; Name: "{localappdata}\EbbesMemeClipboard\GifCache"
[Code]
// Clean up the autostart entry on uninstall, but ONLY when it actually points at the copy
// being removed. The app's own Settings toggle writes the same value name, so a user running
// a portable build alongside this one would otherwise have their autostart silently deleted
// by an uninstall that had nothing to do with it.
procedure CurUninstallStepChanged(CurUninstallStep: TUninstallStep);
var
Existing: String;
AppPath: String;
begin
if CurUninstallStep <> usPostUninstall then
Exit;
if not RegQueryStringValue(HKCU, 'Software\Microsoft\Windows\CurrentVersion\Run',
'EbbesMemeClipboard', Existing) then
Exit;
AppPath := LowerCase(ExpandConstant('{app}'));
if Pos(AppPath, LowerCase(Existing)) > 0 then
RegDeleteValue(HKCU, 'Software\Microsoft\Windows\CurrentVersion\Run', 'EbbesMemeClipboard');
end;
Binary file not shown.

Before

Width:  |  Height:  |  Size: 766 B

After

Width:  |  Height:  |  Size: 18 KiB

@@ -22,6 +22,7 @@
<PackageReference Include="WPF-UI" Version="4.3.0" />
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.2" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.0" />
<PackageReference Include="XamlAnimatedGif" Version="2.3.2" />
</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.
/// </summary>
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;
}
@@ -0,0 +1,20 @@
namespace EbbesMemeClipboard.Models;
/// <summary>
/// An importable image found on the clipboard. Either real files (copied in Explorer) or raw
/// bytes (a screenshot, or an image copied out of a browser) - never both.
/// </summary>
public sealed class ClipboardImportPayload
{
/// <summary>Set when the clipboard held actual files; import these directly.</summary>
public IReadOnlyList<string>? FilePaths { get; init; }
/// <summary>Set when the clipboard held image data rather than files.</summary>
public byte[]? Data { get; init; }
/// <summary>Extension matching <see cref="Data"/>, including the dot (e.g. ".png").</summary>
public string? Extension { get; init; }
public bool HasFiles => FilePaths is { Count: > 0 };
public bool HasBytes => Data is { Length: > 0 } && Extension is not null;
}
@@ -2,12 +2,14 @@ using System.Collections.Specialized;
using System.IO;
using System.Runtime.InteropServices;
using System.Text;
using System.Text.RegularExpressions;
using System.Windows;
using System.Windows.Media.Imaging;
using EbbesMemeClipboard.Models;
namespace EbbesMemeClipboard.Services;
public sealed class ClipboardService : IClipboardService
public sealed partial class ClipboardService : IClipboardService
{
public async Task CopyLocalFileAsync(string filePath)
{
@@ -60,6 +62,86 @@ public sealed class ClipboardService : IClipboardService
_ => "application/octet-stream",
};
private static readonly string[] ImportableExtensions = { ".jpg", ".jpeg", ".png", ".gif" };
/// <summary>
/// Formats are checked in descending order of fidelity, which matters a lot here: a GIF is
/// only still animated in the first two. Falling straight to the bitmap (as a naive
/// implementation would) silently flattens every pasted GIF to one frame.
/// </summary>
public ClipboardImportPayload? TryReadImage()
{
// 1. Real files (copied in Explorer) - byte-for-byte exact, animation intact.
if (Clipboard.ContainsFileDropList())
{
var paths = Clipboard.GetFileDropList()
.Cast<string>()
.Where(p => !string.IsNullOrWhiteSpace(p) &&
ImportableExtensions.Contains(Path.GetExtension(p).ToLowerInvariant()))
.ToList();
if (paths.Count > 0)
{
return new ClipboardImportPayload { FilePaths = paths };
}
}
// 2. CF_HTML with an embedded data: URI - how a real (possibly animated) GIF arrives
// when copied out of a browser or out of this app itself.
if (Clipboard.ContainsText(TextDataFormat.Html))
{
var match = DataUriRegex().Match(Clipboard.GetText(TextDataFormat.Html));
if (match.Success)
{
try
{
var bytes = Convert.FromBase64String(match.Groups["data"].Value);
var ext = match.Groups["mime"].Value.ToLowerInvariant() switch
{
"gif" => ".gif",
"jpeg" or "jpg" => ".jpg",
_ => ".png",
};
return new ClipboardImportPayload { Data = bytes, Extension = ext };
}
catch (FormatException)
{
// Malformed base64 - fall through to the bitmap formats below.
}
}
}
// 3. The registered "PNG" format - lossless and keeps transparency, unlike CF_DIB.
if (Clipboard.ContainsData("PNG") && Clipboard.GetData("PNG") is { } pngData)
{
byte[]? bytes = pngData switch
{
MemoryStream ms => ms.ToArray(),
byte[] raw => raw,
_ => null,
};
if (bytes is { Length: > 0 })
{
return new ClipboardImportPayload { Data = bytes, Extension = ".png" };
}
}
// 4. Plain bitmap (Snipping Tool, Paint) - always static, so it's the last resort.
if (Clipboard.ContainsImage() && Clipboard.GetImage() is { } bitmap)
{
using var stream = new MemoryStream();
var encoder = new PngBitmapEncoder();
encoder.Frames.Add(BitmapFrame.Create(bitmap));
encoder.Save(stream);
return new ClipboardImportPayload { Data = stream.ToArray(), Extension = ".png" };
}
return null;
}
[GeneratedRegex(@"data:image/(?<mime>gif|png|jpe?g);base64,(?<data>[A-Za-z0-9+/=]+)", RegexOptions.IgnoreCase)]
private static partial Regex DataUriRegex();
private static async Task SetClipboardWithRetryAsync(DataObject data)
{
const int maxAttempts = 5;
@@ -1,11 +1,13 @@
using System.Net;
using System.Net.Http;
using System.Net.Http.Json;
using System.Text.Json.Serialization;
using System.Text.RegularExpressions;
using EbbesMemeClipboard.Models;
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 Rating = "pg-13";
@@ -20,45 +22,112 @@ public sealed class GiphyGifProvider : IGifProvider
public string NotConfiguredMessage =>
"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)
{
_http = http;
_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>();
var apiKey = Uri.EscapeDataString(_settings.Current.GiphyApiKey!);
var url = string.IsNullOrWhiteSpace(query)
? $"{BaseUrl}/trending?api_key={apiKey}&limit={limit}&rating={Rating}"
: $"{BaseUrl}/search?api_key={apiKey}&q={Uri.EscapeDataString(query)}&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}&offset={offset}&rating={Rating}";
var response = await _http.GetAsync(url, ct);
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>();
return payload.Data
.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();
return payload.Data.Select(ToResult).OfType<GifSearchResult>().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; }
}
private sealed class GiphySingleResponse
{
[JsonPropertyName("data")] public GiphyItem? Data { get; set; }
}
private sealed class GiphyItem
{
[JsonPropertyName("id")] public string? Id { get; set; }
@@ -1,3 +1,5 @@
using EbbesMemeClipboard.Models;
namespace EbbesMemeClipboard.Services;
public interface IClipboardService
@@ -8,4 +10,10 @@ public interface IClipboardService
/// useful, and animated GIFs survive as animations wherever possible.
/// </summary>
Task CopyLocalFileAsync(string filePath);
/// <summary>
/// Reads an importable image off the clipboard, preferring the highest-fidelity format
/// available. Returns null when the clipboard holds no image (e.g. plain text).
/// </summary>
ClipboardImportPayload? TryReadImage();
}
@@ -12,6 +12,18 @@ public interface IGifProvider
/// <summary>Shown in the UI when IsConfigured is false, explaining how to fix it.</summary>
string NotConfiguredMessage { get; }
/// <summary>An empty query returns whatever the provider considers trending.</summary>
Task<IReadOnlyList<GifSearchResult>> SearchAsync(string query, int limit, CancellationToken ct);
/// <summary>Placeholder shown in the search box while it's empty.</summary>
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);
}
@@ -8,5 +8,9 @@ public interface ILocalMemeLibraryService
IReadOnlyList<LocalMemeRecord> Search(string query);
string GetFullPath(LocalMemeRecord record);
Task<IReadOnlyList<LocalMemeRecord>> ImportAsync(IEnumerable<string> sourceFilePaths);
/// <summary>Imports image data that has no source file (e.g. a pasted screenshot).</summary>
Task<LocalMemeRecord?> ImportBytesAsync(byte[] data, string extension, string originalFileName);
Task RemoveAsync(LocalMemeRecord record);
}
@@ -75,6 +75,27 @@ public sealed class LocalMemeLibraryService : ILocalMemeLibraryService
return imported;
}
public async Task<LocalMemeRecord?> ImportBytesAsync(byte[] data, string extension, string originalFileName)
{
if (!AllowedExtensions.Contains(extension) || data.Length == 0)
return null;
var storedFileName = $"{Guid.NewGuid():N}{extension.ToLowerInvariant()}";
await File.WriteAllBytesAsync(Path.Combine(_libraryRoot, storedFileName), data);
var record = new LocalMemeRecord
{
Id = Guid.NewGuid().ToString("N"),
FileName = storedFileName,
OriginalFileName = originalFileName,
DateAdded = DateTimeOffset.Now,
};
_records.Add(record);
await SaveIndexAsync();
return record;
}
public async Task RemoveAsync(LocalMemeRecord record)
{
var path = GetFullPath(record);
@@ -1,3 +1,4 @@
using System.IO;
using System.Windows.Media.Imaging;
using CommunityToolkit.Mvvm.ComponentModel;
using EbbesMemeClipboard.Models;
@@ -11,9 +12,10 @@ namespace EbbesMemeClipboard.ViewModels;
/// </summary>
public partial class MemeTileViewModel : ObservableObject
{
private const int ThumbnailPixelWidth = 150;
private const int ThumbnailPixelWidth = 240;
private readonly IGifCacheService? _cache;
private readonly bool _animatePreviews;
private string? _localPath;
public LocalMemeRecord? Record { get; }
@@ -33,20 +35,33 @@ public partial class MemeTileViewModel : ObservableObject
[ObservableProperty]
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";
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;
_localPath = fullPath;
_animatePreviews = animatePreviews;
}
public MemeTileViewModel(GifSearchResult remoteResult, IGifCacheService cache)
public MemeTileViewModel(GifSearchResult remoteResult, IGifCacheService cache, bool animatePreviews = false)
{
RemoteResult = remoteResult;
_cache = cache;
_animatePreviews = animatePreviews;
}
/// <summary>
@@ -69,11 +84,23 @@ public partial class MemeTileViewModel : ObservableObject
{
var path = _localPath!;
Thumbnail = await Task.Run(() => ImageDecoding.DecodeFirstFrame(path, ThumbnailPixelWidth), ct);
if (_animatePreviews && ImageDecoding.IsGif(path))
{
AnimationUri = new Uri(path);
}
}
else
{
var bytes = await _cache!.DownloadPreviewAsync(RemoteResult!, 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)
@@ -86,4 +113,7 @@ public partial class MemeTileViewModel : ObservableObject
// 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.Specialized;
using System.IO;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
@@ -9,7 +10,9 @@ namespace EbbesMemeClipboard.ViewModels;
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 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>
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>
public ObservableCollection<MemeTileViewModel> Favorites { get; } = new();
/// <summary>True when the grid is showing a single item resolved from a pasted link.</summary>
private bool _showingResolvedLink;
public ObservableCollection<MemeTileViewModel> Items { get; } = new();
[ObservableProperty]
private string _searchText = string.Empty;
@@ -40,8 +47,12 @@ public partial class PickerViewModel : ObservableObject
[ObservableProperty]
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]
private bool _hasFavorites;
private bool _showingFavorites;
[ObservableProperty]
private MemeSource _activeSource = MemeSource.Local;
@@ -49,6 +60,25 @@ public partial class PickerViewModel : ObservableObject
public bool IsLocalSource => ActiveSource == MemeSource.Local;
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 PickerViewModel(
@@ -68,26 +98,64 @@ public partial class PickerViewModel : ObservableObject
_favorites = favorites;
_providers = providers.ToList();
RefreshLocalItems();
RefreshFavorites();
Items.CollectionChanged += OnItemsChanged;
// Restore the tab the picker was last left on. Assigned to the backing fields directly
// so restoring doesn't count as a user change and immediately re-save.
_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 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)
{
OnPropertyChanged(nameof(IsLocalSource));
OnPropertyChanged(nameof(IsGiphySource));
OnPropertyChanged(nameof(SearchPlaceholder));
OnPropertyChanged(nameof(CanImport));
OnPropertyChanged(nameof(ShowEmptyState));
SearchText = string.Empty;
RefreshFavorites();
PersistViewState();
// Setting SearchText above only triggers a refresh if the value actually changed, so
// refresh explicitly here to cover switching tabs with an already-empty box.
_ = RefreshAsync();
}
private void PersistViewState()
{
_settings.Current.LastSource = ActiveSource;
_settings.Current.LastShowingFavorites = ShowingFavorites;
_ = _settings.SaveAsync();
}
[RelayCommand]
private void SelectSource(MemeSource source) => ActiveSource = source;
[RelayCommand]
private void ToggleFavoritesView() => ShowingFavorites = !ShowingFavorites;
[RelayCommand]
private async Task SelectItemAsync(MemeTileViewModel? tile)
{
@@ -130,7 +198,11 @@ public partial class PickerViewModel : ObservableObject
if (_favorites.IsFavorite(tile.Source, tile.FavoriteKey))
{
await _favorites.RemoveAsync(tile.Source, tile.FavoriteKey);
tile.IsFavorite = false;
StatusMessage = $"Removed {tile.DisplayName} from favourites";
// In the favourites view an unfavourited tile no longer belongs on screen.
if (ShowingFavorites) Items.Remove(tile);
}
else
{
@@ -143,11 +215,9 @@ public partial class PickerViewModel : ObservableObject
FullUrl = tile.RemoteResult?.FullUrl,
DateAdded = DateTimeOffset.Now,
});
tile.IsFavorite = true;
StatusMessage = $"Added {tile.DisplayName} to favourites";
}
RefreshFavorites();
SyncFavoriteFlags();
}
[RelayCommand]
@@ -155,13 +225,13 @@ public partial class PickerViewModel : ObservableObject
{
if (tile?.Record is null) return;
var name = tile.DisplayName;
await _library.RemoveAsync(tile.Record);
// Drop the matching favourite too, otherwise it lingers pointing at a deleted file.
await _favorites.RemoveAsync(MemeSource.Local, tile.Record.Id);
RefreshLocalItems();
RefreshFavorites();
StatusMessage = $"Removed {tile.DisplayName}";
await RefreshAsync();
StatusMessage = $"Removed {name}";
}
[RelayCommand]
@@ -188,6 +258,55 @@ public partial class PickerViewModel : ObservableObject
}
}
/// <summary>
/// Synchronous peek so the Ctrl+V key handler can decide immediately whether to swallow the
/// keystroke (image on the clipboard) or let it through to the search box (plain text).
/// </summary>
public ClipboardImportPayload? ReadClipboardImage()
{
try
{
return _clipboard.TryReadImage();
}
catch (Exception)
{
// Another process holding the clipboard shouldn't break the keystroke entirely.
return null;
}
}
public async Task ImportClipboardAsync(ClipboardImportPayload payload)
{
try
{
if (payload.HasFiles)
{
await ImportPathsAsync(payload.FilePaths!);
return;
}
if (!payload.HasBytes) return;
var name = $"pasted-{DateTime.Now:yyyy-MM-dd-HHmmss}{payload.Extension}";
var record = await _library.ImportBytesAsync(payload.Data!, payload.Extension!, name);
if (record is null)
{
StatusMessage = "Couldn't add that image.";
return;
}
ShowingFavorites = false;
ActiveSource = MemeSource.Local;
RefreshLocalItems();
StatusMessage = $"Pasted {name}";
}
catch (Exception ex)
{
StatusMessage = $"Couldn't paste image: {ex.Message}";
}
}
public async Task ImportPathsAsync(IEnumerable<string> paths)
{
var candidates = paths
@@ -201,6 +320,7 @@ public partial class PickerViewModel : ObservableObject
}
var imported = await _library.ImportAsync(candidates);
ShowingFavorites = false;
ActiveSource = MemeSource.Local;
RefreshLocalItems();
StatusMessage = $"Added {imported.Count} meme(s).";
@@ -209,27 +329,76 @@ public partial class PickerViewModel : ObservableObject
public void OnShown()
{
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()
{
// 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?.Dispose();
_searchCts = null;
_remoteOffset = 0;
_remoteHasMore = false;
_showingResolvedLink = false;
if (ShowingFavorites)
{
RefreshFavoriteItems();
return;
}
if (IsLocalSource)
{
RefreshLocalItems();
return;
}
var provider = _providers.FirstOrDefault(p => p.Source == ActiveSource);
var provider = ActiveProvider;
if (provider is null) return;
if (!provider.IsConfigured)
@@ -249,20 +418,25 @@ public partial class PickerViewModel : ObservableObject
await Task.Delay(SearchDebounce, cts.Token);
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();
Items.Clear();
foreach (var result in results)
{
var tile = new MemeTileViewModel(result, _gifCache);
Items.Add(tile);
_ = tile.LoadThumbnailAsync(cts.Token);
AddRemoteTile(result, cts.Token);
}
SyncFavoriteFlags();
_remoteOffset = results.Count;
_remoteHasMore = results.Count >= RemoteResultLimit;
StatusMessage = results.Count == 0 ? "No results." : $"{results.Count} result(s).";
}
catch (OperationCanceledException)
@@ -282,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()
{
var records = string.IsNullOrWhiteSpace(SearchText)
@@ -291,60 +506,48 @@ public partial class PickerViewModel : ObservableObject
Items.Clear();
foreach (var record in records)
{
var tile = new MemeTileViewModel(record, _library.GetFullPath(record));
Items.Add(tile);
_ = tile.LoadThumbnailAsync();
AddLocalTile(record);
}
SyncFavoriteFlags();
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))
{
MemeTileViewModel tile;
if (fav.Source == MemeSource.Local)
{
var record = _library.GetAll().FirstOrDefault(r => r.Id == fav.Key);
// Favourite pointing at a meme that's since been deleted - skip it rather than
// rendering a broken tile.
// Favourite pointing at a meme that has since been deleted - skip it rather
// than rendering a broken tile.
if (record is null) continue;
tile = new MemeTileViewModel(record, _library.GetFullPath(record));
if (!Matches(record.OriginalFileName, query)) continue;
AddLocalTile(record);
}
else
{
if (fav.PreviewUrl is null || fav.FullUrl is null) continue;
tile = new MemeTileViewModel(
new GifSearchResult
{
Id = fav.Key,
Title = fav.Title ?? "Favourite",
PreviewUrl = fav.PreviewUrl,
FullUrl = fav.FullUrl,
Source = fav.Source,
},
_gifCache);
if (!Matches(fav.Title ?? string.Empty, query)) continue;
AddRemoteTile(new GifSearchResult
{
Id = fav.Key,
Title = fav.Title ?? "Favourite",
PreviewUrl = fav.PreviewUrl,
FullUrl = fav.FullUrl,
Source = fav.Source,
});
}
tile.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 void SyncFavoriteFlags()
{
foreach (var tile in Items)
{
tile.IsFavorite = _favorites.IsFavorite(tile.Source, tile.FavoriteKey);
}
}
private static bool Matches(string text, string query) =>
string.IsNullOrWhiteSpace(query) || text.Contains(query, StringComparison.OrdinalIgnoreCase);
}
@@ -26,6 +26,9 @@ public partial class SettingsViewModel : ObservableObject
[ObservableProperty]
private string _giphyApiKey = string.Empty;
[ObservableProperty]
private bool _playGifPreviews;
public bool IsCopyOnly
{
get => InsertMode == InsertMode.CopyOnly;
@@ -52,6 +55,7 @@ public partial class SettingsViewModel : ObservableObject
_insertMode = _settings.Current.InsertMode;
_giphyApiKey = _settings.Current.GiphyApiKey ?? string.Empty;
_playGifPreviews = _settings.Current.PlayGifPreviews;
_hotkeyDisplay = HotkeyFormatter.Format(_settings.Current.HotkeyModifiers, _settings.Current.HotkeyKey);
// Read from the registry rather than a stored setting, so the checkbox reflects reality
// 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 OnPlayGifPreviewsChanged(bool value)
{
_settings.Current.PlayGifPreviews = value;
_ = _settings.SaveAsync();
}
partial void OnGiphyApiKeyChanged(string value)
{
_settings.Current.GiphyApiKey = string.IsNullOrWhiteSpace(value) ? null : value.Trim();
+75 -66
View File
@@ -4,6 +4,7 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:EbbesMemeClipboard.ViewModels"
xmlns:models="clr-namespace:EbbesMemeClipboard.Models"
xmlns:gif="clr-namespace:XamlAnimatedGif;assembly=XamlAnimatedGif"
Title="Meme Clipboard"
Width="420" Height="520"
WindowStyle="None"
@@ -75,9 +76,11 @@
</Setter>
</Style>
<!-- Shared by the favourites row and the main grid so both behave identically. -->
<!-- Shared by the favourites row and the main grid so both behave identically.
No fixed Width: the tile stretches to fill its UniformGrid cell, so a row always
spans the full panel width instead of leaving a ragged gap on the right. -->
<DataTemplate x:Key="MemeTileTemplate" DataType="{x:Type vm:MemeTileViewModel}">
<Grid Width="88" Height="88" Margin="4">
<Grid Height="118" Margin="4">
<Button Padding="0"
Style="{StaticResource FlatButtonStyle}"
Command="{Binding DataContext.SelectItemCommand, ElementName=RootWindow}"
@@ -96,7 +99,13 @@
CommandParameter="{Binding PlacementTarget.DataContext, RelativeSource={RelativeSource AncestorType=ContextMenu}}"/>
</ContextMenu>
</Button.ContextMenu>
<Image Source="{Binding Thumbnail}" Stretch="Uniform" Width="80" Height="80"/>
<!-- 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>
<!-- Star overlay: marks favourites at a glance without needing a second grid. -->
@@ -131,18 +140,27 @@
</Grid>
</Border>
<StackPanel Grid.Row="1" Orientation="Horizontal" Margin="0,4,0,8">
<Button Content="Local"
<Grid Grid.Row="1" Margin="0,4,0,8">
<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}"
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>
Tag="{Binding ShowingFavorites}"
Command="{Binding ToggleFavoritesViewCommand}"
ToolTip="Show only your favourites from this source"/>
</Grid>
<Grid Grid.Row="2" Margin="0,0,0,10">
<Grid.ColumnDefinitions>
@@ -150,69 +168,60 @@
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<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>
<WrapPanel Orientation="Horizontal"/>
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
</ItemsControl>
<Border Height="1" Background="#3A3B3E" Margin="4,10,4,10"/>
</StackPanel>
<Grid>
<TextBlock Text="No memes yet. Click + or drag files in here."
Foreground="#7B7D85" FontSize="13"
HorizontalAlignment="Center" VerticalAlignment="Center"
TextWrapping="Wrap" TextAlignment="Center" Width="260"
Margin="0,40,0,0">
<TextBox x:Name="SearchBox"
Text="{Binding SearchText, UpdateSourceTrigger=PropertyChanged}"
Background="Transparent" Foreground="White" BorderThickness="0"
Padding="10,7" FontSize="14"
CaretBrush="White"/>
<!-- WPF has no native placeholder, so this sits behind the caret and
hides as soon as anything is typed. Not hit-testable, so clicking
it still focuses the box underneath. -->
<TextBlock Text="{Binding SearchPlaceholder}"
Foreground="#6E7078" FontSize="14"
Margin="11,0,10,0" VerticalAlignment="Center"
IsHitTestVisible="False"
TextTrimming="CharacterEllipsis">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Setter Property="Visibility" Value="Collapsed"/>
<Style.Triggers>
<MultiDataTrigger>
<MultiDataTrigger.Conditions>
<Condition Binding="{Binding Items.Count}" Value="0"/>
<Condition Binding="{Binding IsLocalSource}" Value="True"/>
</MultiDataTrigger.Conditions>
<DataTrigger Binding="{Binding SearchText}" Value="">
<Setter Property="Visibility" Value="Visible"/>
</MultiDataTrigger>
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
<ItemsControl ItemsSource="{Binding Items}"
ItemTemplate="{StaticResource MemeTileTemplate}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel Orientation="Horizontal"/>
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
</ItemsControl>
</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>
<Grid Grid.Row="4" Margin="2,8,0,0">
@@ -29,6 +29,8 @@ public partial class PickerWindow : Window
_autoPaste.CaptureForegroundWindow();
PositionNearCursor();
_viewModel.OnShown();
// Always reopen at the top rather than wherever the last session was left scrolled to.
ResultsScrollViewer.ScrollToTop();
Show();
Activate();
SearchBox.Focus();
@@ -88,6 +90,15 @@ public partial class PickerWindow : Window
{
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)
@@ -99,6 +110,22 @@ public partial class PickerWindow : Window
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_OnContextMenuClosing(object sender, ContextMenuEventArgs e) => _isContextMenuOpen = false;
@@ -108,6 +135,27 @@ public partial class PickerWindow : Window
if (e.Key == Key.Escape) Hide();
}
/// <summary>
/// Ctrl+V adds whatever image is on the clipboard to the local library. The clipboard is
/// inspected synchronously so the decision to swallow the keystroke can be made before the
/// event reaches the search box - when the clipboard holds plain text instead, the paste is
/// deliberately left alone so it still lands in the search field as normal.
/// </summary>
protected override void OnPreviewKeyDown(KeyEventArgs e)
{
if (e.Key == Key.V && (Keyboard.Modifiers & ModifierKeys.Control) == ModifierKeys.Control)
{
var payload = _viewModel.ReadClipboardImage();
if (payload is not null)
{
e.Handled = true;
_ = _viewModel.ImportClipboardAsync(payload);
}
}
base.OnPreviewKeyDown(e);
}
private void PickerWindow_OnDragOver(object sender, DragEventArgs e)
{
e.Effects = e.Data.GetDataPresent(DataFormats.FileDrop) ? DragDropEffects.Copy : DragDropEffects.None;
@@ -37,6 +37,11 @@
<CheckBox Content="Start automatically when Windows starts" Foreground="White"
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"/>
<Border Background="#2B2D31" CornerRadius="6">
<TextBox Text="{Binding GiphyApiKey, UpdateSourceTrigger=PropertyChanged}"