8 Commits
34 changed files with 1924 additions and 166 deletions
+133 -70
View File
@@ -26,15 +26,24 @@ 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)
- Three insert modes, chosen in Settings: **Copy only / Paste into active window /
Paste and send instantly**
- Settings window: hotkey capture (incl. Windows key), insert mode radio buttons
- Consistent dark styling across all menus (tray + tile right-click)
- Persistence: memes and settings survive restarts
- Settings window: hotkey capture (incl. Windows key), insert mode radio buttons,
**autostart toggle**, **Giphy API key field**
- **Source tabs in the picker: Local / Giphy**, with debounced Giphy search, remote
thumbnails, download-on-select caching, and the required "Powered by GIPHY" badge
- **Windows autostart** via the HKCU Run key, toggled in Settings (verified: checkbox and
registry track each other in both directions)
- **Per-source favourites**: right-click any tile → "Add to favourites", shown in a
★ Favourites section at the top of that tab, with a star overlay on favourited tiles.
Scoped per source (Local favourites don't appear on the Giphy tab) and persisted.
- App title shown top-left of the picker; the title row doubles as the drag surface
- Consistent dark styling across all menus (tray + tile right-click) **and scrollbars**
- Persistence: memes, favourites and settings survive restarts
**Build is clean** (0 warnings, 0 errors). Working tree was clean at last check;
`publish/` is gitignored.
@@ -43,10 +52,18 @@ Everything below is **implemented and verified working by actually running the a
```
%AppData%\EbbesMemeClipboard\
settings.json InsertMode, HotkeyModifiers, HotkeyKey
settings.json InsertMode, HotkeyModifiers, HotkeyKey, GiphyApiKey
favorites.json list of FavoriteRecord (Source, Key, Title, Preview/FullUrl, DateAdded)
Library\
index.json list of LocalMemeRecord (Id, FileName, OriginalFileName, DateAdded)
<guid>.png/.jpg/.gif imported files, stored under a GUID name
%LocalAppData%\EbbesMemeClipboard\
GifCache\ downloaded Giphy GIFs (re-downloadable cache, so Local not Roaming)
HKCU\Software\Microsoft\Windows\CurrentVersion\Run\EbbesMemeClipboard
autostart entry; the registry is the single source of truth for
this (deliberately NOT mirrored into settings.json, to avoid drift)
```
---
@@ -60,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.
@@ -78,18 +126,26 @@ faster.
```
src/EbbesMemeClipboard/
App.xaml / App.xaml.cs composition root: DI container, tray icon, hotkey registration
Styles/DarkMenuStyles.xaml implicit ContextMenu/MenuItem/Separator styles (app-wide)
Styles/DarkMenuStyles.xaml implicit ContextMenu/MenuItem/Separator styles (app-wide)
Styles/DarkScrollBarStyles.xaml implicit slim dark ScrollBar style (app-wide)
Models/
LocalMemeRecord.cs Id, FileName, OriginalFileName, DateAdded
AppSettings.cs InsertMode, HotkeyModifiers, HotkeyKey
AppSettings.cs InsertMode, HotkeyModifiers, HotkeyKey, GiphyApiKey
InsertMode.cs CopyOnly | PasteIntoActiveWindow | PasteAndSend
MemeSource.cs Local | Giphy
GifSearchResult.cs provider-agnostic remote hit
FavoriteRecord.cs per-source favourite (remote ones carry their own URLs)
Services/
ILocalMemeLibraryService / LocalMemeLibraryService import/search/remove, index.json
IClipboardService / ClipboardService multi-format clipboard write
IGlobalHotkeyService / GlobalHotkeyService RegisterHotKey + message-only HwndSource
ISettingsService / SettingsService System.Text.Json persistence
IAutoPasteService / AutoPasteService foreground restore + SendInput paste
ImageDecoding.cs first-frame decode helper
IAutostartService / AutostartService HKCU Run key
IGifProvider / GiphyGifProvider remote search behind a provider interface
IGifCacheService / GifCacheService download + cache remote GIFs
IFavoritesService / FavoritesService per-source favourites, favorites.json
ImageDecoding.cs first-frame decode helper (file + bytes)
HotkeyFormatter.cs "Ctrl + Alt + M" display strings
Native/
NativeMethods.cs P/Invoke declarations
@@ -132,9 +188,24 @@ code-behind fields. Look elements up by `Tag`/traversal instead.
|---|---|
| `CF_HDROP` (file drop list) | Discord/Slack/Teams/Explorer paste the real file — preserves GIF animation |
| `CF_DIB` (`SetImage`) | Bitmap-only apps like Paint |
| `"PNG"` (registered format) | **Chromium-based apps (Teams, Slack, Discord, browsers) prefer this over CF_DIB** and may paste nothing at all without it |
| `"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
through to `CF_HTML`, which carries the full animated data URI. Verified: a GIF's clipboard
formats are FileDrop/Bitmap/HTML with no PNG, while a static image still gets PNG.
Two traps here:
- WPF does **NOT** auto-wrap `DataFormats.Html`. The `Version/StartHTML/EndHTML/
StartFragment/EndFragment` header with exact **byte** offsets must be built by hand
@@ -206,79 +277,68 @@ Automated UI testing of this app is unusually fiddly. What works:
for a name containing "Meme Clipboard". This finds it even when tucked in the overflow
area, unlike screenshots.
Clean up after testing: kill `EbbesMemeClipboard` processes and remove
`%AppData%\EbbesMemeClipboard` so seeded test data doesn't leak into real use.
Clean up after testing: kill `EbbesMemeClipboard` processes, remove
`%AppData%\EbbesMemeClipboard` and `%LocalAppData%\EbbesMemeClipboard`, and **check the
autostart Run key isn't left enabled** if autostart was exercised — that one writes to the
user's real machine state, not just app-local files.
---
## Pending requests (not yet implemented)
## Giphy integration notes
From the user's most recent batch. **None of these are started.**
**Tenor is dead.** The user originally asked for Tenor; research confirmed Google **shut
down the public Tenor API on 2026-06-30** (corroborated by 9to5Google, Shacknews,
Slashdot). Not buildable. **Giphy** was chosen instead and is what's implemented.
### 1. Windows auto-start, with a toggle in Settings
Straightforward. Write `HKCU\Software\Microsoft\Windows\CurrentVersion\Run`.
**Important**: use `Environment.ProcessPath`, **not** `Assembly.Location` — the latter
returns an empty string in single-file published mode. Add an `IAutostartService` +
a checkbox in `SettingsWindow`.
### 2. Tab/dropdown for meme sources (Local, Giphy)
UI shell for multiple sources, plus the Giphy integration behind it.
**Critical context — Tenor is dead.** The user originally asked for Tenor; research during
planning confirmed Google **shut down the public Tenor API on 2026-06-30** (corroborated by
9to5Google, Shacknews, Slashdot). It is not buildable. The user chose **Giphy** as the
replacement. Notes:
- `GET https://api.giphy.com/v1/gifs/search?api_key={key}&q={query}&limit={n}&rating=pg-13`
- Response: `data[].images.{fixed_width,downsized,original}.url`
- Free "beta" key is 100 req/hr — plenty for personal use. Production tier is ~$9k/yr and
irrelevant here. **The user needs to supply their own key** (add a field in Settings).
- Giphy's ToS **requires a visible "Powered by GIPHY" badge** wherever results appear.
This is a real UI requirement, not optional attribution.
- Build behind an `IGifProvider` interface so another provider can be swapped in later.
- Remote GIFs must be **downloaded to a local cache file before** they can go on the
clipboard as CF_HDROP.
- `GET https://api.giphy.com/v1/gifs/search?api_key={key}&q={q}&limit={n}&rating=pg-13`
- Empty query falls back to `/trending` with the same parameters.
- Response: `data[].images.{fixed_width,original}.url` — `fixed_width` for grid
thumbnails, `original` for the full-quality animated file that gets pasted.
- **The user supplies their own key** in Settings. Free tier is 100 req/hr; the ~$9k/yr
production tier is irrelevant for personal use. Without a key the tab shows guidance
instead of failing at request time (`IGifProvider.IsConfigured`).
- Searches are **debounced 350ms** and cancel the previous in-flight request — without
that, every keystroke burns a request against the free rate limit.
- Giphy's ToS **requires the visible "Powered by GIPHY" badge**, rendered in the picker
footer whenever the Giphy tab is active. Don't remove it.
- Klipy (`api.klipy.com`) is a viable alternative if Giphy becomes a problem — founded by
ex-Tenor people, deliberately mirrors Tenor's API shape, free.
ex-Tenor people, deliberately mirrors Tenor's API shape, free. Add it as a second
`IGifProvider`; the tab strip and view models are already provider-agnostic.
### 3. "Central store specific to the application for all local memes"
**⚠️ STILL AMBIGUOUS — ASK THE USER BEFORE BUILDING.**
The app *already* stores memes centrally in `%AppData%\EbbesMemeClipboard\Library`. When
asked to clarify, the user selected "Something else" but the conversation moved on before
they explained. Options offered (and rejected) were: (a) save Giphy results into the local
library, (b) configurable library folder location. So it's something other than those two —
**get a concrete description first.**
**Untested by the assistant**: actual Giphy search results, remote thumbnail loading, and
download-on-select were never exercised against the live API, because no API key was
available. The code paths are written but unverified end-to-end — if something misbehaves
once a key is entered, start with `GiphyGifProvider.SearchAsync` (JSON field names) and
`GifCacheService.GetOrDownloadAsync`.
### 4. Fix GIFs — "clicking a GIF doesn't copy/paste it correctly"
User confirmed this specific symptom (not thumbnails, not crashes, not lag).
---
**Strong root-cause hypothesis (not yet verified — verify before/while fixing):**
`ClipboardService` sets the `"PNG"` clipboard format from `firstFrame` — i.e. **frame 0 of
the GIF re-encoded as a static PNG**. Chromium-based apps *prefer* the `"PNG"` format over
everything else (that preference is exactly why it was added, to fix static-image pasting
into Teams). So for a GIF, Teams/Discord grab the static first frame instead of the animated
GIF → the GIF pastes as a still image.
## Pending requests
**Suggested fix**: skip the `"PNG"` format when the file is a GIF, so Chromium falls back to
`CF_HTML` (which already carries the full animated `data:image/gif;base64,...` URI) or
`CF_HDROP`. `ImageDecoding.IsGif()` already exists for the check. Keep `CF_DIB` so Paint
still gets something. Test in both Teams and Paint, since these two want opposite things.
### ⚠️ 1. "Central store specific to the application for all local memes" — STILL AMBIGUOUS
**Ask the user before building.** The app *already* stores memes centrally in
`%AppData%\EbbesMemeClipboard\Library`. When asked to clarify, the user chose "Something
else" but the conversation moved on before they explained. Options offered *and rejected*
were: (a) save Giphy results into the local library, (b) configurable library folder
location. So it's something other than those two — get a concrete description first.
**Also worth fixing while in there** (separate, minor, pre-existing):
`ImageDecoding.DecodeFirstFrame` **ignores `decodePixelWidth` for GIFs** — it returns the
`GifBitmapDecoder` frame before reaching the `DecodePixelWidth` logic. So GIF thumbnails
decode at full resolution while static images are capped at 150px. A library with many
large GIFs will use more memory than it should.
### 2. Paste-send reliability ("Teams is sometimes too slow")
Not yet addressed. Current delays in `AutoPasteService`: `FocusSettleDelay` 200ms (before
Ctrl+V), `PasteSettleDelay` 400ms (before Enter). Both are fixed sleeps, so a slow app will
still occasionally miss.
### 5. Paste-send reliability ("Teams is sometimes too slow")
Current delays in `AutoPasteService`: `FocusSettleDelay` 200ms (before Ctrl+V),
`PasteSettleDelay` 400ms (before Enter). Both are fixed sleeps — a slow app will still miss.
Bumping the constants is the cheap fix but slows every paste for everyone. Better: **poll
for readiness instead of guessing** — verify the target window is genuinely foreground
before sending Ctrl+V, and confirm rather than firing Enter blind. Note the failure mode
already observed: if Enter fires when the paste didn't land, **Teams sends an empty
message**, so the Enter step should be conservative — a wrong guess posts to a real chat.
Bumping the constants is the cheap fix, but it makes every paste slower for everyone. A
better approach is to **poll for readiness instead of guessing**: e.g. verify the target
window is actually foreground before sending Ctrl+V, and for the send step consider
retrying/confirming rather than firing Enter blind. Note the failure mode already seen:
if Enter fires when the paste didn't land, **Teams sends an empty message** — so the Enter
step should be conservative, since a wrong guess posts to a real chat.
### 3. Possible follow-ups (not requested, just noted)
- **Single-instance protection** (named Mutex + named pipe). Currently two instances fight
over the global hotkey; this caused a confusing phantom "regression" during development.
- **`WPF-UI` is referenced but unused** — intended for Mica/Fluent polish. Use it or drop it.
- **Animated GIF previews on hover** (currently static first frame only) — `GifFramePlayer`
control was designed for this in the original plan but never built.
---
@@ -294,6 +354,9 @@ step should be conservative, since a wrong guess posts to a real chat.
chat apps. Default insert mode is `PasteIntoActiveWindow`.
- No Settings-window dark title bar (content is dark, title bar is standard OS chrome).
User was offered a fix and it wasn't prioritized.
- **Autostart state lives only in the registry**, not in `settings.json`. Mirroring it would
let the two drift apart if the entry is removed via Task Manager or another tool; the
Settings checkbox reads live registry state each time the window opens.
## Known limitations (inherent, not bugs)
+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;
+1
View File
@@ -6,6 +6,7 @@
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<ResourceDictionary Source="Styles/DarkMenuStyles.xaml"/>
<ResourceDictionary Source="Styles/DarkScrollBarStyles.xaml"/>
</ResourceDictionary.MergedDictionaries>
<tb:TaskbarIcon x:Key="TrayIcon"
+9 -5
View File
@@ -1,3 +1,4 @@
using System.Net.Http;
using System.Windows;
using EbbesMemeClipboard.Services;
using EbbesMemeClipboard.ViewModels;
@@ -27,6 +28,13 @@ public partial class App : Application
services.AddSingleton<IGlobalHotkeyService, GlobalHotkeyService>();
services.AddSingleton<ISettingsService, SettingsService>();
services.AddSingleton<IAutoPasteService, AutoPasteService>();
services.AddSingleton<IAutostartService, AutostartService>();
// One long-lived HttpClient: the socket-exhaustion problem IHttpClientFactory solves
// comes from repeatedly constructing clients, which a singleton avoids anyway.
services.AddSingleton<HttpClient>();
services.AddSingleton<IGifCacheService, GifCacheService>();
services.AddSingleton<IFavoritesService, FavoritesService>();
services.AddSingleton<IGifProvider, GiphyGifProvider>();
services.AddSingleton<PickerViewModel>();
services.AddSingleton<PickerWindow>();
services.AddSingleton<SettingsViewModel>();
@@ -72,11 +80,7 @@ public partial class App : Application
private void TrayOpenPicker_Click(object sender, RoutedEventArgs e) => _pickerWindow.ShowNearCursor();
private void TraySettings_Click(object sender, RoutedEventArgs e)
{
_settingsWindow.Show();
_settingsWindow.Activate();
}
private void TraySettings_Click(object sender, RoutedEventArgs e) => _settingsWindow.ShowSettings();
private void TrayExit_Click(object sender, RoutedEventArgs e) => Shutdown();
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>
@@ -7,4 +7,26 @@ public sealed class AppSettings
public InsertMode InsertMode { get; set; } = InsertMode.PasteIntoActiveWindow;
public ModifierKeys HotkeyModifiers { get; set; } = ModifierKeys.Control | ModifierKeys.Alt;
public Key HotkeyKey { get; set; } = Key.M;
/// <summary>
/// User-supplied Giphy API key. Null/empty disables the Giphy tab's searching rather than
/// failing at request time. Autostart is deliberately NOT stored here - the registry Run
/// 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;
}
@@ -0,0 +1,24 @@
namespace EbbesMemeClipboard.Models;
/// <summary>
/// A favourited item, scoped to the source it came from. Remote favourites carry their own
/// URLs so the favourites row can be rebuilt without re-hitting the provider's API (which
/// would otherwise cost a request just to show what the user already saved).
/// </summary>
public sealed class FavoriteRecord
{
public required MemeSource Source { get; init; }
/// <summary>Local: the LocalMemeRecord Id. Remote: the provider's own item Id.</summary>
public required string Key { get; init; }
public string? Title { get; init; }
/// <summary>Remote only.</summary>
public string? PreviewUrl { get; init; }
/// <summary>Remote only.</summary>
public string? FullUrl { get; init; }
public DateTimeOffset DateAdded { get; init; }
}
@@ -0,0 +1,19 @@
namespace EbbesMemeClipboard.Models;
/// <summary>
/// A provider-agnostic search hit. Deliberately not Giphy-shaped so a second provider can be
/// added without touching the view models.
/// </summary>
public sealed class GifSearchResult
{
public required string Id { get; init; }
public required string Title { get; init; }
/// <summary>Smaller rendition used for the grid thumbnail.</summary>
public required string PreviewUrl { get; init; }
/// <summary>Full-quality animated rendition, downloaded when the user picks the tile.</summary>
public required string FullUrl { get; init; }
public required MemeSource Source { get; init; }
}
@@ -0,0 +1,10 @@
namespace EbbesMemeClipboard.Models;
public enum MemeSource
{
/// <summary>Memes the user added themselves, stored under %AppData%.</summary>
Local,
/// <summary>Search results from the Giphy API.</summary>
Giphy,
}
@@ -0,0 +1,42 @@
using Microsoft.Win32;
namespace EbbesMemeClipboard.Services;
public sealed class AutostartService : IAutostartService
{
private const string RunKeyPath = @"Software\Microsoft\Windows\CurrentVersion\Run";
private const string ValueName = "EbbesMemeClipboard";
public bool IsEnabled
{
get
{
using var key = Registry.CurrentUser.OpenSubKey(RunKeyPath, writable: false);
return key?.GetValue(ValueName) is string existing && existing.Contains(ExecutablePath, StringComparison.OrdinalIgnoreCase);
}
}
public void SetEnabled(bool enabled)
{
using var key = Registry.CurrentUser.OpenSubKey(RunKeyPath, writable: true)
?? Registry.CurrentUser.CreateSubKey(RunKeyPath, writable: true);
if (enabled)
{
// Quoted because the path can contain spaces, which Windows would otherwise treat
// as an argument boundary and fail to launch.
key.SetValue(ValueName, $"\"{ExecutablePath}\"");
}
else
{
key.DeleteValue(ValueName, throwOnMissingValue: false);
}
}
/// <summary>
/// Environment.ProcessPath, not Assembly.Location: in a single-file published build the
/// managed assemblies are never extracted to disk, so Assembly.Location returns an empty
/// string and the registry entry would point at nothing.
/// </summary>
private static string ExecutablePath => Environment.ProcessPath ?? string.Empty;
}
@@ -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)
{
@@ -27,12 +29,20 @@ public sealed class ClipboardService : IClipboardService
// reading a pasted image, because those older formats are lossy for transparency. This
// covers Teams, Slack, Discord's web layer, and browsers - without it, paste into any of
// them can silently produce nothing even though CF_DIB is present and perfectly valid.
using var pngStream = new MemoryStream();
var pngEncoder = new PngBitmapEncoder();
pngEncoder.Frames.Add(BitmapFrame.Create(firstFrame));
pngEncoder.Save(pngStream);
pngStream.Position = 0;
data.SetData("PNG", pngStream);
//
// Deliberately skipped for GIFs: a PNG can only ever hold one static frame, and since
// Chromium PREFERS this format over all others, offering it for a GIF is what makes
// animated GIFs paste as a still first frame. Omitting it lets those targets fall
// through to CF_HTML below, which carries the full animated data URI.
if (!ImageDecoding.IsGif(filePath))
{
using var pngStream = new MemoryStream();
var pngEncoder = new PngBitmapEncoder();
pngEncoder.Frames.Add(BitmapFrame.Create(firstFrame));
pngEncoder.Save(pngStream);
pngStream.Position = 0;
data.SetData("PNG", pngStream);
}
// CF_HTML: the other route those same web-based paste targets check, and the only one of
// these formats that preserves animation for them, for GIFs.
@@ -52,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;
@@ -0,0 +1,73 @@
using System.IO;
using System.Text.Json;
using System.Text.Json.Serialization;
using EbbesMemeClipboard.Models;
namespace EbbesMemeClipboard.Services;
public sealed class FavoritesService : IFavoritesService
{
private readonly string _path;
private readonly List<FavoriteRecord> _records;
private readonly JsonSerializerOptions _jsonOptions = new()
{
WriteIndented = true,
Converters = { new JsonStringEnumConverter() },
};
public FavoritesService()
{
var appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
var dir = Path.Combine(appData, "EbbesMemeClipboard");
Directory.CreateDirectory(dir);
_path = Path.Combine(dir, "favorites.json");
_records = Load();
}
public IReadOnlyList<FavoriteRecord> GetForSource(MemeSource source) =>
_records.Where(r => r.Source == source)
.OrderByDescending(r => r.DateAdded)
.ToList();
public bool IsFavorite(MemeSource source, string key) =>
_records.Any(r => r.Source == source && r.Key == key);
public async Task AddAsync(FavoriteRecord record)
{
if (IsFavorite(record.Source, record.Key)) return;
_records.Add(record);
await SaveAsync();
}
public async Task RemoveAsync(MemeSource source, string key)
{
int removed = _records.RemoveAll(r => r.Source == source && r.Key == key);
if (removed > 0)
{
await SaveAsync();
}
}
private List<FavoriteRecord> Load()
{
if (!File.Exists(_path)) return new List<FavoriteRecord>();
try
{
var json = File.ReadAllText(_path);
return JsonSerializer.Deserialize<List<FavoriteRecord>>(json, _jsonOptions) ?? new List<FavoriteRecord>();
}
catch (JsonException)
{
// Corrupt file: start clean rather than blocking the whole picker from opening.
return new List<FavoriteRecord>();
}
}
private async Task SaveAsync()
{
var json = JsonSerializer.Serialize(_records, _jsonOptions);
await File.WriteAllTextAsync(_path, json);
}
}
@@ -0,0 +1,45 @@
using System.IO;
using System.Net.Http;
using EbbesMemeClipboard.Models;
namespace EbbesMemeClipboard.Services;
public sealed class GifCacheService : IGifCacheService
{
private readonly HttpClient _http;
private readonly string _cacheRoot;
public GifCacheService(HttpClient http)
{
_http = http;
var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
// LocalApplicationData rather than the roaming AppData used by the library: this is
// re-downloadable cache, not user content, so it shouldn't follow a roaming profile.
_cacheRoot = Path.Combine(localAppData, "EbbesMemeClipboard", "GifCache");
Directory.CreateDirectory(_cacheRoot);
}
public async Task<string> GetOrDownloadAsync(GifSearchResult result, CancellationToken ct)
{
var safeId = string.Concat(result.Id.Where(char.IsLetterOrDigit));
var path = Path.Combine(_cacheRoot, $"{result.Source}_{safeId}.gif");
if (File.Exists(path) && new FileInfo(path).Length > 0)
{
return path;
}
var bytes = await _http.GetByteArrayAsync(result.FullUrl, ct);
// Write to a temp name then move, so an interrupted download can't leave a truncated
// file behind that later runs would treat as a valid cache hit.
var tempPath = path + ".partial";
await File.WriteAllBytesAsync(tempPath, bytes, ct);
File.Move(tempPath, path, overwrite: true);
return path;
}
public Task<byte[]> DownloadPreviewAsync(GifSearchResult result, CancellationToken ct) =>
_http.GetByteArrayAsync(result.PreviewUrl, ct);
}
@@ -0,0 +1,148 @@
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 partial class GiphyGifProvider : IGifProvider
{
private const string BaseUrl = "https://api.giphy.com/v1/gifs";
private const string Rating = "pg-13";
private readonly HttpClient _http;
private readonly ISettingsService _settings;
public MemeSource Source => MemeSource.Giphy;
public bool IsConfigured => !string.IsNullOrWhiteSpace(_settings.Current.GiphyApiKey);
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, 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}&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<GiphyListResponse>(cancellationToken: ct);
if (payload?.Data is null) return Array.Empty<GifSearchResult>();
return payload.Data.Select(ToResult).OfType<GifSearchResult>().ToList();
}
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; }
[JsonPropertyName("title")] public string? Title { get; set; }
[JsonPropertyName("images")] public GiphyImages? Images { get; set; }
}
private sealed class GiphyImages
{
[JsonPropertyName("fixed_width")] public GiphyRendition? FixedWidth { get; set; }
[JsonPropertyName("original")] public GiphyRendition? Original { get; set; }
}
private sealed class GiphyRendition
{
[JsonPropertyName("url")] public string? Url { get; set; }
}
}
@@ -0,0 +1,10 @@
namespace EbbesMemeClipboard.Services;
public interface IAutostartService
{
/// <summary>Reads the live registry state rather than a cached setting, so it stays truthful
/// even if the user removes the entry through Task Manager or another tool.</summary>
bool IsEnabled { get; }
void SetEnabled(bool enabled);
}
@@ -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();
}
@@ -0,0 +1,11 @@
using EbbesMemeClipboard.Models;
namespace EbbesMemeClipboard.Services;
public interface IFavoritesService
{
IReadOnlyList<FavoriteRecord> GetForSource(MemeSource source);
bool IsFavorite(MemeSource source, string key);
Task AddAsync(FavoriteRecord record);
Task RemoveAsync(MemeSource source, string key);
}
@@ -0,0 +1,16 @@
using EbbesMemeClipboard.Models;
namespace EbbesMemeClipboard.Services;
public interface IGifCacheService
{
/// <summary>
/// Ensures the result's full-quality GIF exists as a real file on disk and returns its path.
/// Required before a remote result can go on the clipboard at all, since the file-drop
/// format (the one that preserves animation for chat apps) needs an actual local file.
/// </summary>
Task<string> GetOrDownloadAsync(GifSearchResult result, CancellationToken ct);
/// <summary>Downloads a preview rendition into memory for grid thumbnails.</summary>
Task<byte[]> DownloadPreviewAsync(GifSearchResult result, CancellationToken ct);
}
@@ -0,0 +1,29 @@
using EbbesMemeClipboard.Models;
namespace EbbesMemeClipboard.Services;
public interface IGifProvider
{
MemeSource Source { get; }
/// <summary>False when the provider isn't usable yet (e.g. no API key configured).</summary>
bool IsConfigured { get; }
/// <summary>Shown in the UI when IsConfigured is false, explaining how to fix it.</summary>
string NotConfiguredMessage { get; }
/// <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);
}
@@ -1,4 +1,5 @@
using System.IO;
using System.Windows.Media;
using System.Windows.Media.Imaging;
namespace EbbesMemeClipboard.Services;
@@ -9,7 +10,7 @@ internal static class ImageDecoding
Path.GetExtension(path).Equals(".gif", StringComparison.OrdinalIgnoreCase);
/// <summary>
/// Decodes just the first frame of an image (for GIFs, frame 0 only - the rest of the
/// Decodes just the first frame of an image file (for GIFs, frame 0 only - the rest of the
/// animation is never touched). The result is frozen so it can be handed back across
/// threads safely when called from a background thread.
/// </summary>
@@ -18,9 +19,7 @@ internal static class ImageDecoding
if (IsGif(path))
{
var decoder = new GifBitmapDecoder(new Uri(path), BitmapCreateOptions.PreservePixelFormat, BitmapCacheOption.OnLoad);
var frame = decoder.Frames[0];
frame.Freeze();
return frame;
return Finalize(decoder.Frames[0], decodePixelWidth);
}
var bitmap = new BitmapImage();
@@ -35,4 +34,31 @@ internal static class ImageDecoding
bitmap.Freeze();
return bitmap;
}
/// <summary>Same as DecodeFirstFrame but for bytes already in memory (remote previews).</summary>
internal static BitmapSource DecodeFirstFrameFromBytes(byte[] data, int? decodePixelWidth = null)
{
using var stream = new MemoryStream(data);
var decoder = BitmapDecoder.Create(stream, BitmapCreateOptions.PreservePixelFormat, BitmapCacheOption.OnLoad);
return Finalize(decoder.Frames[0], decodePixelWidth);
}
/// <summary>
/// Scales a decoded frame down to the requested width and freezes it. GIF frames can't use
/// BitmapImage.DecodePixelWidth (that only applies when decoding from a URI), so without
/// this every GIF thumbnail would sit in memory at full resolution.
/// </summary>
private static BitmapSource Finalize(BitmapSource frame, int? decodePixelWidth)
{
if (decodePixelWidth is not int width || frame.PixelWidth <= width)
{
frame.Freeze();
return frame;
}
double scale = width / (double)frame.PixelWidth;
var scaled = new TransformedBitmap(frame, new ScaleTransform(scale, scale));
scaled.Freeze();
return scaled;
}
}
@@ -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);
@@ -0,0 +1,104 @@
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<!-- Slim, rounded, dark scrollbar matching the rest of the app. Implicit (no x:Key) so
merging this dictionary restyles every ScrollViewer without per-control wiring.
The default WPF scrollbar is light grey with stepper arrows, which looks foreign
against the dark popup. -->
<Style x:Key="ScrollBarThumbStyle" TargetType="Thumb">
<Setter Property="OverridesDefaultStyle" Value="True"/>
<Setter Property="IsTabStop" Value="False"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Thumb">
<Border x:Name="Bd" Background="#4A4C52" CornerRadius="3"
SnapsToDevicePixels="True"/>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="Background" Value="#63666E"/>
</Trigger>
<Trigger Property="IsDragging" Value="True">
<Setter TargetName="Bd" Property="Background" Value="#7B7F88"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- Track background must stay hit-testable but invisible, so click-to-page still works
without drawing a visible gutter. -->
<Style x:Key="ScrollBarPageButtonStyle" TargetType="RepeatButton">
<Setter Property="OverridesDefaultStyle" Value="True"/>
<Setter Property="Focusable" Value="False"/>
<Setter Property="IsTabStop" Value="False"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="RepeatButton">
<Border Background="Transparent"/>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style TargetType="ScrollBar">
<Setter Property="OverridesDefaultStyle" Value="True"/>
<Setter Property="Background" Value="Transparent"/>
<Setter Property="Width" Value="10"/>
<Setter Property="MinWidth" Value="10"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ScrollBar">
<Border Background="{TemplateBinding Background}" Padding="2,4">
<!-- No RepeatButtons for the stepper arrows: this is a modern thin
scrollbar, so the arrows are deliberately omitted. -->
<Track x:Name="PART_Track" IsDirectionReversed="True">
<Track.DecreaseRepeatButton>
<RepeatButton Command="ScrollBar.PageUpCommand"
Style="{StaticResource ScrollBarPageButtonStyle}"/>
</Track.DecreaseRepeatButton>
<Track.Thumb>
<Thumb Style="{StaticResource ScrollBarThumbStyle}"/>
</Track.Thumb>
<Track.IncreaseRepeatButton>
<RepeatButton Command="ScrollBar.PageDownCommand"
Style="{StaticResource ScrollBarPageButtonStyle}"/>
</Track.IncreaseRepeatButton>
</Track>
</Border>
</ControlTemplate>
</Setter.Value>
</Setter>
<Style.Triggers>
<Trigger Property="Orientation" Value="Horizontal">
<Setter Property="Width" Value="Auto"/>
<Setter Property="MinWidth" Value="0"/>
<Setter Property="Height" Value="10"/>
<Setter Property="MinHeight" Value="10"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ScrollBar">
<Border Background="{TemplateBinding Background}" Padding="4,2">
<Track x:Name="PART_Track" IsDirectionReversed="False">
<Track.DecreaseRepeatButton>
<RepeatButton Command="ScrollBar.PageLeftCommand"
Style="{StaticResource ScrollBarPageButtonStyle}"/>
</Track.DecreaseRepeatButton>
<Track.Thumb>
<Thumb Style="{StaticResource ScrollBarThumbStyle}"/>
</Track.Thumb>
<Track.IncreaseRepeatButton>
<RepeatButton Command="ScrollBar.PageRightCommand"
Style="{StaticResource ScrollBarPageButtonStyle}"/>
</Track.IncreaseRepeatButton>
</Track>
</Border>
</ControlTemplate>
</Setter.Value>
</Setter>
</Trigger>
</Style.Triggers>
</Style>
</ResourceDictionary>
@@ -1,3 +1,4 @@
using System.IO;
using System.Windows.Media.Imaging;
using CommunityToolkit.Mvvm.ComponentModel;
using EbbesMemeClipboard.Models;
@@ -5,26 +6,114 @@ using EbbesMemeClipboard.Services;
namespace EbbesMemeClipboard.ViewModels;
/// <summary>
/// One grid tile. Covers both a local library file and a remote provider result so the picker
/// can use a single DataTemplate for every source.
/// </summary>
public partial class MemeTileViewModel : ObservableObject
{
private const int ThumbnailPixelWidth = 150;
private const int ThumbnailPixelWidth = 240;
public LocalMemeRecord Record { get; }
public string FullPath { get; }
public string DisplayName => Record.OriginalFileName;
private readonly IGifCacheService? _cache;
private readonly bool _animatePreviews;
private string? _localPath;
public LocalMemeRecord? Record { get; }
public GifSearchResult? RemoteResult { get; }
public bool IsLocal => Record is not null;
public string DisplayName => Record?.OriginalFileName ?? RemoteResult?.Title ?? "Meme";
public MemeSource Source => Record is not null ? MemeSource.Local : RemoteResult!.Source;
/// <summary>Stable identity for favouriting: the library Id locally, the provider Id remotely.</summary>
public string FavoriteKey => Record?.Id ?? RemoteResult!.Id;
[ObservableProperty]
private BitmapSource? _thumbnail;
public MemeTileViewModel(LocalMemeRecord record, string fullPath)
[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, bool animatePreviews = false)
{
Record = record;
FullPath = fullPath;
_localPath = fullPath;
_animatePreviews = animatePreviews;
}
public async Task LoadThumbnailAsync()
public MemeTileViewModel(GifSearchResult remoteResult, IGifCacheService cache, bool animatePreviews = false)
{
var path = FullPath;
Thumbnail = await Task.Run(() => ImageDecoding.DecodeFirstFrame(path, ThumbnailPixelWidth));
RemoteResult = remoteResult;
_cache = cache;
_animatePreviews = animatePreviews;
}
/// <summary>
/// Resolves a real file on disk, downloading and caching first for remote results. The
/// clipboard's file-drop format needs an actual path, so this must complete before copying.
/// </summary>
public async Task<string> EnsureLocalPathAsync(CancellationToken ct = default)
{
if (_localPath is not null) return _localPath;
_localPath = await _cache!.GetOrDownloadAsync(RemoteResult!, ct);
return _localPath;
}
public async Task LoadThumbnailAsync(CancellationToken ct = default)
{
try
{
if (IsLocal)
{
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)
{
// Superseded by a newer search - drop it silently, the tile is already discarded.
}
catch (Exception)
{
// A single unreadable/failed thumbnail shouldn't blank the whole grid; the tile just
// 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,10 +10,28 @@ namespace EbbesMemeClipboard.ViewModels;
public partial class PickerViewModel : ObservableObject
{
// 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;
private readonly IClipboardService _clipboard;
private readonly ISettingsService _settings;
private readonly IAutoPasteService _autoPaste;
private readonly IGifCacheService _gifCache;
private readonly IFavoritesService _favorites;
private readonly IReadOnlyList<IGifProvider> _providers;
/// <summary>Cancels the in-flight remote search when a newer keystroke supersedes it.</summary>
private CancellationTokenSource? _searchCts;
private int _remoteOffset;
private bool _remoteHasMore;
private bool _isLoadingMore;
/// <summary>True when the grid is showing a single item resolved from a pasted link.</summary>
private bool _showingResolvedLink;
public ObservableCollection<MemeTileViewModel> Items { get; } = new();
@@ -22,30 +41,120 @@ public partial class PickerViewModel : ObservableObject
[ObservableProperty]
private string _statusMessage = string.Empty;
/// <summary>
/// True while a modal dialog (currently: the "add memes" file picker) opened from within the
/// picker is showing. The window's Deactivated handler checks this so opening that dialog
/// doesn't get treated as "user clicked away" and hide the picker out from under it.
/// </summary>
[ObservableProperty]
private bool _isBusy;
[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 _showingFavorites;
[ObservableProperty]
private MemeSource _activeSource = MemeSource.Local;
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(
ILocalMemeLibraryService library,
IClipboardService clipboard,
ISettingsService settings,
IAutoPasteService autoPaste)
IAutoPasteService autoPaste,
IGifCacheService gifCache,
IFavoritesService favorites,
IEnumerable<IGifProvider> providers)
{
_library = library;
_clipboard = clipboard;
_settings = settings;
_autoPaste = autoPaste;
RefreshItems();
_gifCache = gifCache;
_favorites = favorites;
_providers = providers.ToList();
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();
}
partial void OnSearchTextChanged(string value) => RefreshItems();
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;
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)
@@ -54,7 +163,12 @@ public partial class PickerViewModel : ObservableObject
try
{
await _clipboard.CopyLocalFileAsync(tile.FullPath);
IsBusy = true;
// For remote results this downloads and caches the full-quality GIF first; the
// clipboard's file-drop format needs a real file on disk.
var path = await tile.EnsureLocalPathAsync();
await _clipboard.CopyLocalFileAsync(path);
StatusMessage = $"Copied {tile.DisplayName}";
RequestClose?.Invoke(this, EventArgs.Empty);
@@ -66,21 +180,58 @@ public partial class PickerViewModel : ObservableObject
}
catch (Exception ex)
{
// Deliberately broad: CommunityToolkit's AsyncRelayCommand otherwise swallows any
// exception here silently (no crash, no message, the click just appears to do
// nothing), which makes clipboard/paste failures impossible to diagnose from the UI.
// Deliberately broad: AsyncRelayCommand otherwise swallows exceptions silently, so
// a clipboard/download failure would look like the click simply did nothing.
StatusMessage = $"Couldn't insert {tile.DisplayName}: {ex.Message}";
}
finally
{
IsBusy = false;
}
}
[RelayCommand]
private async Task ToggleFavoriteAsync(MemeTileViewModel? tile)
{
if (tile is null) return;
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
{
await _favorites.AddAsync(new FavoriteRecord
{
Source = tile.Source,
Key = tile.FavoriteKey,
Title = tile.DisplayName,
PreviewUrl = tile.RemoteResult?.PreviewUrl,
FullUrl = tile.RemoteResult?.FullUrl,
DateAdded = DateTimeOffset.Now,
});
tile.IsFavorite = true;
StatusMessage = $"Added {tile.DisplayName} to favourites";
}
}
[RelayCommand]
private async Task RemoveItemAsync(MemeTileViewModel? tile)
{
if (tile is null) return;
if (tile?.Record is null) return;
var name = tile.DisplayName;
await _library.RemoveAsync(tile.Record);
RefreshItems();
StatusMessage = $"Removed {tile.DisplayName}";
// Drop the matching favourite too, otherwise it lingers pointing at a deleted file.
await _favorites.RemoveAsync(MemeSource.Local, tile.Record.Id);
await RefreshAsync();
StatusMessage = $"Removed {name}";
}
[RelayCommand]
@@ -107,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
@@ -120,17 +320,184 @@ public partial class PickerViewModel : ObservableObject
}
var imported = await _library.ImportAsync(candidates);
RefreshItems();
ShowingFavorites = false;
ActiveSource = MemeSource.Local;
RefreshLocalItems();
StatusMessage = $"Added {imported.Count} meme(s).";
}
public void OnShown()
{
SearchText = string.Empty;
RefreshItems();
// Rebuild rather than just reset: picks up memes added since last time, and lets a
// changed "animate GIF previews" setting take effect.
_ = RefreshAsync();
}
private void RefreshItems()
/// <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
{
var results = await provider.SearchAsync(queryAtStart, RemoteResultLimit, _remoteOffset, CancellationToken.None);
// The query may have changed while this page was in flight; if so the newer search
// owns the grid and these results are stale.
if (!string.Equals(queryAtStart, SearchText, StringComparison.Ordinal)) return;
foreach (var result in results)
{
AddRemoteTile(result);
}
_remoteOffset += results.Count;
_remoteHasMore = results.Count >= RemoteResultLimit;
StatusMessage = $"{Items.Count} result(s).";
}
catch (Exception ex)
{
StatusMessage = $"Couldn't load more: {ex.Message}";
_remoteHasMore = false;
}
finally
{
_isLoadingMore = false;
}
}
private async Task RefreshAsync()
{
// 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 = ActiveProvider;
if (provider is null) return;
if (!provider.IsConfigured)
{
Items.Clear();
StatusMessage = provider.NotConfiguredMessage;
return;
}
var cts = new CancellationTokenSource();
_searchCts = cts;
try
{
// Debounce: without this every keystroke fires an API call, which burns through a
// free-tier rate limit almost immediately.
await Task.Delay(SearchDebounce, cts.Token);
IsBusy = true;
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)
{
AddRemoteTile(result, cts.Token);
}
_remoteOffset = results.Count;
_remoteHasMore = results.Count >= RemoteResultLimit;
StatusMessage = results.Count == 0 ? "No results." : $"{results.Count} result(s).";
}
catch (OperationCanceledException)
{
// Superseded by a newer query - the newer one owns the UI state now.
}
catch (Exception ex)
{
StatusMessage = $"Search failed: {ex.Message}";
}
finally
{
if (_searchCts == cts)
{
IsBusy = false;
}
}
}
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)
? _library.GetAll()
@@ -139,9 +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);
}
StatusMessage = string.Empty;
}
/// <summary>Favourites for the active source, narrowed by the search box like any other view.</summary>
private void RefreshFavoriteItems()
{
Items.Clear();
var query = SearchText;
foreach (var fav in _favorites.GetForSource(ActiveSource))
{
if (fav.Source == MemeSource.Local)
{
var record = _library.GetAll().FirstOrDefault(r => r.Id == fav.Key);
// Favourite pointing at a meme that has since been deleted - skip it rather
// than rendering a broken tile.
if (record is null) continue;
if (!Matches(record.OriginalFileName, query)) continue;
AddLocalTile(record);
}
else
{
if (fav.PreviewUrl is null || fav.FullUrl is null) continue;
if (!Matches(fav.Title ?? string.Empty, query)) continue;
AddRemoteTile(new GifSearchResult
{
Id = fav.Key,
Title = fav.Title ?? "Favourite",
PreviewUrl = fav.PreviewUrl,
FullUrl = fav.FullUrl,
Source = fav.Source,
});
}
}
StatusMessage = Items.Count == 0 ? string.Empty : $"{Items.Count} favourite(s).";
}
private static bool Matches(string text, string query) =>
string.IsNullOrWhiteSpace(query) || text.Contains(query, StringComparison.OrdinalIgnoreCase);
}
@@ -9,6 +9,7 @@ public partial class SettingsViewModel : ObservableObject
{
private readonly ISettingsService _settings;
private readonly IGlobalHotkeyService _hotkeyService;
private readonly IAutostartService _autostart;
[ObservableProperty]
private string _hotkeyDisplay = string.Empty;
@@ -19,6 +20,15 @@ public partial class SettingsViewModel : ObservableObject
[ObservableProperty]
private InsertMode _insertMode;
[ObservableProperty]
private bool _autostartEnabled;
[ObservableProperty]
private string _giphyApiKey = string.Empty;
[ObservableProperty]
private bool _playGifPreviews;
public bool IsCopyOnly
{
get => InsertMode == InsertMode.CopyOnly;
@@ -37,12 +47,19 @@ public partial class SettingsViewModel : ObservableObject
set { if (value) InsertMode = InsertMode.PasteAndSend; }
}
public SettingsViewModel(ISettingsService settings, IGlobalHotkeyService hotkeyService)
public SettingsViewModel(ISettingsService settings, IGlobalHotkeyService hotkeyService, IAutostartService autostart)
{
_settings = settings;
_hotkeyService = hotkeyService;
_autostart = autostart;
_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.
_autostartEnabled = _autostart.IsEnabled;
}
partial void OnInsertModeChanged(InsertMode value)
@@ -54,6 +71,23 @@ public partial class SettingsViewModel : ObservableObject
OnPropertyChanged(nameof(IsPasteAndSend));
}
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();
_ = _settings.SaveAsync();
}
/// <summary>Called when the settings window is shown, to re-sync externally-changed state.</summary>
public void Refresh() => AutostartEnabled = _autostart.IsEnabled;
public void TrySetHotkey(ModifierKeys modifiers, Key key)
{
if (_hotkeyService.Register(modifiers, key))
+157 -47
View File
@@ -3,6 +3,8 @@
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
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"
@@ -20,6 +22,8 @@
ContextMenuOpening="PickerWindow_OnContextMenuOpening"
ContextMenuClosing="PickerWindow_OnContextMenuClosing">
<Window.Resources>
<BooleanToVisibilityConverter x:Key="BoolToVisibility"/>
<Style x:Key="FlatButtonStyle" TargetType="Button">
<Setter Property="Background" Value="#2B2D31"/>
<Setter Property="Foreground" Value="White"/>
@@ -43,90 +47,196 @@
</Setter.Value>
</Setter>
</Style>
<!-- Tag carries "is this tab active", so one style can drive both tab buttons. -->
<Style x:Key="TabButtonStyle" TargetType="Button">
<Setter Property="Foreground" Value="#9A9CA3"/>
<Setter Property="Background" Value="Transparent"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="Padding" Value="14,6"/>
<Setter Property="FontSize" Value="13"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border x:Name="Bd" Background="{TemplateBinding Background}" CornerRadius="6" SnapsToDevicePixels="True">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center" Margin="{TemplateBinding Padding}"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="Background" Value="#2B2D31"/>
</Trigger>
<DataTrigger Binding="{Binding Tag, RelativeSource={RelativeSource Self}}" Value="True">
<Setter TargetName="Bd" Property="Background" Value="#3A3D42"/>
<Setter Property="Foreground" Value="White"/>
<Setter Property="FontWeight" Value="SemiBold"/>
</DataTrigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- 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 Height="118" Margin="4">
<Button Padding="0"
Style="{StaticResource FlatButtonStyle}"
Command="{Binding DataContext.SelectItemCommand, ElementName=RootWindow}"
CommandParameter="{Binding}"
Tag="{Binding DataContext, ElementName=RootWindow}"
ToolTip="{Binding DisplayName}">
<Button.ContextMenu>
<ContextMenu>
<MenuItem Header="{Binding PlacementTarget.DataContext.FavoriteActionLabel, RelativeSource={RelativeSource AncestorType=ContextMenu}}"
Command="{Binding PlacementTarget.Tag.ToggleFavoriteCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}"
CommandParameter="{Binding PlacementTarget.DataContext, RelativeSource={RelativeSource AncestorType=ContextMenu}}"/>
<Separator/>
<MenuItem Header="Remove"
IsEnabled="{Binding PlacementTarget.DataContext.IsLocal, RelativeSource={RelativeSource AncestorType=ContextMenu}}"
Command="{Binding PlacementTarget.Tag.RemoveItemCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}"
CommandParameter="{Binding PlacementTarget.DataContext, RelativeSource={RelativeSource AncestorType=ContextMenu}}"/>
</ContextMenu>
</Button.ContextMenu>
<!-- 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. -->
<TextBlock Text="★" FontSize="13" Foreground="#FFC72C"
HorizontalAlignment="Right" VerticalAlignment="Top"
Margin="0,4,6,0" IsHitTestVisible="False"
Visibility="{Binding IsFavorite, Converter={StaticResource BoolToVisibility}}"/>
</Grid>
</DataTemplate>
</Window.Resources>
<Border Background="#F0202225" CornerRadius="10" BorderBrush="#3A3B3E" BorderThickness="1">
<Grid Margin="12">
<Grid Margin="12,8,12,12">
<Grid.RowDefinitions>
<RowDefinition Height="16"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<Border Grid.Row="0" Background="Transparent" Cursor="SizeAll"
<!-- Title bar: doubles as the drag surface. -->
<Border Grid.Row="0" Background="Transparent" Cursor="SizeAll" Height="26"
MouseLeftButtonDown="DragHandle_OnMouseLeftButtonDown"
ToolTip="Drag to move">
<Border Width="36" Height="4" CornerRadius="2" Background="#4A4C52"
HorizontalAlignment="Center" VerticalAlignment="Center"/>
<Grid>
<TextBlock Text="Meme Clipboard" Foreground="White" FontSize="13"
FontWeight="SemiBold" VerticalAlignment="Center"
HorizontalAlignment="Left"/>
<Border Width="36" Height="4" CornerRadius="2" Background="#4A4C52"
HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Grid>
</Border>
<Grid Grid.Row="1" Margin="0,4,0,10">
<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 ShowingFavorites}"
Command="{Binding ToggleFavoritesViewCommand}"
ToolTip="Show only your favourites from this source"/>
</Grid>
<Grid Grid.Row="2" Margin="0,0,0,10">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<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"/>
<Grid>
<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>
<DataTrigger Binding="{Binding SearchText}" Value="">
<Setter Property="Visibility" Value="Visible"/>
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
</Grid>
</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 Grid.Row="2" VerticalScrollBarVisibility="Auto">
<ScrollViewer x:Name="ResultsScrollViewer"
Grid.Row="3" VerticalScrollBarVisibility="Auto" Padding="0,0,4,0"
ScrollChanged="ResultsScrollViewer_OnScrollChanged">
<Grid>
<TextBlock Text="No memes yet. Click + or drag files in here."
<TextBlock Text="{Binding EmptyStateText}"
Foreground="#7B7D85" FontSize="13"
HorizontalAlignment="Center" VerticalAlignment="Center"
TextWrapping="Wrap" TextAlignment="Center" Width="260">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Setter Property="Visibility" Value="Collapsed"/>
<Style.Triggers>
<DataTrigger Binding="{Binding Items.Count}" Value="0">
<Setter Property="Visibility" Value="Visible"/>
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
TextWrapping="Wrap" TextAlignment="Center" Width="260"
Margin="0,40,0,0"
Visibility="{Binding ShowEmptyState, Converter={StaticResource BoolToVisibility}}"/>
<ItemsControl ItemsSource="{Binding Items}">
<ItemsControl ItemsSource="{Binding Items}"
ItemTemplate="{StaticResource MemeTileTemplate}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel Orientation="Horizontal"/>
<UniformGrid Columns="3"/>
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate DataType="{x:Type vm:MemeTileViewModel}">
<Button Width="88" Height="88" Margin="4" Padding="0"
Style="{StaticResource FlatButtonStyle}"
Command="{Binding DataContext.SelectItemCommand, ElementName=RootWindow}"
CommandParameter="{Binding}"
Tag="{Binding DataContext, ElementName=RootWindow}"
ToolTip="{Binding DisplayName}">
<Button.ContextMenu>
<ContextMenu>
<MenuItem Header="Remove"
Command="{Binding PlacementTarget.Tag.RemoveItemCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}"
CommandParameter="{Binding PlacementTarget.DataContext, RelativeSource={RelativeSource AncestorType=ContextMenu}}"/>
</ContextMenu>
</Button.ContextMenu>
<Image Source="{Binding Thumbnail}" Stretch="Uniform" Width="80" Height="80"/>
</Button>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</Grid>
</ScrollViewer>
<TextBlock Grid.Row="3" Text="{Binding StatusMessage}" Foreground="#9A9CA3" FontSize="11" Margin="2,8,0,0"/>
<Grid Grid.Row="4" Margin="2,8,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0" Text="{Binding StatusMessage}" Foreground="#9A9CA3"
FontSize="11" TextTrimming="CharacterEllipsis" VerticalAlignment="Center"/>
<!-- Required by Giphy's API terms wherever their results are displayed. -->
<TextBlock Grid.Column="1" Text="Powered by GIPHY" Foreground="#7B7D85"
FontSize="10" FontWeight="SemiBold" VerticalAlignment="Center"
Margin="8,0,0,0"
Visibility="{Binding IsGiphySource, Converter={StaticResource BoolToVisibility}}"/>
</Grid>
</Grid>
</Border>
</Window>
@@ -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;
@@ -2,7 +2,7 @@
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Settings - Ebbe's Meme Clipboard"
Width="380" Height="380"
Width="400"
Background="#F0202225"
WindowStartupLocation="CenterScreen"
ResizeMode="NoResize"
@@ -32,5 +32,23 @@
IsChecked="{Binding IsPasteAndSend}"/>
<TextBlock Text="Presses Enter right after pasting to submit it - use with care in chat apps."
Foreground="#7B7D85" FontSize="10" TextWrapping="Wrap" Margin="20,4,0,0"/>
<TextBlock Text="Startup" Foreground="White" FontSize="14" FontWeight="Bold" Margin="0,24,0,8"/>
<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}"
Background="Transparent" Foreground="White" BorderThickness="0"
Padding="10,8" FontSize="13" CaretBrush="White"/>
</Border>
<TextBlock TextWrapping="Wrap" Foreground="#7B7D85" FontSize="11" Margin="0,4,0,0"
Text="API key for the Giphy tab. Get a free one at developers.giphy.com (create an app, choose an API key). Leave empty to disable Giphy search."/>
</StackPanel>
</Window>
@@ -16,6 +16,14 @@ public partial class SettingsWindow : Window
DataContext = _viewModel;
}
/// <summary>Re-reads live state (e.g. the autostart registry entry) each time it's opened.</summary>
public void ShowSettings()
{
_viewModel.Refresh();
Show();
Activate();
}
private void HotkeyBox_OnPreviewKeyDown(object sender, KeyEventArgs e)
{
e.Handled = true;