diff --git a/.claude/PROJECT-CONTEXT.md b/.claude/PROJECT-CONTEXT.md index 03bdc5a..c9ac02c 100644 --- a/.claude/PROJECT-CONTEXT.md +++ b/.claude/PROJECT-CONTEXT.md @@ -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) .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) ``` --- @@ -78,18 +95,26 @@ faster. ``` src/EbbesMemeClipboard/ App.xaml / App.xaml.cs composition root: DI container, tray icon, hotkey registration - Styles/DarkMenuStyles.xaml implicit ContextMenu/MenuItem/Separator styles (app-wide) + Styles/DarkMenuStyles.xaml implicit ContextMenu/MenuItem/Separator styles (app-wide) + Styles/DarkScrollBarStyles.xaml implicit slim dark ScrollBar style (app-wide) Models/ LocalMemeRecord.cs Id, FileName, OriginalFileName, DateAdded - AppSettings.cs InsertMode, HotkeyModifiers, HotkeyKey + AppSettings.cs InsertMode, HotkeyModifiers, HotkeyKey, GiphyApiKey InsertMode.cs CopyOnly | PasteIntoActiveWindow | PasteAndSend + MemeSource.cs Local | Giphy + GifSearchResult.cs provider-agnostic remote hit + FavoriteRecord.cs per-source favourite (remote ones carry their own URLs) Services/ ILocalMemeLibraryService / LocalMemeLibraryService import/search/remove, index.json IClipboardService / ClipboardService multi-format clipboard write IGlobalHotkeyService / GlobalHotkeyService RegisterHotKey + message-only HwndSource ISettingsService / SettingsService System.Text.Json persistence IAutoPasteService / AutoPasteService foreground restore + SendInput paste - ImageDecoding.cs first-frame decode helper + IAutostartService / AutostartService HKCU Run key + IGifProvider / GiphyGifProvider remote search behind a provider interface + IGifCacheService / GifCacheService download + cache remote GIFs + IFavoritesService / FavoritesService per-source favourites, favorites.json + ImageDecoding.cs first-frame decode helper (file + bytes) HotkeyFormatter.cs "Ctrl + Alt + M" display strings Native/ NativeMethods.cs P/Invoke declarations @@ -132,9 +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) diff --git a/src/EbbesMemeClipboard/App.xaml b/src/EbbesMemeClipboard/App.xaml index b04b49e..252895f 100644 --- a/src/EbbesMemeClipboard/App.xaml +++ b/src/EbbesMemeClipboard/App.xaml @@ -6,6 +6,7 @@ + (); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + // One long-lived HttpClient: the socket-exhaustion problem IHttpClientFactory solves + // comes from repeatedly constructing clients, which a singleton avoids anyway. + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); @@ -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(); diff --git a/src/EbbesMemeClipboard/Models/AppSettings.cs b/src/EbbesMemeClipboard/Models/AppSettings.cs index 110522a..aee7342 100644 --- a/src/EbbesMemeClipboard/Models/AppSettings.cs +++ b/src/EbbesMemeClipboard/Models/AppSettings.cs @@ -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; + + /// + /// 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. + /// + public string? GiphyApiKey { get; set; } } diff --git a/src/EbbesMemeClipboard/Models/FavoriteRecord.cs b/src/EbbesMemeClipboard/Models/FavoriteRecord.cs new file mode 100644 index 0000000..b3141cd --- /dev/null +++ b/src/EbbesMemeClipboard/Models/FavoriteRecord.cs @@ -0,0 +1,24 @@ +namespace EbbesMemeClipboard.Models; + +/// +/// 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). +/// +public sealed class FavoriteRecord +{ + public required MemeSource Source { get; init; } + + /// Local: the LocalMemeRecord Id. Remote: the provider's own item Id. + public required string Key { get; init; } + + public string? Title { get; init; } + + /// Remote only. + public string? PreviewUrl { get; init; } + + /// Remote only. + public string? FullUrl { get; init; } + + public DateTimeOffset DateAdded { get; init; } +} diff --git a/src/EbbesMemeClipboard/Models/GifSearchResult.cs b/src/EbbesMemeClipboard/Models/GifSearchResult.cs new file mode 100644 index 0000000..2cd0ba6 --- /dev/null +++ b/src/EbbesMemeClipboard/Models/GifSearchResult.cs @@ -0,0 +1,19 @@ +namespace EbbesMemeClipboard.Models; + +/// +/// A provider-agnostic search hit. Deliberately not Giphy-shaped so a second provider can be +/// added without touching the view models. +/// +public sealed class GifSearchResult +{ + public required string Id { get; init; } + public required string Title { get; init; } + + /// Smaller rendition used for the grid thumbnail. + public required string PreviewUrl { get; init; } + + /// Full-quality animated rendition, downloaded when the user picks the tile. + public required string FullUrl { get; init; } + + public required MemeSource Source { get; init; } +} diff --git a/src/EbbesMemeClipboard/Models/MemeSource.cs b/src/EbbesMemeClipboard/Models/MemeSource.cs new file mode 100644 index 0000000..6673486 --- /dev/null +++ b/src/EbbesMemeClipboard/Models/MemeSource.cs @@ -0,0 +1,10 @@ +namespace EbbesMemeClipboard.Models; + +public enum MemeSource +{ + /// Memes the user added themselves, stored under %AppData%. + Local, + + /// Search results from the Giphy API. + Giphy, +} diff --git a/src/EbbesMemeClipboard/Services/AutostartService.cs b/src/EbbesMemeClipboard/Services/AutostartService.cs new file mode 100644 index 0000000..62590b9 --- /dev/null +++ b/src/EbbesMemeClipboard/Services/AutostartService.cs @@ -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); + } + } + + /// + /// 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. + /// + private static string ExecutablePath => Environment.ProcessPath ?? string.Empty; +} diff --git a/src/EbbesMemeClipboard/Services/ClipboardService.cs b/src/EbbesMemeClipboard/Services/ClipboardService.cs index 9475b80..f393b44 100644 --- a/src/EbbesMemeClipboard/Services/ClipboardService.cs +++ b/src/EbbesMemeClipboard/Services/ClipboardService.cs @@ -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. - using var pngStream = new MemoryStream(); - var pngEncoder = new PngBitmapEncoder(); - pngEncoder.Frames.Add(BitmapFrame.Create(firstFrame)); - pngEncoder.Save(pngStream); - pngStream.Position = 0; - data.SetData("PNG", pngStream); + // + // Deliberately skipped for GIFs: a PNG can only ever hold one static frame, and since + // Chromium PREFERS this format over all others, offering it for a GIF is what makes + // animated GIFs paste as a still first frame. Omitting it lets those targets fall + // through to CF_HTML below, which carries the full animated data URI. + if (!ImageDecoding.IsGif(filePath)) + { + using var pngStream = new MemoryStream(); + var pngEncoder = new PngBitmapEncoder(); + pngEncoder.Frames.Add(BitmapFrame.Create(firstFrame)); + pngEncoder.Save(pngStream); + pngStream.Position = 0; + data.SetData("PNG", pngStream); + } // CF_HTML: the other route those same web-based paste targets check, and the only one of // these formats that preserves animation for them, for GIFs. diff --git a/src/EbbesMemeClipboard/Services/FavoritesService.cs b/src/EbbesMemeClipboard/Services/FavoritesService.cs new file mode 100644 index 0000000..104671c --- /dev/null +++ b/src/EbbesMemeClipboard/Services/FavoritesService.cs @@ -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 _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 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 Load() + { + if (!File.Exists(_path)) return new List(); + + try + { + var json = File.ReadAllText(_path); + return JsonSerializer.Deserialize>(json, _jsonOptions) ?? new List(); + } + catch (JsonException) + { + // Corrupt file: start clean rather than blocking the whole picker from opening. + return new List(); + } + } + + private async Task SaveAsync() + { + var json = JsonSerializer.Serialize(_records, _jsonOptions); + await File.WriteAllTextAsync(_path, json); + } +} diff --git a/src/EbbesMemeClipboard/Services/GifCacheService.cs b/src/EbbesMemeClipboard/Services/GifCacheService.cs new file mode 100644 index 0000000..3823fd5 --- /dev/null +++ b/src/EbbesMemeClipboard/Services/GifCacheService.cs @@ -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 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 DownloadPreviewAsync(GifSearchResult result, CancellationToken ct) => + _http.GetByteArrayAsync(result.PreviewUrl, ct); +} diff --git a/src/EbbesMemeClipboard/Services/GiphyGifProvider.cs b/src/EbbesMemeClipboard/Services/GiphyGifProvider.cs new file mode 100644 index 0000000..9e7e1eb --- /dev/null +++ b/src/EbbesMemeClipboard/Services/GiphyGifProvider.cs @@ -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> SearchAsync(string query, int limit, CancellationToken ct) + { + if (!IsConfigured) return Array.Empty(); + + 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(cancellationToken: ct); + if (payload?.Data is null) return Array.Empty(); + + 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? 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; } + } +} diff --git a/src/EbbesMemeClipboard/Services/IAutostartService.cs b/src/EbbesMemeClipboard/Services/IAutostartService.cs new file mode 100644 index 0000000..66f94e1 --- /dev/null +++ b/src/EbbesMemeClipboard/Services/IAutostartService.cs @@ -0,0 +1,10 @@ +namespace EbbesMemeClipboard.Services; + +public interface IAutostartService +{ + /// 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. + bool IsEnabled { get; } + + void SetEnabled(bool enabled); +} diff --git a/src/EbbesMemeClipboard/Services/IFavoritesService.cs b/src/EbbesMemeClipboard/Services/IFavoritesService.cs new file mode 100644 index 0000000..8b330b8 --- /dev/null +++ b/src/EbbesMemeClipboard/Services/IFavoritesService.cs @@ -0,0 +1,11 @@ +using EbbesMemeClipboard.Models; + +namespace EbbesMemeClipboard.Services; + +public interface IFavoritesService +{ + IReadOnlyList GetForSource(MemeSource source); + bool IsFavorite(MemeSource source, string key); + Task AddAsync(FavoriteRecord record); + Task RemoveAsync(MemeSource source, string key); +} diff --git a/src/EbbesMemeClipboard/Services/IGifCacheService.cs b/src/EbbesMemeClipboard/Services/IGifCacheService.cs new file mode 100644 index 0000000..ad7615a --- /dev/null +++ b/src/EbbesMemeClipboard/Services/IGifCacheService.cs @@ -0,0 +1,16 @@ +using EbbesMemeClipboard.Models; + +namespace EbbesMemeClipboard.Services; + +public interface IGifCacheService +{ + /// + /// 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. + /// + Task GetOrDownloadAsync(GifSearchResult result, CancellationToken ct); + + /// Downloads a preview rendition into memory for grid thumbnails. + Task DownloadPreviewAsync(GifSearchResult result, CancellationToken ct); +} diff --git a/src/EbbesMemeClipboard/Services/IGifProvider.cs b/src/EbbesMemeClipboard/Services/IGifProvider.cs new file mode 100644 index 0000000..ce4c431 --- /dev/null +++ b/src/EbbesMemeClipboard/Services/IGifProvider.cs @@ -0,0 +1,17 @@ +using EbbesMemeClipboard.Models; + +namespace EbbesMemeClipboard.Services; + +public interface IGifProvider +{ + MemeSource Source { get; } + + /// False when the provider isn't usable yet (e.g. no API key configured). + bool IsConfigured { get; } + + /// Shown in the UI when IsConfigured is false, explaining how to fix it. + string NotConfiguredMessage { get; } + + /// An empty query returns whatever the provider considers trending. + Task> SearchAsync(string query, int limit, CancellationToken ct); +} diff --git a/src/EbbesMemeClipboard/Services/ImageDecoding.cs b/src/EbbesMemeClipboard/Services/ImageDecoding.cs index 3183d71..f0b5513 100644 --- a/src/EbbesMemeClipboard/Services/ImageDecoding.cs +++ b/src/EbbesMemeClipboard/Services/ImageDecoding.cs @@ -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); /// - /// 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. /// @@ -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; } + + /// Same as DecodeFirstFrame but for bytes already in memory (remote previews). + 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); + } + + /// + /// 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. + /// + 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; + } } diff --git a/src/EbbesMemeClipboard/Styles/DarkScrollBarStyles.xaml b/src/EbbesMemeClipboard/Styles/DarkScrollBarStyles.xaml new file mode 100644 index 0000000..1b9cfec --- /dev/null +++ b/src/EbbesMemeClipboard/Styles/DarkScrollBarStyles.xaml @@ -0,0 +1,104 @@ + + + + + + + + + + + + diff --git a/src/EbbesMemeClipboard/ViewModels/MemeTileViewModel.cs b/src/EbbesMemeClipboard/ViewModels/MemeTileViewModel.cs index c6f49cd..9498cba 100644 --- a/src/EbbesMemeClipboard/ViewModels/MemeTileViewModel.cs +++ b/src/EbbesMemeClipboard/ViewModels/MemeTileViewModel.cs @@ -5,26 +5,85 @@ using EbbesMemeClipboard.Services; namespace EbbesMemeClipboard.ViewModels; +/// +/// 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. +/// 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; + + /// Stable identity for favouriting: the library Id locally, the provider Id remotely. + 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; + } + + /// + /// 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. + /// + public async Task 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. + } } } diff --git a/src/EbbesMemeClipboard/ViewModels/PickerViewModel.cs b/src/EbbesMemeClipboard/ViewModels/PickerViewModel.cs index 685758b..f63a86b 100644 --- a/src/EbbesMemeClipboard/ViewModels/PickerViewModel.cs +++ b/src/EbbesMemeClipboard/ViewModels/PickerViewModel.cs @@ -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 _providers; + + /// Cancels the in-flight remote search when a newer keystroke supersedes it. + private CancellationTokenSource? _searchCts; public ObservableCollection Items { get; } = new(); + /// Favourites for the currently active source only. + public ObservableCollection Favorites { get; } = new(); + [ObservableProperty] private string _searchText = string.Empty; [ObservableProperty] private string _statusMessage = string.Empty; - /// - /// 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. - /// + [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 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; + } + + /// Keeps the star state on the main grid in step with the favourites store. + private void SyncFavoriteFlags() + { + foreach (var tile in Items) + { + tile.IsFavorite = _favorites.IsFavorite(tile.Source, tile.FavoriteKey); + } } } diff --git a/src/EbbesMemeClipboard/ViewModels/SettingsViewModel.cs b/src/EbbesMemeClipboard/ViewModels/SettingsViewModel.cs index a718b55..46adaf7 100644 --- a/src/EbbesMemeClipboard/ViewModels/SettingsViewModel.cs +++ b/src/EbbesMemeClipboard/ViewModels/SettingsViewModel.cs @@ -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(); + } + + /// Called when the settings window is shown, to re-sync externally-changed state. + public void Refresh() => AutostartEnabled = _autostart.IsEnabled; + public void TrySetHotkey(ModifierKeys modifiers, Key key) { if (_hotkeyService.Register(modifiers, key)) diff --git a/src/EbbesMemeClipboard/Views/PickerWindow.xaml b/src/EbbesMemeClipboard/Views/PickerWindow.xaml index 73daaa4..d6dc95a 100644 --- a/src/EbbesMemeClipboard/Views/PickerWindow.xaml +++ b/src/EbbesMemeClipboard/Views/PickerWindow.xaml @@ -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"> + + + + + + + + + + + + + + + - + - + + - + - + + + + - + + - - - - + + + + + + + + + + + + + + + + - + + + + + + + + + diff --git a/src/EbbesMemeClipboard/Views/SettingsWindow.xaml b/src/EbbesMemeClipboard/Views/SettingsWindow.xaml index 90f3f1c..ff00c51 100644 --- a/src/EbbesMemeClipboard/Views/SettingsWindow.xaml +++ b/src/EbbesMemeClipboard/Views/SettingsWindow.xaml @@ -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}"/> + + + + + + + + + diff --git a/src/EbbesMemeClipboard/Views/SettingsWindow.xaml.cs b/src/EbbesMemeClipboard/Views/SettingsWindow.xaml.cs index fe51757..165611d 100644 --- a/src/EbbesMemeClipboard/Views/SettingsWindow.xaml.cs +++ b/src/EbbesMemeClipboard/Views/SettingsWindow.xaml.cs @@ -16,6 +16,14 @@ public partial class SettingsWindow : Window DataContext = _viewModel; } + /// Re-reads live state (e.g. the autostart registry entry) each time it's opened. + public void ShowSettings() + { + _viewModel.Refresh(); + Show(); + Activate(); + } + private void HotkeyBox_OnPreviewKeyDown(object sender, KeyEventArgs e) { e.Handled = true;