Added Giphy support, Favourites

This commit is contained in:
Ebbe Baß
2026-08-14 13:55:34 +02:00
parent 747cd2a8aa
commit 4c755919d4
24 changed files with 1087 additions and 160 deletions
+90 -67
View File
@@ -32,9 +32,18 @@ Everything below is **implemented and verified working by actually running the a
- Multi-format clipboard write (see "Clipboard formats" below)
- Three insert modes, chosen in Settings: **Copy only / Paste into active window /
Paste and send instantly**
- Settings window: hotkey capture (incl. Windows key), insert mode radio buttons
- Consistent dark styling across all menus (tray + tile right-click)
- Persistence: memes and settings survive restarts
- Settings window: hotkey capture (incl. Windows key), insert mode radio buttons,
**autostart toggle**, **Giphy API key field**
- **Source tabs in the picker: Local / Giphy**, with debounced Giphy search, remote
thumbnails, download-on-select caching, and the required "Powered by GIPHY" badge
- **Windows autostart** via the HKCU Run key, toggled in Settings (verified: checkbox and
registry track each other in both directions)
- **Per-source favourites**: right-click any tile → "Add to favourites", shown in a
★ Favourites section at the top of that tab, with a star overlay on favourited tiles.
Scoped per source (Local favourites don't appear on the Giphy tab) and persisted.
- App title shown top-left of the picker; the title row doubles as the drag surface
- Consistent dark styling across all menus (tray + tile right-click) **and scrollbars**
- Persistence: memes, favourites and settings survive restarts
**Build is clean** (0 warnings, 0 errors). Working tree was clean at last check;
`publish/` is gitignored.
@@ -43,10 +52,18 @@ Everything below is **implemented and verified working by actually running the a
```
%AppData%\EbbesMemeClipboard\
settings.json InsertMode, HotkeyModifiers, HotkeyKey
settings.json InsertMode, HotkeyModifiers, HotkeyKey, GiphyApiKey
favorites.json list of FavoriteRecord (Source, Key, Title, Preview/FullUrl, DateAdded)
Library\
index.json list of LocalMemeRecord (Id, FileName, OriginalFileName, DateAdded)
<guid>.png/.jpg/.gif imported files, stored under a GUID name
%LocalAppData%\EbbesMemeClipboard\
GifCache\ downloaded Giphy GIFs (re-downloadable cache, so Local not Roaming)
HKCU\Software\Microsoft\Windows\CurrentVersion\Run\EbbesMemeClipboard
autostart entry; the registry is the single source of truth for
this (deliberately NOT mirrored into settings.json, to avoid drift)
```
---
@@ -79,17 +96,25 @@ faster.
src/EbbesMemeClipboard/
App.xaml / App.xaml.cs composition root: DI container, tray icon, hotkey registration
Styles/DarkMenuStyles.xaml implicit ContextMenu/MenuItem/Separator styles (app-wide)
Styles/DarkScrollBarStyles.xaml implicit slim dark ScrollBar style (app-wide)
Models/
LocalMemeRecord.cs Id, FileName, OriginalFileName, DateAdded
AppSettings.cs InsertMode, HotkeyModifiers, HotkeyKey
AppSettings.cs InsertMode, HotkeyModifiers, HotkeyKey, GiphyApiKey
InsertMode.cs CopyOnly | PasteIntoActiveWindow | PasteAndSend
MemeSource.cs Local | Giphy
GifSearchResult.cs provider-agnostic remote hit
FavoriteRecord.cs per-source favourite (remote ones carry their own URLs)
Services/
ILocalMemeLibraryService / LocalMemeLibraryService import/search/remove, index.json
IClipboardService / ClipboardService multi-format clipboard write
IGlobalHotkeyService / GlobalHotkeyService RegisterHotKey + message-only HwndSource
ISettingsService / SettingsService System.Text.Json persistence
IAutoPasteService / AutoPasteService foreground restore + SendInput paste
ImageDecoding.cs first-frame decode helper
IAutostartService / AutostartService HKCU Run key
IGifProvider / GiphyGifProvider remote search behind a provider interface
IGifCacheService / GifCacheService download + cache remote GIFs
IFavoritesService / FavoritesService per-source favourites, favorites.json
ImageDecoding.cs first-frame decode helper (file + bytes)
HotkeyFormatter.cs "Ctrl + Alt + M" display strings
Native/
NativeMethods.cs P/Invoke declarations
@@ -132,9 +157,15 @@ code-behind fields. Look elements up by `Tag`/traversal instead.
|---|---|
| `CF_HDROP` (file drop list) | Discord/Slack/Teams/Explorer paste the real file — preserves GIF animation |
| `CF_DIB` (`SetImage`) | Bitmap-only apps like Paint |
| `"PNG"` (registered format) | **Chromium-based apps (Teams, Slack, Discord, browsers) prefer this over CF_DIB** and may paste nothing at all without it |
| `"PNG"` (registered format) | **Chromium-based apps (Teams, Slack, Discord, browsers) prefer this over CF_DIB** and may paste nothing at all without it. **Deliberately omitted for GIFs** — see below |
| `CF_HTML` | The other route web-based compose boxes check; carries a base64 `data:` URI |
**The `"PNG"` format is skipped for GIFs on purpose.** A PNG can only hold one static frame,
and since Chromium *prefers* that format over all others, offering it for a GIF is exactly
what made animated GIFs paste as a still first frame. Omitting it lets those targets fall
through to `CF_HTML`, which carries the full animated data URI. Verified: a GIF's clipboard
formats are FileDrop/Bitmap/HTML with no PNG, while a static image still gets PNG.
Two traps here:
- WPF does **NOT** auto-wrap `DataFormats.Html`. The `Version/StartHTML/EndHTML/
StartFragment/EndFragment` header with exact **byte** offsets must be built by hand
@@ -206,79 +237,68 @@ Automated UI testing of this app is unusually fiddly. What works:
for a name containing "Meme Clipboard". This finds it even when tucked in the overflow
area, unlike screenshots.
Clean up after testing: kill `EbbesMemeClipboard` processes and remove
`%AppData%\EbbesMemeClipboard` so seeded test data doesn't leak into real use.
Clean up after testing: kill `EbbesMemeClipboard` processes, remove
`%AppData%\EbbesMemeClipboard` and `%LocalAppData%\EbbesMemeClipboard`, and **check the
autostart Run key isn't left enabled** if autostart was exercised — that one writes to the
user's real machine state, not just app-local files.
---
## Pending requests (not yet implemented)
## Giphy integration notes
From the user's most recent batch. **None of these are started.**
**Tenor is dead.** The user originally asked for Tenor; research confirmed Google **shut
down the public Tenor API on 2026-06-30** (corroborated by 9to5Google, Shacknews,
Slashdot). Not buildable. **Giphy** was chosen instead and is what's implemented.
### 1. Windows auto-start, with a toggle in Settings
Straightforward. Write `HKCU\Software\Microsoft\Windows\CurrentVersion\Run`.
**Important**: use `Environment.ProcessPath`, **not** `Assembly.Location` — the latter
returns an empty string in single-file published mode. Add an `IAutostartService` +
a checkbox in `SettingsWindow`.
### 2. Tab/dropdown for meme sources (Local, Giphy)
UI shell for multiple sources, plus the Giphy integration behind it.
**Critical context — Tenor is dead.** The user originally asked for Tenor; research during
planning confirmed Google **shut down the public Tenor API on 2026-06-30** (corroborated by
9to5Google, Shacknews, Slashdot). It is not buildable. The user chose **Giphy** as the
replacement. Notes:
- `GET https://api.giphy.com/v1/gifs/search?api_key={key}&q={query}&limit={n}&rating=pg-13`
- Response: `data[].images.{fixed_width,downsized,original}.url`
- Free "beta" key is 100 req/hr — plenty for personal use. Production tier is ~$9k/yr and
irrelevant here. **The user needs to supply their own key** (add a field in Settings).
- Giphy's ToS **requires a visible "Powered by GIPHY" badge** wherever results appear.
This is a real UI requirement, not optional attribution.
- Build behind an `IGifProvider` interface so another provider can be swapped in later.
- Remote GIFs must be **downloaded to a local cache file before** they can go on the
clipboard as CF_HDROP.
- `GET https://api.giphy.com/v1/gifs/search?api_key={key}&q={q}&limit={n}&rating=pg-13`
- Empty query falls back to `/trending` with the same parameters.
- Response: `data[].images.{fixed_width,original}.url` — `fixed_width` for grid
thumbnails, `original` for the full-quality animated file that gets pasted.
- **The user supplies their own key** in Settings. Free tier is 100 req/hr; the ~$9k/yr
production tier is irrelevant for personal use. Without a key the tab shows guidance
instead of failing at request time (`IGifProvider.IsConfigured`).
- Searches are **debounced 350ms** and cancel the previous in-flight request — without
that, every keystroke burns a request against the free rate limit.
- Giphy's ToS **requires the visible "Powered by GIPHY" badge**, rendered in the picker
footer whenever the Giphy tab is active. Don't remove it.
- Klipy (`api.klipy.com`) is a viable alternative if Giphy becomes a problem — founded by
ex-Tenor people, deliberately mirrors Tenor's API shape, free.
ex-Tenor people, deliberately mirrors Tenor's API shape, free. Add it as a second
`IGifProvider`; the tab strip and view models are already provider-agnostic.
### 3. "Central store specific to the application for all local memes"
**⚠️ STILL AMBIGUOUS — ASK THE USER BEFORE BUILDING.**
The app *already* stores memes centrally in `%AppData%\EbbesMemeClipboard\Library`. When
asked to clarify, the user selected "Something else" but the conversation moved on before
they explained. Options offered (and rejected) were: (a) save Giphy results into the local
library, (b) configurable library folder location. So it's something other than those two —
**get a concrete description first.**
**Untested by the assistant**: actual Giphy search results, remote thumbnail loading, and
download-on-select were never exercised against the live API, because no API key was
available. The code paths are written but unverified end-to-end — if something misbehaves
once a key is entered, start with `GiphyGifProvider.SearchAsync` (JSON field names) and
`GifCacheService.GetOrDownloadAsync`.
### 4. Fix GIFs — "clicking a GIF doesn't copy/paste it correctly"
User confirmed this specific symptom (not thumbnails, not crashes, not lag).
---
**Strong root-cause hypothesis (not yet verified — verify before/while fixing):**
`ClipboardService` sets the `"PNG"` clipboard format from `firstFrame` — i.e. **frame 0 of
the GIF re-encoded as a static PNG**. Chromium-based apps *prefer* the `"PNG"` format over
everything else (that preference is exactly why it was added, to fix static-image pasting
into Teams). So for a GIF, Teams/Discord grab the static first frame instead of the animated
GIF → the GIF pastes as a still image.
## Pending requests
**Suggested fix**: skip the `"PNG"` format when the file is a GIF, so Chromium falls back to
`CF_HTML` (which already carries the full animated `data:image/gif;base64,...` URI) or
`CF_HDROP`. `ImageDecoding.IsGif()` already exists for the check. Keep `CF_DIB` so Paint
still gets something. Test in both Teams and Paint, since these two want opposite things.
### ⚠️ 1. "Central store specific to the application for all local memes" — STILL AMBIGUOUS
**Ask the user before building.** The app *already* stores memes centrally in
`%AppData%\EbbesMemeClipboard\Library`. When asked to clarify, the user chose "Something
else" but the conversation moved on before they explained. Options offered *and rejected*
were: (a) save Giphy results into the local library, (b) configurable library folder
location. So it's something other than those two — get a concrete description first.
**Also worth fixing while in there** (separate, minor, pre-existing):
`ImageDecoding.DecodeFirstFrame` **ignores `decodePixelWidth` for GIFs** — it returns the
`GifBitmapDecoder` frame before reaching the `DecodePixelWidth` logic. So GIF thumbnails
decode at full resolution while static images are capped at 150px. A library with many
large GIFs will use more memory than it should.
### 2. Paste-send reliability ("Teams is sometimes too slow")
Not yet addressed. Current delays in `AutoPasteService`: `FocusSettleDelay` 200ms (before
Ctrl+V), `PasteSettleDelay` 400ms (before Enter). Both are fixed sleeps, so a slow app will
still occasionally miss.
### 5. Paste-send reliability ("Teams is sometimes too slow")
Current delays in `AutoPasteService`: `FocusSettleDelay` 200ms (before Ctrl+V),
`PasteSettleDelay` 400ms (before Enter). Both are fixed sleeps — a slow app will still miss.
Bumping the constants is the cheap fix but slows every paste for everyone. Better: **poll
for readiness instead of guessing** — verify the target window is genuinely foreground
before sending Ctrl+V, and confirm rather than firing Enter blind. Note the failure mode
already observed: if Enter fires when the paste didn't land, **Teams sends an empty
message**, so the Enter step should be conservative — a wrong guess posts to a real chat.
Bumping the constants is the cheap fix, but it makes every paste slower for everyone. A
better approach is to **poll for readiness instead of guessing**: e.g. verify the target
window is actually foreground before sending Ctrl+V, and for the send step consider
retrying/confirming rather than firing Enter blind. Note the failure mode already seen:
if Enter fires when the paste didn't land, **Teams sends an empty message** — so the Enter
step should be conservative, since a wrong guess posts to a real chat.
### 3. Possible follow-ups (not requested, just noted)
- **Single-instance protection** (named Mutex + named pipe). Currently two instances fight
over the global hotkey; this caused a confusing phantom "regression" during development.
- **`WPF-UI` is referenced but unused** — intended for Mica/Fluent polish. Use it or drop it.
- **Animated GIF previews on hover** (currently static first frame only) — `GifFramePlayer`
control was designed for this in the original plan but never built.
---
@@ -294,6 +314,9 @@ step should be conservative, since a wrong guess posts to a real chat.
chat apps. Default insert mode is `PasteIntoActiveWindow`.
- No Settings-window dark title bar (content is dark, title bar is standard OS chrome).
User was offered a fix and it wasn't prioritized.
- **Autostart state lives only in the registry**, not in `settings.json`. Mirroring it would
let the two drift apart if the entry is removed via Task Manager or another tool; the
Settings checkbox reads live registry state each time the window opens.
## Known limitations (inherent, not bugs)
+1
View File
@@ -6,6 +6,7 @@
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<ResourceDictionary Source="Styles/DarkMenuStyles.xaml"/>
<ResourceDictionary Source="Styles/DarkScrollBarStyles.xaml"/>
</ResourceDictionary.MergedDictionaries>
<tb:TaskbarIcon x:Key="TrayIcon"
+9 -5
View File
@@ -1,3 +1,4 @@
using System.Net.Http;
using System.Windows;
using EbbesMemeClipboard.Services;
using EbbesMemeClipboard.ViewModels;
@@ -27,6 +28,13 @@ public partial class App : Application
services.AddSingleton<IGlobalHotkeyService, GlobalHotkeyService>();
services.AddSingleton<ISettingsService, SettingsService>();
services.AddSingleton<IAutoPasteService, AutoPasteService>();
services.AddSingleton<IAutostartService, AutostartService>();
// One long-lived HttpClient: the socket-exhaustion problem IHttpClientFactory solves
// comes from repeatedly constructing clients, which a singleton avoids anyway.
services.AddSingleton<HttpClient>();
services.AddSingleton<IGifCacheService, GifCacheService>();
services.AddSingleton<IFavoritesService, FavoritesService>();
services.AddSingleton<IGifProvider, GiphyGifProvider>();
services.AddSingleton<PickerViewModel>();
services.AddSingleton<PickerWindow>();
services.AddSingleton<SettingsViewModel>();
@@ -72,11 +80,7 @@ public partial class App : Application
private void TrayOpenPicker_Click(object sender, RoutedEventArgs e) => _pickerWindow.ShowNearCursor();
private void TraySettings_Click(object sender, RoutedEventArgs e)
{
_settingsWindow.Show();
_settingsWindow.Activate();
}
private void TraySettings_Click(object sender, RoutedEventArgs e) => _settingsWindow.ShowSettings();
private void TrayExit_Click(object sender, RoutedEventArgs e) => Shutdown();
@@ -7,4 +7,11 @@ public sealed class AppSettings
public InsertMode InsertMode { get; set; } = InsertMode.PasteIntoActiveWindow;
public ModifierKeys HotkeyModifiers { get; set; } = ModifierKeys.Control | ModifierKeys.Alt;
public Key HotkeyKey { get; set; } = Key.M;
/// <summary>
/// User-supplied Giphy API key. Null/empty disables the Giphy tab's searching rather than
/// failing at request time. Autostart is deliberately NOT stored here - the registry Run
/// key is its own source of truth, so caching it would risk the two drifting apart.
/// </summary>
public string? GiphyApiKey { get; set; }
}
@@ -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;
}
@@ -27,12 +27,20 @@ public sealed class ClipboardService : IClipboardService
// reading a pasted image, because those older formats are lossy for transparency. This
// covers Teams, Slack, Discord's web layer, and browsers - without it, paste into any of
// them can silently produce nothing even though CF_DIB is present and perfectly valid.
//
// Deliberately skipped for GIFs: a PNG can only ever hold one static frame, and since
// Chromium PREFERS this format over all others, offering it for a GIF is what makes
// animated GIFs paste as a still first frame. Omitting it lets those targets fall
// through to CF_HTML below, which carries the full animated data URI.
if (!ImageDecoding.IsGif(filePath))
{
using var pngStream = new MemoryStream();
var pngEncoder = new PngBitmapEncoder();
pngEncoder.Frames.Add(BitmapFrame.Create(firstFrame));
pngEncoder.Save(pngStream);
pngStream.Position = 0;
data.SetData("PNG", pngStream);
}
// CF_HTML: the other route those same web-based paste targets check, and the only one of
// these formats that preserves animation for them, for GIFs.
@@ -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);
}
@@ -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);
}
@@ -1,4 +1,5 @@
using System.IO;
using System.Windows.Media;
using System.Windows.Media.Imaging;
namespace EbbesMemeClipboard.Services;
@@ -9,7 +10,7 @@ internal static class ImageDecoding
Path.GetExtension(path).Equals(".gif", StringComparison.OrdinalIgnoreCase);
/// <summary>
/// Decodes just the first frame of an image (for GIFs, frame 0 only - the rest of the
/// Decodes just the first frame of an image file (for GIFs, frame 0 only - the rest of the
/// animation is never touched). The result is frozen so it can be handed back across
/// threads safely when called from a background thread.
/// </summary>
@@ -18,9 +19,7 @@ internal static class ImageDecoding
if (IsGif(path))
{
var decoder = new GifBitmapDecoder(new Uri(path), BitmapCreateOptions.PreservePixelFormat, BitmapCacheOption.OnLoad);
var frame = decoder.Frames[0];
frame.Freeze();
return frame;
return Finalize(decoder.Frames[0], decodePixelWidth);
}
var bitmap = new BitmapImage();
@@ -35,4 +34,31 @@ internal static class ImageDecoding
bitmap.Freeze();
return bitmap;
}
/// <summary>Same as DecodeFirstFrame but for bytes already in memory (remote previews).</summary>
internal static BitmapSource DecodeFirstFrameFromBytes(byte[] data, int? decodePixelWidth = null)
{
using var stream = new MemoryStream(data);
var decoder = BitmapDecoder.Create(stream, BitmapCreateOptions.PreservePixelFormat, BitmapCacheOption.OnLoad);
return Finalize(decoder.Frames[0], decodePixelWidth);
}
/// <summary>
/// Scales a decoded frame down to the requested width and freezes it. GIF frames can't use
/// BitmapImage.DecodePixelWidth (that only applies when decoding from a URI), so without
/// this every GIF thumbnail would sit in memory at full resolution.
/// </summary>
private static BitmapSource Finalize(BitmapSource frame, int? decodePixelWidth)
{
if (decodePixelWidth is not int width || frame.PixelWidth <= width)
{
frame.Freeze();
return frame;
}
double scale = width / (double)frame.PixelWidth;
var scaled = new TransformedBitmap(frame, new ScaleTransform(scale, scale));
scaled.Freeze();
return scaled;
}
}
@@ -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,85 @@ using EbbesMemeClipboard.Services;
namespace EbbesMemeClipboard.ViewModels;
/// <summary>
/// One grid tile. Covers both a local library file and a remote provider result so the picker
/// can use a single DataTemplate for every source.
/// </summary>
public partial class MemeTileViewModel : ObservableObject
{
private const int ThumbnailPixelWidth = 150;
public LocalMemeRecord Record { get; }
public string FullPath { get; }
public string DisplayName => Record.OriginalFileName;
private readonly IGifCacheService? _cache;
private string? _localPath;
public LocalMemeRecord? Record { get; }
public GifSearchResult? RemoteResult { get; }
public bool IsLocal => Record is not null;
public string DisplayName => Record?.OriginalFileName ?? RemoteResult?.Title ?? "Meme";
public MemeSource Source => Record is not null ? MemeSource.Local : RemoteResult!.Source;
/// <summary>Stable identity for favouriting: the library Id locally, the provider Id remotely.</summary>
public string FavoriteKey => Record?.Id ?? RemoteResult!.Id;
[ObservableProperty]
private BitmapSource? _thumbnail;
[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)
{
Record = record;
FullPath = fullPath;
_localPath = fullPath;
}
public async Task LoadThumbnailAsync()
public MemeTileViewModel(GifSearchResult remoteResult, IGifCacheService cache)
{
var path = FullPath;
Thumbnail = await Task.Run(() => ImageDecoding.DecodeFirstFrame(path, ThumbnailPixelWidth));
RemoteResult = remoteResult;
_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
{
private const int RemoteResultLimit = 30;
private static readonly TimeSpan SearchDebounce = TimeSpan.FromMilliseconds(350);
private readonly ILocalMemeLibraryService _library;
private readonly IClipboardService _clipboard;
private readonly ISettingsService _settings;
private readonly IAutoPasteService _autoPaste;
private readonly IGifCacheService _gifCache;
private readonly IFavoritesService _favorites;
private readonly IReadOnlyList<IGifProvider> _providers;
/// <summary>Cancels the in-flight remote search when a newer keystroke supersedes it.</summary>
private CancellationTokenSource? _searchCts;
public ObservableCollection<MemeTileViewModel> Items { get; } = new();
/// <summary>Favourites for the currently active source only.</summary>
public ObservableCollection<MemeTileViewModel> Favorites { get; } = new();
[ObservableProperty]
private string _searchText = string.Empty;
[ObservableProperty]
private string _statusMessage = string.Empty;
/// <summary>
/// True while a modal dialog (currently: the "add memes" file picker) opened from within the
/// picker is showing. The window's Deactivated handler checks this so opening that dialog
/// doesn't get treated as "user clicked away" and hide the picker out from under it.
/// </summary>
[ObservableProperty]
private bool _isBusy;
[ObservableProperty]
private bool _isDialogOpen;
[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 PickerViewModel(
ILocalMemeLibraryService library,
IClipboardService clipboard,
ISettingsService settings,
IAutoPasteService autoPaste)
IAutoPasteService autoPaste,
IGifCacheService gifCache,
IFavoritesService favorites,
IEnumerable<IGifProvider> providers)
{
_library = library;
_clipboard = clipboard;
_settings = settings;
_autoPaste = autoPaste;
RefreshItems();
_gifCache = gifCache;
_favorites = favorites;
_providers = providers.ToList();
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]
private async Task SelectItemAsync(MemeTileViewModel? tile)
@@ -54,7 +95,12 @@ public partial class PickerViewModel : ObservableObject
try
{
await _clipboard.CopyLocalFileAsync(tile.FullPath);
IsBusy = true;
// For remote results this downloads and caches the full-quality GIF first; the
// clipboard's file-drop format needs a real file on disk.
var path = await tile.EnsureLocalPathAsync();
await _clipboard.CopyLocalFileAsync(path);
StatusMessage = $"Copied {tile.DisplayName}";
RequestClose?.Invoke(this, EventArgs.Empty);
@@ -66,20 +112,55 @@ public partial class PickerViewModel : ObservableObject
}
catch (Exception ex)
{
// Deliberately broad: CommunityToolkit's AsyncRelayCommand otherwise swallows any
// exception here silently (no crash, no message, the click just appears to do
// nothing), which makes clipboard/paste failures impossible to diagnose from the UI.
// Deliberately broad: AsyncRelayCommand otherwise swallows exceptions silently, so
// a clipboard/download failure would look like the click simply did nothing.
StatusMessage = $"Couldn't insert {tile.DisplayName}: {ex.Message}";
}
finally
{
IsBusy = false;
}
}
[RelayCommand]
private async Task ToggleFavoriteAsync(MemeTileViewModel? tile)
{
if (tile is null) return;
if (_favorites.IsFavorite(tile.Source, tile.FavoriteKey))
{
await _favorites.RemoveAsync(tile.Source, tile.FavoriteKey);
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]
private async Task RemoveItemAsync(MemeTileViewModel? tile)
{
if (tile is null) return;
if (tile?.Record is null) return;
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}";
}
@@ -120,17 +201,88 @@ public partial class PickerViewModel : ObservableObject
}
var imported = await _library.ImportAsync(candidates);
RefreshItems();
ActiveSource = MemeSource.Local;
RefreshLocalItems();
StatusMessage = $"Added {imported.Count} meme(s).";
}
public void OnShown()
{
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)
? _library.GetAll()
@@ -143,5 +295,56 @@ public partial class PickerViewModel : ObservableObject
Items.Add(tile);
_ = 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 IGlobalHotkeyService _hotkeyService;
private readonly IAutostartService _autostart;
[ObservableProperty]
private string _hotkeyDisplay = string.Empty;
@@ -19,6 +20,12 @@ public partial class SettingsViewModel : ObservableObject
[ObservableProperty]
private InsertMode _insertMode;
[ObservableProperty]
private bool _autostartEnabled;
[ObservableProperty]
private string _giphyApiKey = string.Empty;
public bool IsCopyOnly
{
get => InsertMode == InsertMode.CopyOnly;
@@ -37,12 +44,18 @@ public partial class SettingsViewModel : ObservableObject
set { if (value) InsertMode = InsertMode.PasteAndSend; }
}
public SettingsViewModel(ISettingsService settings, IGlobalHotkeyService hotkeyService)
public SettingsViewModel(ISettingsService settings, IGlobalHotkeyService hotkeyService, IAutostartService autostart)
{
_settings = settings;
_hotkeyService = hotkeyService;
_autostart = autostart;
_insertMode = _settings.Current.InsertMode;
_giphyApiKey = _settings.Current.GiphyApiKey ?? string.Empty;
_hotkeyDisplay = HotkeyFormatter.Format(_settings.Current.HotkeyModifiers, _settings.Current.HotkeyKey);
// Read from the registry rather than a stored setting, so the checkbox reflects reality
// even if the entry was removed outside the app.
_autostartEnabled = _autostart.IsEnabled;
}
partial void OnInsertModeChanged(InsertMode value)
@@ -54,6 +67,17 @@ public partial class SettingsViewModel : ObservableObject
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)
{
if (_hotkeyService.Register(modifiers, key))
+130 -29
View File
@@ -3,6 +3,7 @@
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:EbbesMemeClipboard.ViewModels"
xmlns:models="clr-namespace:EbbesMemeClipboard.Models"
Title="Meme Clipboard"
Width="420" Height="520"
WindowStyle="None"
@@ -20,6 +21,8 @@
ContextMenuOpening="PickerWindow_OnContextMenuOpening"
ContextMenuClosing="PickerWindow_OnContextMenuClosing">
<Window.Resources>
<BooleanToVisibilityConverter x:Key="BoolToVisibility"/>
<Style x:Key="FlatButtonStyle" TargetType="Button">
<Setter Property="Background" Value="#2B2D31"/>
<Setter Property="Foreground" Value="White"/>
@@ -43,25 +46,105 @@
</Setter.Value>
</Setter>
</Style>
<!-- Tag carries "is this tab active", so one style can drive both tab buttons. -->
<Style x:Key="TabButtonStyle" TargetType="Button">
<Setter Property="Foreground" Value="#9A9CA3"/>
<Setter Property="Background" Value="Transparent"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="Padding" Value="14,6"/>
<Setter Property="FontSize" Value="13"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border x:Name="Bd" Background="{TemplateBinding Background}" CornerRadius="6" SnapsToDevicePixels="True">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center" Margin="{TemplateBinding Padding}"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="Background" Value="#2B2D31"/>
</Trigger>
<DataTrigger Binding="{Binding Tag, RelativeSource={RelativeSource Self}}" Value="True">
<Setter TargetName="Bd" Property="Background" Value="#3A3D42"/>
<Setter Property="Foreground" Value="White"/>
<Setter Property="FontWeight" Value="SemiBold"/>
</DataTrigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- Shared by the favourites row and the main grid so both behave identically. -->
<DataTemplate x:Key="MemeTileTemplate" DataType="{x:Type vm:MemeTileViewModel}">
<Grid Width="88" Height="88" 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" Width="80" Height="80"/>
</Button>
<!-- Star overlay: marks favourites at a glance without needing a second grid. -->
<TextBlock Text="★" FontSize="13" Foreground="#FFC72C"
HorizontalAlignment="Right" VerticalAlignment="Top"
Margin="0,4,6,0" IsHitTestVisible="False"
Visibility="{Binding IsFavorite, Converter={StaticResource BoolToVisibility}}"/>
</Grid>
</DataTemplate>
</Window.Resources>
<Border Background="#F0202225" CornerRadius="10" BorderBrush="#3A3B3E" BorderThickness="1">
<Grid Margin="12">
<Grid Margin="12,8,12,12">
<Grid.RowDefinitions>
<RowDefinition Height="16"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<Border Grid.Row="0" Background="Transparent" Cursor="SizeAll"
<!-- Title bar: doubles as the drag surface. -->
<Border Grid.Row="0" Background="Transparent" Cursor="SizeAll" Height="26"
MouseLeftButtonDown="DragHandle_OnMouseLeftButtonDown"
ToolTip="Drag to move">
<Grid>
<TextBlock Text="Meme Clipboard" Foreground="White" FontSize="13"
FontWeight="SemiBold" VerticalAlignment="Center"
HorizontalAlignment="Left"/>
<Border Width="36" Height="4" CornerRadius="2" Background="#4A4C52"
HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Grid>
</Border>
<Grid Grid.Row="1" Margin="0,4,0,10">
<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>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
@@ -76,57 +159,75 @@
<Button Grid.Column="1" Content="+" Width="36" Height="36" Margin="8,0,0,0"
FontSize="18" Style="{StaticResource FlatButtonStyle}"
Command="{Binding ImportFilesCommand}"
Visibility="{Binding IsLocalSource, Converter={StaticResource BoolToVisibility}}"
ToolTip="Add memes"/>
</Grid>
<ScrollViewer Grid.Row="2" VerticalScrollBarVisibility="Auto">
<ScrollViewer Grid.Row="3" VerticalScrollBarVisibility="Auto" Padding="0,0,4,0">
<StackPanel>
<!-- Favourites for the active source. Scrolls with the content rather than
being pinned, so it doesn't permanently eat height in a 520px window. -->
<StackPanel Visibility="{Binding HasFavorites, Converter={StaticResource BoolToVisibility}}">
<TextBlock Text="★ Favourites" Foreground="#9A9CA3" FontSize="11"
FontWeight="SemiBold" Margin="4,0,0,4"/>
<ItemsControl ItemsSource="{Binding Favorites}"
ItemTemplate="{StaticResource MemeTileTemplate}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel Orientation="Horizontal"/>
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
</ItemsControl>
<Border Height="1" Background="#3A3B3E" Margin="4,10,4,10"/>
</StackPanel>
<Grid>
<TextBlock Text="No memes yet. Click + or drag files in here."
Foreground="#7B7D85" FontSize="13"
HorizontalAlignment="Center" VerticalAlignment="Center"
TextWrapping="Wrap" TextAlignment="Center" Width="260">
TextWrapping="Wrap" TextAlignment="Center" Width="260"
Margin="0,40,0,0">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Setter Property="Visibility" Value="Collapsed"/>
<Style.Triggers>
<DataTrigger Binding="{Binding Items.Count}" Value="0">
<MultiDataTrigger>
<MultiDataTrigger.Conditions>
<Condition Binding="{Binding Items.Count}" Value="0"/>
<Condition Binding="{Binding IsLocalSource}" Value="True"/>
</MultiDataTrigger.Conditions>
<Setter Property="Visibility" Value="Visible"/>
</DataTrigger>
</MultiDataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
<ItemsControl ItemsSource="{Binding Items}">
<ItemsControl ItemsSource="{Binding Items}"
ItemTemplate="{StaticResource MemeTileTemplate}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel Orientation="Horizontal"/>
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate DataType="{x:Type vm:MemeTileViewModel}">
<Button Width="88" Height="88" Margin="4" Padding="0"
Style="{StaticResource FlatButtonStyle}"
Command="{Binding DataContext.SelectItemCommand, ElementName=RootWindow}"
CommandParameter="{Binding}"
Tag="{Binding DataContext, ElementName=RootWindow}"
ToolTip="{Binding DisplayName}">
<Button.ContextMenu>
<ContextMenu>
<MenuItem Header="Remove"
Command="{Binding PlacementTarget.Tag.RemoveItemCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}"
CommandParameter="{Binding PlacementTarget.DataContext, RelativeSource={RelativeSource AncestorType=ContextMenu}}"/>
</ContextMenu>
</Button.ContextMenu>
<Image Source="{Binding Thumbnail}" Stretch="Uniform" Width="80" Height="80"/>
</Button>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</Grid>
</StackPanel>
</ScrollViewer>
<TextBlock Grid.Row="3" Text="{Binding StatusMessage}" Foreground="#9A9CA3" FontSize="11" Margin="2,8,0,0"/>
<Grid Grid.Row="4" Margin="2,8,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0" Text="{Binding StatusMessage}" Foreground="#9A9CA3"
FontSize="11" TextTrimming="CharacterEllipsis" VerticalAlignment="Center"/>
<!-- Required by Giphy's API terms wherever their results are displayed. -->
<TextBlock Grid.Column="1" Text="Powered by GIPHY" Foreground="#7B7D85"
FontSize="10" FontWeight="SemiBold" VerticalAlignment="Center"
Margin="8,0,0,0"
Visibility="{Binding IsGiphySource, Converter={StaticResource BoolToVisibility}}"/>
</Grid>
</Grid>
</Border>
</Window>
@@ -2,7 +2,7 @@
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Settings - Ebbe's Meme Clipboard"
Width="380" Height="380"
Width="400"
Background="#F0202225"
WindowStartupLocation="CenterScreen"
ResizeMode="NoResize"
@@ -32,5 +32,18 @@
IsChecked="{Binding IsPasteAndSend}"/>
<TextBlock Text="Presses Enter right after pasting to submit it - use with care in chat apps."
Foreground="#7B7D85" FontSize="10" TextWrapping="Wrap" Margin="20,4,0,0"/>
<TextBlock Text="Startup" Foreground="White" FontSize="14" FontWeight="Bold" Margin="0,24,0,8"/>
<CheckBox Content="Start automatically when Windows starts" Foreground="White"
IsChecked="{Binding AutostartEnabled}"/>
<TextBlock Text="Giphy" Foreground="White" FontSize="14" FontWeight="Bold" Margin="0,24,0,8"/>
<Border Background="#2B2D31" CornerRadius="6">
<TextBox Text="{Binding GiphyApiKey, UpdateSourceTrigger=PropertyChanged}"
Background="Transparent" Foreground="White" BorderThickness="0"
Padding="10,8" FontSize="13" CaretBrush="White"/>
</Border>
<TextBlock TextWrapping="Wrap" Foreground="#7B7D85" FontSize="11" Margin="0,4,0,0"
Text="API key for the Giphy tab. Get a free one at developers.giphy.com (create an app, choose an API key). Leave empty to disable Giphy search."/>
</StackPanel>
</Window>
@@ -16,6 +16,14 @@ public partial class SettingsWindow : Window
DataContext = _viewModel;
}
/// <summary>Re-reads live state (e.g. the autostart registry entry) each time it's opened.</summary>
public void ShowSettings()
{
_viewModel.Refresh();
Show();
Activate();
}
private void HotkeyBox_OnPreviewKeyDown(object sender, KeyEventArgs e)
{
e.Handled = true;