5 Commits
Author SHA1 Message Date
Ebbe Baß 9b0ffcccdf added description 2026-08-19 11:34:53 +02:00
Ebbe Baß b10370f8bc added install wizard 2026-08-19 10:30:41 +02:00
Ebbe Baß 6409132659 added installer output 2026-08-19 10:30:32 +02:00
Ebbe Baß b6644ebecf Added pasting new memes, fixed offsets and changed out tray icon. 2026-08-19 10:15:16 +02:00
Ebbe Baß 4c755919d4 Added Giphy support, Favourites 2026-08-14 13:55:34 +02:00
33 changed files with 1590 additions and 165 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** - Tray icon with menu: **Open / Settings / Exit**
- Popup picker: search box, thumbnail grid, drag-to-move via top handle strip, - 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 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) - Search filters by filename (case-insensitive substring)
- Right-click a tile → **Remove** (deletes to Recycle Bin, recoverable — verified) - Right-click a tile → **Remove** (deletes to Recycle Bin, recoverable — verified)
- Multi-format clipboard write (see "Clipboard formats" below) - Multi-format clipboard write (see "Clipboard formats" below)
- Three insert modes, chosen in Settings: **Copy only / Paste into active window / - Three insert modes, chosen in Settings: **Copy only / Paste into active window /
Paste and send instantly** Paste and send instantly**
- Settings window: hotkey capture (incl. Windows key), insert mode radio buttons - Settings window: hotkey capture (incl. Windows key), insert mode radio buttons,
- Consistent dark styling across all menus (tray + tile right-click) **autostart toggle**, **Giphy API key field**
- Persistence: memes and settings survive restarts - **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; **Build is clean** (0 warnings, 0 errors). Working tree was clean at last check;
`publish/` is gitignored. `publish/` is gitignored.
@@ -43,10 +52,18 @@ Everything below is **implemented and verified working by actually running the a
``` ```
%AppData%\EbbesMemeClipboard\ %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\ Library\
index.json list of LocalMemeRecord (Id, FileName, OriginalFileName, DateAdded) index.json list of LocalMemeRecord (Id, FileName, OriginalFileName, DateAdded)
<guid>.png/.jpg/.gif imported files, stored under a GUID name <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 # build
dotnet 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 \ dotnet publish src/EbbesMemeClipboard/EbbesMemeClipboard.csproj -c Release -r win-x64 \
--self-contained true -p:PublishSingleFile=true \ --self-contained true -p:PublishSingleFile=true \
-p:IncludeNativeLibrariesForSelfExtract=true -p:EnableCompressionInSingleFile=true \ -p:IncludeNativeLibrariesForSelfExtract=true -p:EnableCompressionInSingleFile=true \
-o publish -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 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 icon appears (native library self-extraction to TEMP). Not a bug. Subsequent launches are
faster. faster.
@@ -78,18 +126,26 @@ faster.
``` ```
src/EbbesMemeClipboard/ src/EbbesMemeClipboard/
App.xaml / App.xaml.cs composition root: DI container, tray icon, hotkey registration 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/ Models/
LocalMemeRecord.cs Id, FileName, OriginalFileName, DateAdded LocalMemeRecord.cs Id, FileName, OriginalFileName, DateAdded
AppSettings.cs InsertMode, HotkeyModifiers, HotkeyKey AppSettings.cs InsertMode, HotkeyModifiers, HotkeyKey, GiphyApiKey
InsertMode.cs CopyOnly | PasteIntoActiveWindow | PasteAndSend 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/ Services/
ILocalMemeLibraryService / LocalMemeLibraryService import/search/remove, index.json ILocalMemeLibraryService / LocalMemeLibraryService import/search/remove, index.json
IClipboardService / ClipboardService multi-format clipboard write IClipboardService / ClipboardService multi-format clipboard write
IGlobalHotkeyService / GlobalHotkeyService RegisterHotKey + message-only HwndSource IGlobalHotkeyService / GlobalHotkeyService RegisterHotKey + message-only HwndSource
ISettingsService / SettingsService System.Text.Json persistence ISettingsService / SettingsService System.Text.Json persistence
IAutoPasteService / AutoPasteService foreground restore + SendInput paste 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 HotkeyFormatter.cs "Ctrl + Alt + M" display strings
Native/ Native/
NativeMethods.cs P/Invoke declarations 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_HDROP` (file drop list) | Discord/Slack/Teams/Explorer paste the real file — preserves GIF animation |
| `CF_DIB` (`SetImage`) | Bitmap-only apps like Paint | | `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 | | `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: Two traps here:
- WPF does **NOT** auto-wrap `DataFormats.Html`. The `Version/StartHTML/EndHTML/ - WPF does **NOT** auto-wrap `DataFormats.Html`. The `Version/StartHTML/EndHTML/
StartFragment/EndFragment` header with exact **byte** offsets must be built by hand 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 for a name containing "Meme Clipboard". This finds it even when tucked in the overflow
area, unlike screenshots. area, unlike screenshots.
Clean up after testing: kill `EbbesMemeClipboard` processes and remove Clean up after testing: kill `EbbesMemeClipboard` processes, remove
`%AppData%\EbbesMemeClipboard` so seeded test data doesn't leak into real use. `%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 - `GET https://api.giphy.com/v1/gifs/search?api_key={key}&q={q}&limit={n}&rating=pg-13`
Straightforward. Write `HKCU\Software\Microsoft\Windows\CurrentVersion\Run`. - Empty query falls back to `/trending` with the same parameters.
**Important**: use `Environment.ProcessPath`, **not** `Assembly.Location` — the latter - Response: `data[].images.{fixed_width,original}.url` — `fixed_width` for grid
returns an empty string in single-file published mode. Add an `IAutostartService` + thumbnails, `original` for the full-quality animated file that gets pasted.
a checkbox in `SettingsWindow`. - **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
### 2. Tab/dropdown for meme sources (Local, Giphy) instead of failing at request time (`IGifProvider.IsConfigured`).
UI shell for multiple sources, plus the Giphy integration behind it. - Searches are **debounced 350ms** and cancel the previous in-flight request — without
that, every keystroke burns a request against the free rate limit.
**Critical context — Tenor is dead.** The user originally asked for Tenor; research during - Giphy's ToS **requires the visible "Powered by GIPHY" badge**, rendered in the picker
planning confirmed Google **shut down the public Tenor API on 2026-06-30** (corroborated by footer whenever the Giphy tab is active. Don't remove it.
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.
- Klipy (`api.klipy.com`) is a viable alternative if Giphy becomes a problem — founded by - 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" **Untested by the assistant**: actual Giphy search results, remote thumbnail loading, and
**⚠️ STILL AMBIGUOUS — ASK THE USER BEFORE BUILDING.** download-on-select were never exercised against the live API, because no API key was
The app *already* stores memes centrally in `%AppData%\EbbesMemeClipboard\Library`. When available. The code paths are written but unverified end-to-end — if something misbehaves
asked to clarify, the user selected "Something else" but the conversation moved on before once a key is entered, start with `GiphyGifProvider.SearchAsync` (JSON field names) and
they explained. Options offered (and rejected) were: (a) save Giphy results into the local `GifCacheService.GetOrDownloadAsync`.
library, (b) configurable library folder location. So it's something other than those two —
**get a concrete description first.**
### 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):** ## Pending requests
`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.
**Suggested fix**: skip the `"PNG"` format when the file is a GIF, so Chromium falls back to ### ⚠️ 1. "Central store specific to the application for all local memes" — STILL AMBIGUOUS
`CF_HTML` (which already carries the full animated `data:image/gif;base64,...` URI) or **Ask the user before building.** The app *already* stores memes centrally in
`CF_HDROP`. `ImageDecoding.IsGif()` already exists for the check. Keep `CF_DIB` so Paint `%AppData%\EbbesMemeClipboard\Library`. When asked to clarify, the user chose "Something
still gets something. Test in both Teams and Paint, since these two want opposite things. 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): ### 2. Paste-send reliability ("Teams is sometimes too slow")
`ImageDecoding.DecodeFirstFrame` **ignores `decodePixelWidth` for GIFs** — it returns the Not yet addressed. Current delays in `AutoPasteService`: `FocusSettleDelay` 200ms (before
`GifBitmapDecoder` frame before reaching the `DecodePixelWidth` logic. So GIF thumbnails Ctrl+V), `PasteSettleDelay` 400ms (before Enter). Both are fixed sleeps, so a slow app will
decode at full resolution while static images are capped at 150px. A library with many still occasionally miss.
large GIFs will use more memory than it should.
### 5. Paste-send reliability ("Teams is sometimes too slow") Bumping the constants is the cheap fix but slows every paste for everyone. Better: **poll
Current delays in `AutoPasteService`: `FocusSettleDelay` 200ms (before Ctrl+V), for readiness instead of guessing** — verify the target window is genuinely foreground
`PasteSettleDelay` 400ms (before Enter). Both are fixed sleeps — a slow app will still miss. 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 ### 3. Possible follow-ups (not requested, just noted)
better approach is to **poll for readiness instead of guessing**: e.g. verify the target - **Single-instance protection** (named Mutex + named pipe). Currently two instances fight
window is actually foreground before sending Ctrl+V, and for the send step consider over the global hotkey; this caused a confusing phantom "regression" during development.
retrying/confirming rather than firing Enter blind. Note the failure mode already seen: - **`WPF-UI` is referenced but unused** — intended for Mica/Fluent polish. Use it or drop it.
if Enter fires when the paste didn't land, **Teams sends an empty message** — so the Enter - **Animated GIF previews on hover** (currently static first frame only) — `GifFramePlayer`
step should be conservative, since a wrong guess posts to a real chat. 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`. chat apps. Default insert mode is `PasteIntoActiveWindow`.
- No Settings-window dark title bar (content is dark, title bar is standard OS chrome). - 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. 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) ## Known limitations (inherent, not bugs)
+5
View File
@@ -17,7 +17,12 @@ bin/
obj/ obj/
*.user *.user
publish/ publish/
publish-installer/
# Compiled installer output (the .iss script itself IS tracked)
installer/output/
# Local app data seeded during manual testing # Local app data seeded during manual testing
diag.log diag.log
/installer/output/
+138 -1
View File
@@ -1,2 +1,139 @@
# 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).
- **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.
## 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.0"
#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>
<ResourceDictionary.MergedDictionaries> <ResourceDictionary.MergedDictionaries>
<ResourceDictionary Source="Styles/DarkMenuStyles.xaml"/> <ResourceDictionary Source="Styles/DarkMenuStyles.xaml"/>
<ResourceDictionary Source="Styles/DarkScrollBarStyles.xaml"/>
</ResourceDictionary.MergedDictionaries> </ResourceDictionary.MergedDictionaries>
<tb:TaskbarIcon x:Key="TrayIcon" <tb:TaskbarIcon x:Key="TrayIcon"
+9 -5
View File
@@ -1,3 +1,4 @@
using System.Net.Http;
using System.Windows; using System.Windows;
using EbbesMemeClipboard.Services; using EbbesMemeClipboard.Services;
using EbbesMemeClipboard.ViewModels; using EbbesMemeClipboard.ViewModels;
@@ -27,6 +28,13 @@ public partial class App : Application
services.AddSingleton<IGlobalHotkeyService, GlobalHotkeyService>(); services.AddSingleton<IGlobalHotkeyService, GlobalHotkeyService>();
services.AddSingleton<ISettingsService, SettingsService>(); services.AddSingleton<ISettingsService, SettingsService>();
services.AddSingleton<IAutoPasteService, AutoPasteService>(); 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<PickerViewModel>();
services.AddSingleton<PickerWindow>(); services.AddSingleton<PickerWindow>();
services.AddSingleton<SettingsViewModel>(); services.AddSingleton<SettingsViewModel>();
@@ -72,11 +80,7 @@ public partial class App : Application
private void TrayOpenPicker_Click(object sender, RoutedEventArgs e) => _pickerWindow.ShowNearCursor(); private void TrayOpenPicker_Click(object sender, RoutedEventArgs e) => _pickerWindow.ShowNearCursor();
private void TraySettings_Click(object sender, RoutedEventArgs e) private void TraySettings_Click(object sender, RoutedEventArgs e) => _settingsWindow.ShowSettings();
{
_settingsWindow.Show();
_settingsWindow.Activate();
}
private void TrayExit_Click(object sender, RoutedEventArgs e) => Shutdown(); 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

@@ -7,4 +7,11 @@ public sealed class AppSettings
public InsertMode InsertMode { get; set; } = InsertMode.PasteIntoActiveWindow; public InsertMode InsertMode { get; set; } = InsertMode.PasteIntoActiveWindow;
public ModifierKeys HotkeyModifiers { get; set; } = ModifierKeys.Control | ModifierKeys.Alt; public ModifierKeys HotkeyModifiers { get; set; } = ModifierKeys.Control | ModifierKeys.Alt;
public Key HotkeyKey { get; set; } = Key.M; 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; }
} }
@@ -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.IO;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
using System.Text; using System.Text;
using System.Text.RegularExpressions;
using System.Windows; using System.Windows;
using System.Windows.Media.Imaging; using System.Windows.Media.Imaging;
using EbbesMemeClipboard.Models;
namespace EbbesMemeClipboard.Services; namespace EbbesMemeClipboard.Services;
public sealed class ClipboardService : IClipboardService public sealed partial class ClipboardService : IClipboardService
{ {
public async Task CopyLocalFileAsync(string filePath) 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 // 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 // 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. // them can silently produce nothing even though CF_DIB is present and perfectly valid.
using var pngStream = new MemoryStream(); //
var pngEncoder = new PngBitmapEncoder(); // Deliberately skipped for GIFs: a PNG can only ever hold one static frame, and since
pngEncoder.Frames.Add(BitmapFrame.Create(firstFrame)); // Chromium PREFERS this format over all others, offering it for a GIF is what makes
pngEncoder.Save(pngStream); // animated GIFs paste as a still first frame. Omitting it lets those targets fall
pngStream.Position = 0; // through to CF_HTML below, which carries the full animated data URI.
data.SetData("PNG", pngStream); 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 // 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. // these formats that preserves animation for them, for GIFs.
@@ -52,6 +62,86 @@ public sealed class ClipboardService : IClipboardService
_ => "application/octet-stream", _ => "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) private static async Task SetClipboardWithRetryAsync(DataObject data)
{ {
const int maxAttempts = 5; 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,79 @@
using System.Net.Http;
using System.Net.Http.Json;
using System.Text.Json.Serialization;
using EbbesMemeClipboard.Models;
namespace EbbesMemeClipboard.Services;
public sealed 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 GiphyGifProvider(HttpClient http, ISettingsService settings)
{
_http = http;
_settings = settings;
}
public async Task<IReadOnlyList<GifSearchResult>> SearchAsync(string query, int limit, CancellationToken ct)
{
if (!IsConfigured) return Array.Empty<GifSearchResult>();
var apiKey = Uri.EscapeDataString(_settings.Current.GiphyApiKey!);
var url = string.IsNullOrWhiteSpace(query)
? $"{BaseUrl}/trending?api_key={apiKey}&limit={limit}&rating={Rating}"
: $"{BaseUrl}/search?api_key={apiKey}&q={Uri.EscapeDataString(query)}&limit={limit}&rating={Rating}";
var response = await _http.GetAsync(url, ct);
response.EnsureSuccessStatusCode();
var payload = await response.Content.ReadFromJsonAsync<GiphyResponse>(cancellationToken: ct);
if (payload?.Data is null) return Array.Empty<GifSearchResult>();
return payload.Data
.Where(item => item.Images?.FixedWidth?.Url is not null && item.Images.Original?.Url is not null)
.Select(item => new GifSearchResult
{
Id = item.Id ?? Guid.NewGuid().ToString("N"),
Title = string.IsNullOrWhiteSpace(item.Title) ? "Giphy GIF" : item.Title,
PreviewUrl = item.Images!.FixedWidth!.Url!,
FullUrl = item.Images.Original!.Url!,
Source = MemeSource.Giphy,
})
.ToList();
}
private sealed class GiphyResponse
{
[JsonPropertyName("data")] public List<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; namespace EbbesMemeClipboard.Services;
public interface IClipboardService public interface IClipboardService
@@ -8,4 +10,10 @@ public interface IClipboardService
/// useful, and animated GIFs survive as animations wherever possible. /// useful, and animated GIFs survive as animations wherever possible.
/// </summary> /// </summary>
Task CopyLocalFileAsync(string filePath); 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,17 @@
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>An empty query returns whatever the provider considers trending.</summary>
Task<IReadOnlyList<GifSearchResult>> SearchAsync(string query, int limit, CancellationToken ct);
}
@@ -8,5 +8,9 @@ public interface ILocalMemeLibraryService
IReadOnlyList<LocalMemeRecord> Search(string query); IReadOnlyList<LocalMemeRecord> Search(string query);
string GetFullPath(LocalMemeRecord record); string GetFullPath(LocalMemeRecord record);
Task<IReadOnlyList<LocalMemeRecord>> ImportAsync(IEnumerable<string> sourceFilePaths); 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); Task RemoveAsync(LocalMemeRecord record);
} }
@@ -1,4 +1,5 @@
using System.IO; using System.IO;
using System.Windows.Media;
using System.Windows.Media.Imaging; using System.Windows.Media.Imaging;
namespace EbbesMemeClipboard.Services; namespace EbbesMemeClipboard.Services;
@@ -9,7 +10,7 @@ internal static class ImageDecoding
Path.GetExtension(path).Equals(".gif", StringComparison.OrdinalIgnoreCase); Path.GetExtension(path).Equals(".gif", StringComparison.OrdinalIgnoreCase);
/// <summary> /// <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 /// animation is never touched). The result is frozen so it can be handed back across
/// threads safely when called from a background thread. /// threads safely when called from a background thread.
/// </summary> /// </summary>
@@ -18,9 +19,7 @@ internal static class ImageDecoding
if (IsGif(path)) if (IsGif(path))
{ {
var decoder = new GifBitmapDecoder(new Uri(path), BitmapCreateOptions.PreservePixelFormat, BitmapCacheOption.OnLoad); var decoder = new GifBitmapDecoder(new Uri(path), BitmapCreateOptions.PreservePixelFormat, BitmapCacheOption.OnLoad);
var frame = decoder.Frames[0]; return Finalize(decoder.Frames[0], decodePixelWidth);
frame.Freeze();
return frame;
} }
var bitmap = new BitmapImage(); var bitmap = new BitmapImage();
@@ -35,4 +34,31 @@ internal static class ImageDecoding
bitmap.Freeze(); bitmap.Freeze();
return bitmap; 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; 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) public async Task RemoveAsync(LocalMemeRecord record)
{ {
var path = GetFullPath(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>
@@ -5,26 +5,87 @@ using EbbesMemeClipboard.Services;
namespace EbbesMemeClipboard.ViewModels; 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 public partial class MemeTileViewModel : ObservableObject
{ {
private const int ThumbnailPixelWidth = 150; // Comfortably above the ~126px logical tile width so thumbnails stay sharp at 150-200%
// display scaling, without decoding anything near full resolution.
private const int ThumbnailPixelWidth = 240;
public LocalMemeRecord Record { get; } private readonly IGifCacheService? _cache;
public string FullPath { get; } private string? _localPath;
public string DisplayName => Record.OriginalFileName;
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] [ObservableProperty]
private BitmapSource? _thumbnail; private BitmapSource? _thumbnail;
[ObservableProperty]
private bool _isFavorite;
public string FavoriteActionLabel => IsFavorite ? "Remove from favourites" : "Add to favourites";
partial void OnIsFavoriteChanged(bool value) => OnPropertyChanged(nameof(FavoriteActionLabel));
public MemeTileViewModel(LocalMemeRecord record, string fullPath) public MemeTileViewModel(LocalMemeRecord record, string fullPath)
{ {
Record = record; Record = record;
FullPath = fullPath; _localPath = fullPath;
} }
public async Task LoadThumbnailAsync() public MemeTileViewModel(GifSearchResult remoteResult, IGifCacheService cache)
{ {
var path = FullPath; RemoteResult = remoteResult;
Thumbnail = await Task.Run(() => ImageDecoding.DecodeFirstFrame(path, ThumbnailPixelWidth)); _cache = cache;
}
/// <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);
}
else
{
var bytes = await _cache!.DownloadPreviewAsync(RemoteResult!, ct);
Thumbnail = await Task.Run(() => ImageDecoding.DecodeFirstFrameFromBytes(bytes, ThumbnailPixelWidth), ct);
}
}
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.
}
} }
} }
@@ -9,43 +9,84 @@ namespace EbbesMemeClipboard.ViewModels;
public partial class PickerViewModel : ObservableObject public partial class PickerViewModel : ObservableObject
{ {
private const int RemoteResultLimit = 30;
private static readonly TimeSpan SearchDebounce = TimeSpan.FromMilliseconds(350);
private readonly ILocalMemeLibraryService _library; private readonly ILocalMemeLibraryService _library;
private readonly IClipboardService _clipboard; private readonly IClipboardService _clipboard;
private readonly ISettingsService _settings; private readonly ISettingsService _settings;
private readonly IAutoPasteService _autoPaste; 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;
public ObservableCollection<MemeTileViewModel> Items { get; } = new(); public ObservableCollection<MemeTileViewModel> Items { get; } = new();
/// <summary>Favourites for the currently active source only.</summary>
public ObservableCollection<MemeTileViewModel> Favorites { get; } = new();
[ObservableProperty] [ObservableProperty]
private string _searchText = string.Empty; private string _searchText = string.Empty;
[ObservableProperty] [ObservableProperty]
private string _statusMessage = string.Empty; private string _statusMessage = string.Empty;
/// <summary> [ObservableProperty]
/// True while a modal dialog (currently: the "add memes" file picker) opened from within the private bool _isBusy;
/// 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] [ObservableProperty]
private bool _isDialogOpen; private bool _isDialogOpen;
[ObservableProperty]
private bool _hasFavorites;
[ObservableProperty]
private MemeSource _activeSource = MemeSource.Local;
public bool IsLocalSource => ActiveSource == MemeSource.Local;
public bool IsGiphySource => ActiveSource == MemeSource.Giphy;
public event EventHandler? RequestClose; public event EventHandler? RequestClose;
public PickerViewModel( public PickerViewModel(
ILocalMemeLibraryService library, ILocalMemeLibraryService library,
IClipboardService clipboard, IClipboardService clipboard,
ISettingsService settings, ISettingsService settings,
IAutoPasteService autoPaste) IAutoPasteService autoPaste,
IGifCacheService gifCache,
IFavoritesService favorites,
IEnumerable<IGifProvider> providers)
{ {
_library = library; _library = library;
_clipboard = clipboard; _clipboard = clipboard;
_settings = settings; _settings = settings;
_autoPaste = autoPaste; _autoPaste = autoPaste;
RefreshItems(); _gifCache = gifCache;
_favorites = favorites;
_providers = providers.ToList();
RefreshLocalItems();
RefreshFavorites();
} }
partial void OnSearchTextChanged(string value) => RefreshItems(); partial void OnSearchTextChanged(string value) => _ = RefreshAsync();
partial void OnActiveSourceChanged(MemeSource value)
{
OnPropertyChanged(nameof(IsLocalSource));
OnPropertyChanged(nameof(IsGiphySource));
SearchText = string.Empty;
RefreshFavorites();
// 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();
}
[RelayCommand]
private void SelectSource(MemeSource source) => ActiveSource = source;
[RelayCommand] [RelayCommand]
private async Task SelectItemAsync(MemeTileViewModel? tile) private async Task SelectItemAsync(MemeTileViewModel? tile)
@@ -54,7 +95,12 @@ public partial class PickerViewModel : ObservableObject
try 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}"; StatusMessage = $"Copied {tile.DisplayName}";
RequestClose?.Invoke(this, EventArgs.Empty); RequestClose?.Invoke(this, EventArgs.Empty);
@@ -66,20 +112,55 @@ public partial class PickerViewModel : ObservableObject
} }
catch (Exception ex) catch (Exception ex)
{ {
// Deliberately broad: CommunityToolkit's AsyncRelayCommand otherwise swallows any // Deliberately broad: AsyncRelayCommand otherwise swallows exceptions silently, so
// exception here silently (no crash, no message, the click just appears to do // a clipboard/download failure would look like the click simply did nothing.
// nothing), which makes clipboard/paste failures impossible to diagnose from the UI.
StatusMessage = $"Couldn't insert {tile.DisplayName}: {ex.Message}"; 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);
StatusMessage = $"Removed {tile.DisplayName} from favourites";
}
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,
});
StatusMessage = $"Added {tile.DisplayName} to favourites";
}
RefreshFavorites();
SyncFavoriteFlags();
} }
[RelayCommand] [RelayCommand]
private async Task RemoveItemAsync(MemeTileViewModel? tile) private async Task RemoveItemAsync(MemeTileViewModel? tile)
{ {
if (tile is null) return; if (tile?.Record is null) return;
await _library.RemoveAsync(tile.Record); await _library.RemoveAsync(tile.Record);
RefreshItems(); // Drop the matching favourite too, otherwise it lingers pointing at a deleted file.
await _favorites.RemoveAsync(MemeSource.Local, tile.Record.Id);
RefreshLocalItems();
RefreshFavorites();
StatusMessage = $"Removed {tile.DisplayName}"; StatusMessage = $"Removed {tile.DisplayName}";
} }
@@ -107,6 +188,54 @@ 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;
}
ActiveSource = MemeSource.Local;
RefreshLocalItems();
StatusMessage = $"Pasted {name}";
}
catch (Exception ex)
{
StatusMessage = $"Couldn't paste image: {ex.Message}";
}
}
public async Task ImportPathsAsync(IEnumerable<string> paths) public async Task ImportPathsAsync(IEnumerable<string> paths)
{ {
var candidates = paths var candidates = paths
@@ -120,17 +249,88 @@ public partial class PickerViewModel : ObservableObject
} }
var imported = await _library.ImportAsync(candidates); var imported = await _library.ImportAsync(candidates);
RefreshItems(); ActiveSource = MemeSource.Local;
RefreshLocalItems();
StatusMessage = $"Added {imported.Count} meme(s)."; StatusMessage = $"Added {imported.Count} meme(s).";
} }
public void OnShown() public void OnShown()
{ {
SearchText = string.Empty; SearchText = string.Empty;
RefreshItems(); if (IsLocalSource)
{
RefreshLocalItems();
}
RefreshFavorites();
} }
private void RefreshItems() private async Task RefreshAsync()
{
// Any pending remote search is stale the moment the query or tab changes.
_searchCts?.Cancel();
_searchCts?.Dispose();
_searchCts = null;
if (IsLocalSource)
{
RefreshLocalItems();
return;
}
var provider = _providers.FirstOrDefault(p => p.Source == ActiveSource);
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;
StatusMessage = "Searching...";
var results = await provider.SearchAsync(SearchText, RemoteResultLimit, cts.Token);
cts.Token.ThrowIfCancellationRequested();
Items.Clear();
foreach (var result in results)
{
var tile = new MemeTileViewModel(result, _gifCache);
Items.Add(tile);
_ = tile.LoadThumbnailAsync(cts.Token);
}
SyncFavoriteFlags();
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 void RefreshLocalItems()
{ {
var records = string.IsNullOrWhiteSpace(SearchText) var records = string.IsNullOrWhiteSpace(SearchText)
? _library.GetAll() ? _library.GetAll()
@@ -143,5 +343,56 @@ public partial class PickerViewModel : ObservableObject
Items.Add(tile); Items.Add(tile);
_ = tile.LoadThumbnailAsync(); _ = tile.LoadThumbnailAsync();
} }
SyncFavoriteFlags();
StatusMessage = string.Empty;
}
private void RefreshFavorites()
{
Favorites.Clear();
foreach (var fav in _favorites.GetForSource(ActiveSource))
{
MemeTileViewModel tile;
if (fav.Source == MemeSource.Local)
{
var record = _library.GetAll().FirstOrDefault(r => r.Id == fav.Key);
// Favourite pointing at a meme that's since been deleted - skip it rather than
// rendering a broken tile.
if (record is null) continue;
tile = new MemeTileViewModel(record, _library.GetFullPath(record));
}
else
{
if (fav.PreviewUrl is null || fav.FullUrl is null) continue;
tile = new MemeTileViewModel(
new GifSearchResult
{
Id = fav.Key,
Title = fav.Title ?? "Favourite",
PreviewUrl = fav.PreviewUrl,
FullUrl = fav.FullUrl,
Source = fav.Source,
},
_gifCache);
}
tile.IsFavorite = true;
Favorites.Add(tile);
_ = tile.LoadThumbnailAsync();
}
HasFavorites = Favorites.Count > 0;
}
/// <summary>Keeps the star state on the main grid in step with the favourites store.</summary>
private void SyncFavoriteFlags()
{
foreach (var tile in Items)
{
tile.IsFavorite = _favorites.IsFavorite(tile.Source, tile.FavoriteKey);
}
} }
} }
@@ -9,6 +9,7 @@ public partial class SettingsViewModel : ObservableObject
{ {
private readonly ISettingsService _settings; private readonly ISettingsService _settings;
private readonly IGlobalHotkeyService _hotkeyService; private readonly IGlobalHotkeyService _hotkeyService;
private readonly IAutostartService _autostart;
[ObservableProperty] [ObservableProperty]
private string _hotkeyDisplay = string.Empty; private string _hotkeyDisplay = string.Empty;
@@ -19,6 +20,12 @@ public partial class SettingsViewModel : ObservableObject
[ObservableProperty] [ObservableProperty]
private InsertMode _insertMode; private InsertMode _insertMode;
[ObservableProperty]
private bool _autostartEnabled;
[ObservableProperty]
private string _giphyApiKey = string.Empty;
public bool IsCopyOnly public bool IsCopyOnly
{ {
get => InsertMode == InsertMode.CopyOnly; get => InsertMode == InsertMode.CopyOnly;
@@ -37,12 +44,18 @@ public partial class SettingsViewModel : ObservableObject
set { if (value) InsertMode = InsertMode.PasteAndSend; } set { if (value) InsertMode = InsertMode.PasteAndSend; }
} }
public SettingsViewModel(ISettingsService settings, IGlobalHotkeyService hotkeyService) public SettingsViewModel(ISettingsService settings, IGlobalHotkeyService hotkeyService, IAutostartService autostart)
{ {
_settings = settings; _settings = settings;
_hotkeyService = hotkeyService; _hotkeyService = hotkeyService;
_autostart = autostart;
_insertMode = _settings.Current.InsertMode; _insertMode = _settings.Current.InsertMode;
_giphyApiKey = _settings.Current.GiphyApiKey ?? string.Empty;
_hotkeyDisplay = HotkeyFormatter.Format(_settings.Current.HotkeyModifiers, _settings.Current.HotkeyKey); _hotkeyDisplay = HotkeyFormatter.Format(_settings.Current.HotkeyModifiers, _settings.Current.HotkeyKey);
// Read from the registry rather than a stored setting, so the checkbox reflects reality
// even if the entry was removed outside the app.
_autostartEnabled = _autostart.IsEnabled;
} }
partial void OnInsertModeChanged(InsertMode value) partial void OnInsertModeChanged(InsertMode value)
@@ -54,6 +67,17 @@ public partial class SettingsViewModel : ObservableObject
OnPropertyChanged(nameof(IsPasteAndSend)); OnPropertyChanged(nameof(IsPasteAndSend));
} }
partial void OnAutostartEnabledChanged(bool value) => _autostart.SetEnabled(value);
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) public void TrySetHotkey(ModifierKeys modifiers, Key key)
{ {
if (_hotkeyService.Register(modifiers, key)) if (_hotkeyService.Register(modifiers, key))
+154 -51
View File
@@ -3,6 +3,7 @@
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:EbbesMemeClipboard.ViewModels" xmlns:vm="clr-namespace:EbbesMemeClipboard.ViewModels"
xmlns:models="clr-namespace:EbbesMemeClipboard.Models"
Title="Meme Clipboard" Title="Meme Clipboard"
Width="420" Height="520" Width="420" Height="520"
WindowStyle="None" WindowStyle="None"
@@ -20,6 +21,8 @@
ContextMenuOpening="PickerWindow_OnContextMenuOpening" ContextMenuOpening="PickerWindow_OnContextMenuOpening"
ContextMenuClosing="PickerWindow_OnContextMenuClosing"> ContextMenuClosing="PickerWindow_OnContextMenuClosing">
<Window.Resources> <Window.Resources>
<BooleanToVisibilityConverter x:Key="BoolToVisibility"/>
<Style x:Key="FlatButtonStyle" TargetType="Button"> <Style x:Key="FlatButtonStyle" TargetType="Button">
<Setter Property="Background" Value="#2B2D31"/> <Setter Property="Background" Value="#2B2D31"/>
<Setter Property="Foreground" Value="White"/> <Setter Property="Foreground" Value="White"/>
@@ -43,25 +46,107 @@
</Setter.Value> </Setter.Value>
</Setter> </Setter>
</Style> </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>
<Image Source="{Binding Thumbnail}" Stretch="Uniform" Margin="5"/>
</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> </Window.Resources>
<Border Background="#F0202225" CornerRadius="10" BorderBrush="#3A3B3E" BorderThickness="1"> <Border Background="#F0202225" CornerRadius="10" BorderBrush="#3A3B3E" BorderThickness="1">
<Grid Margin="12"> <Grid Margin="12,8,12,12">
<Grid.RowDefinitions> <Grid.RowDefinitions>
<RowDefinition Height="16"/> <RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/> <RowDefinition Height="Auto"/>
<RowDefinition Height="*"/> <RowDefinition Height="*"/>
<RowDefinition Height="Auto"/> <RowDefinition Height="Auto"/>
</Grid.RowDefinitions> </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" MouseLeftButtonDown="DragHandle_OnMouseLeftButtonDown"
ToolTip="Drag to move"> ToolTip="Drag to move">
<Border Width="36" Height="4" CornerRadius="2" Background="#4A4C52" <Grid>
HorizontalAlignment="Center" VerticalAlignment="Center"/> <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> </Border>
<Grid Grid.Row="1" Margin="0,4,0,10"> <StackPanel Grid.Row="1" Orientation="Horizontal" Margin="0,4,0,8">
<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>
<Grid Grid.Row="2" Margin="0,0,0,10">
<Grid.ColumnDefinitions> <Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/> <ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/> <ColumnDefinition Width="Auto"/>
@@ -76,57 +161,75 @@
<Button Grid.Column="1" Content="+" Width="36" Height="36" Margin="8,0,0,0" <Button Grid.Column="1" Content="+" Width="36" Height="36" Margin="8,0,0,0"
FontSize="18" Style="{StaticResource FlatButtonStyle}" FontSize="18" Style="{StaticResource FlatButtonStyle}"
Command="{Binding ImportFilesCommand}" Command="{Binding ImportFilesCommand}"
Visibility="{Binding IsLocalSource, Converter={StaticResource BoolToVisibility}}"
ToolTip="Add memes"/> ToolTip="Add memes"/>
</Grid> </Grid>
<ScrollViewer Grid.Row="2" VerticalScrollBarVisibility="Auto"> <ScrollViewer Grid.Row="3" VerticalScrollBarVisibility="Auto" Padding="0,0,4,0">
<Grid> <StackPanel>
<TextBlock Text="No memes yet. Click + or drag files in here." <!-- Favourites for the active source. Scrolls with the content rather than
Foreground="#7B7D85" FontSize="13" being pinned, so it doesn't permanently eat height in a 520px window. -->
HorizontalAlignment="Center" VerticalAlignment="Center" <StackPanel Visibility="{Binding HasFavorites, Converter={StaticResource BoolToVisibility}}">
TextWrapping="Wrap" TextAlignment="Center" Width="260"> <TextBlock Text="★ Favourites" Foreground="#9A9CA3" FontSize="11"
<TextBlock.Style> FontWeight="SemiBold" Margin="4,0,0,4"/>
<Style TargetType="TextBlock"> <ItemsControl ItemsSource="{Binding Favorites}"
<Setter Property="Visibility" Value="Collapsed"/> ItemTemplate="{StaticResource MemeTileTemplate}">
<Style.Triggers> <ItemsControl.ItemsPanel>
<DataTrigger Binding="{Binding Items.Count}" Value="0"> <ItemsPanelTemplate>
<Setter Property="Visibility" Value="Visible"/> <UniformGrid Columns="3"/>
</DataTrigger> </ItemsPanelTemplate>
</Style.Triggers> </ItemsControl.ItemsPanel>
</Style> </ItemsControl>
</TextBlock.Style> <Border Height="1" Background="#3A3B3E" Margin="4,10,4,10"/>
</TextBlock> </StackPanel>
<ItemsControl ItemsSource="{Binding Items}"> <Grid>
<ItemsControl.ItemsPanel> <TextBlock Text="No memes yet. Click +, drag files in, or paste with Ctrl+V."
<ItemsPanelTemplate> Foreground="#7B7D85" FontSize="13"
<WrapPanel Orientation="Horizontal"/> HorizontalAlignment="Center" VerticalAlignment="Center"
</ItemsPanelTemplate> TextWrapping="Wrap" TextAlignment="Center" Width="260"
</ItemsControl.ItemsPanel> Margin="0,40,0,0">
<ItemsControl.ItemTemplate> <TextBlock.Style>
<DataTemplate DataType="{x:Type vm:MemeTileViewModel}"> <Style TargetType="TextBlock">
<Button Width="88" Height="88" Margin="4" Padding="0" <Setter Property="Visibility" Value="Collapsed"/>
Style="{StaticResource FlatButtonStyle}" <Style.Triggers>
Command="{Binding DataContext.SelectItemCommand, ElementName=RootWindow}" <MultiDataTrigger>
CommandParameter="{Binding}" <MultiDataTrigger.Conditions>
Tag="{Binding DataContext, ElementName=RootWindow}" <Condition Binding="{Binding Items.Count}" Value="0"/>
ToolTip="{Binding DisplayName}"> <Condition Binding="{Binding IsLocalSource}" Value="True"/>
<Button.ContextMenu> </MultiDataTrigger.Conditions>
<ContextMenu> <Setter Property="Visibility" Value="Visible"/>
<MenuItem Header="Remove" </MultiDataTrigger>
Command="{Binding PlacementTarget.Tag.RemoveItemCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}" </Style.Triggers>
CommandParameter="{Binding PlacementTarget.DataContext, RelativeSource={RelativeSource AncestorType=ContextMenu}}"/> </Style>
</ContextMenu> </TextBlock.Style>
</Button.ContextMenu> </TextBlock>
<Image Source="{Binding Thumbnail}" Stretch="Uniform" Width="80" Height="80"/>
</Button> <ItemsControl ItemsSource="{Binding Items}"
</DataTemplate> ItemTemplate="{StaticResource MemeTileTemplate}">
</ItemsControl.ItemTemplate> <ItemsControl.ItemsPanel>
</ItemsControl> <ItemsPanelTemplate>
</Grid> <UniformGrid Columns="3"/>
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
</ItemsControl>
</Grid>
</StackPanel>
</ScrollViewer> </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> </Grid>
</Border> </Border>
</Window> </Window>
@@ -108,6 +108,27 @@ public partial class PickerWindow : Window
if (e.Key == Key.Escape) Hide(); 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) private void PickerWindow_OnDragOver(object sender, DragEventArgs e)
{ {
e.Effects = e.Data.GetDataPresent(DataFormats.FileDrop) ? DragDropEffects.Copy : DragDropEffects.None; 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="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Settings - Ebbe's Meme Clipboard" Title="Settings - Ebbe's Meme Clipboard"
Width="380" Height="380" Width="400"
Background="#F0202225" Background="#F0202225"
WindowStartupLocation="CenterScreen" WindowStartupLocation="CenterScreen"
ResizeMode="NoResize" ResizeMode="NoResize"
@@ -32,5 +32,18 @@
IsChecked="{Binding IsPasteAndSend}"/> IsChecked="{Binding IsPasteAndSend}"/>
<TextBlock Text="Presses Enter right after pasting to submit it - use with care in chat apps." <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"/> 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="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> </StackPanel>
</Window> </Window>
@@ -16,6 +16,14 @@ public partial class SettingsWindow : Window
DataContext = _viewModel; 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) private void HotkeyBox_OnPreviewKeyDown(object sender, KeyEventArgs e)
{ {
e.Handled = true; e.Handled = true;