diff --git a/.gitignore b/.gitignore
index 8c2b884..7d2e575 100644
--- a/.gitignore
+++ b/.gitignore
@@ -12,3 +12,12 @@
# Built Visual Studio Code Extensions
*.vsix
+# ---> dotnet / Visual Studio
+bin/
+obj/
+*.user
+publish/
+
+# Local app data seeded during manual testing
+diag.log
+
diff --git a/EbbesMemeClipboard.slnx b/EbbesMemeClipboard.slnx
new file mode 100644
index 0000000..564278a
--- /dev/null
+++ b/EbbesMemeClipboard.slnx
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/src/EbbesMemeClipboard/App.xaml b/src/EbbesMemeClipboard/App.xaml
new file mode 100644
index 0000000..915156f
--- /dev/null
+++ b/src/EbbesMemeClipboard/App.xaml
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/EbbesMemeClipboard/App.xaml.cs b/src/EbbesMemeClipboard/App.xaml.cs
new file mode 100644
index 0000000..6089095
--- /dev/null
+++ b/src/EbbesMemeClipboard/App.xaml.cs
@@ -0,0 +1,73 @@
+using System.Windows;
+using System.Windows.Input;
+using EbbesMemeClipboard.Services;
+using EbbesMemeClipboard.ViewModels;
+using EbbesMemeClipboard.Views;
+using H.NotifyIcon;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace EbbesMemeClipboard;
+
+public partial class App : Application
+{
+ private static readonly ModifierKeys DefaultHotkeyModifiers = ModifierKeys.Control | ModifierKeys.Alt;
+ private const Key DefaultHotkeyKey = Key.M;
+
+ private IServiceProvider _services = null!;
+ private IGlobalHotkeyService _hotkeyService = null!;
+ private PickerWindow _pickerWindow = null!;
+ private PickerViewModel _pickerViewModel = null!;
+ private TaskbarIcon? _trayIcon;
+
+ protected override void OnStartup(StartupEventArgs e)
+ {
+ base.OnStartup(e);
+ ShutdownMode = ShutdownMode.OnExplicitShutdown;
+
+ var services = new ServiceCollection();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ _services = services.BuildServiceProvider();
+
+ _pickerViewModel = _services.GetRequiredService();
+ _pickerWindow = _services.GetRequiredService();
+
+ _trayIcon = (TaskbarIcon)Resources["TrayIcon"];
+ _trayIcon.TrayLeftMouseUp += (_, _) => _pickerWindow.ToggleVisibility();
+
+ _hotkeyService = _services.GetRequiredService();
+ if (_hotkeyService.Register(DefaultHotkeyModifiers, DefaultHotkeyKey))
+ {
+ _hotkeyService.HotkeyPressed += (_, _) => Dispatcher.Invoke(() => _pickerWindow.ToggleVisibility());
+ }
+ else
+ {
+ MessageBox.Show(
+ "Couldn't register the global hotkey (Ctrl+Alt+M) - it may already be in use by another application. " +
+ "You can still open the picker from the tray icon.",
+ "Ebbe's Meme Clipboard",
+ MessageBoxButton.OK,
+ MessageBoxImage.Warning);
+ }
+ }
+
+ private void TrayOpenPicker_Click(object sender, RoutedEventArgs e) => _pickerWindow.ShowNearCursor();
+
+ private async void TrayAddMemes_Click(object sender, RoutedEventArgs e)
+ {
+ _pickerWindow.ShowNearCursor();
+ await _pickerViewModel.ImportFilesCommand.ExecuteAsync(null);
+ }
+
+ private void TrayExit_Click(object sender, RoutedEventArgs e) => Shutdown();
+
+ protected override void OnExit(ExitEventArgs e)
+ {
+ _hotkeyService?.Dispose();
+ _trayIcon?.Dispose();
+ base.OnExit(e);
+ }
+}
diff --git a/src/EbbesMemeClipboard/AssemblyInfo.cs b/src/EbbesMemeClipboard/AssemblyInfo.cs
new file mode 100644
index 0000000..cc29e7f
--- /dev/null
+++ b/src/EbbesMemeClipboard/AssemblyInfo.cs
@@ -0,0 +1,10 @@
+using System.Windows;
+
+[assembly:ThemeInfo(
+ ResourceDictionaryLocation.None, //where theme specific resource dictionaries are located
+ //(used if a resource is not found in the page,
+ // or application resource dictionaries)
+ ResourceDictionaryLocation.SourceAssembly //where the generic resource dictionary is located
+ //(used if a resource is not found in the page,
+ // app, or any theme specific resource dictionaries)
+)]
diff --git a/src/EbbesMemeClipboard/Assets/tray-icon.ico b/src/EbbesMemeClipboard/Assets/tray-icon.ico
new file mode 100644
index 0000000..c62d7a7
Binary files /dev/null and b/src/EbbesMemeClipboard/Assets/tray-icon.ico differ
diff --git a/src/EbbesMemeClipboard/EbbesMemeClipboard.csproj b/src/EbbesMemeClipboard/EbbesMemeClipboard.csproj
new file mode 100644
index 0000000..16ee60f
--- /dev/null
+++ b/src/EbbesMemeClipboard/EbbesMemeClipboard.csproj
@@ -0,0 +1,39 @@
+
+
+
+ WinExe
+ net10.0-windows
+ enable
+ enable
+ true
+ true
+ app.manifest
+ EbbesMemeClipboard
+ EbbesMemeClipboard
+ Assets\tray-icon.ico
+
+ $(NoWarn);WFO0003
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/EbbesMemeClipboard/Models/LocalMemeRecord.cs b/src/EbbesMemeClipboard/Models/LocalMemeRecord.cs
new file mode 100644
index 0000000..071d26f
--- /dev/null
+++ b/src/EbbesMemeClipboard/Models/LocalMemeRecord.cs
@@ -0,0 +1,9 @@
+namespace EbbesMemeClipboard.Models;
+
+public sealed class LocalMemeRecord
+{
+ public required string Id { get; init; }
+ public required string FileName { get; init; }
+ public required string OriginalFileName { get; init; }
+ public required DateTimeOffset DateAdded { get; init; }
+}
diff --git a/src/EbbesMemeClipboard/Native/HotkeyInterop.cs b/src/EbbesMemeClipboard/Native/HotkeyInterop.cs
new file mode 100644
index 0000000..109c18d
--- /dev/null
+++ b/src/EbbesMemeClipboard/Native/HotkeyInterop.cs
@@ -0,0 +1,14 @@
+namespace EbbesMemeClipboard.Native;
+
+internal static class HotkeyConstants
+{
+ internal const int WM_HOTKEY = 0x0312;
+
+ internal const uint MOD_ALT = 0x0001;
+ internal const uint MOD_CONTROL = 0x0002;
+ internal const uint MOD_SHIFT = 0x0004;
+ internal const uint MOD_WIN = 0x0008;
+
+ // Prevents the shell from re-firing WM_HOTKEY on key-repeat while the combo is held down.
+ internal const uint MOD_NOREPEAT = 0x4000;
+}
diff --git a/src/EbbesMemeClipboard/Native/NativeMethods.cs b/src/EbbesMemeClipboard/Native/NativeMethods.cs
new file mode 100644
index 0000000..dcb1486
--- /dev/null
+++ b/src/EbbesMemeClipboard/Native/NativeMethods.cs
@@ -0,0 +1,12 @@
+using System.Runtime.InteropServices;
+
+namespace EbbesMemeClipboard.Native;
+
+internal static class NativeMethods
+{
+ [DllImport("user32.dll", SetLastError = true)]
+ internal static extern bool RegisterHotKey(IntPtr hWnd, int id, uint fsModifiers, uint vk);
+
+ [DllImport("user32.dll", SetLastError = true)]
+ internal static extern bool UnregisterHotKey(IntPtr hWnd, int id);
+}
diff --git a/src/EbbesMemeClipboard/Services/ClipboardService.cs b/src/EbbesMemeClipboard/Services/ClipboardService.cs
new file mode 100644
index 0000000..6eb0cd7
--- /dev/null
+++ b/src/EbbesMemeClipboard/Services/ClipboardService.cs
@@ -0,0 +1,82 @@
+using System.Collections.Specialized;
+using System.IO;
+using System.Runtime.InteropServices;
+using System.Text;
+using System.Windows;
+
+namespace EbbesMemeClipboard.Services;
+
+public sealed class ClipboardService : IClipboardService
+{
+ public async Task CopyLocalFileAsync(string filePath)
+ {
+ bool isGif = ImageDecoding.IsGif(filePath);
+
+ var firstFrame = await Task.Run(() => ImageDecoding.DecodeFirstFrame(filePath));
+
+ var data = new DataObject();
+
+ // CF_HDROP: lets Discord/Slack/Teams/Explorer paste the real file (animated, for GIFs).
+ data.SetFileDropList(new StringCollection { filePath });
+
+ // CF_DIB fallback: apps that only accept bitmap paste (e.g. Paint) get a static image.
+ // WPF's DataObject.SetImage auto-converts to CF_DIB - no manual header work needed here.
+ data.SetImage(firstFrame);
+
+ if (isGif)
+ {
+ // CF_HTML: the format most browser/rich-text paste targets use, and the only one of
+ // the three that preserves animation for those targets (CF_DIB is a single frame).
+ var bytes = await File.ReadAllBytesAsync(filePath);
+ var fragment = $"
";
+ data.SetData(DataFormats.Html, BuildCfHtml(fragment));
+ }
+
+ await SetClipboardWithRetryAsync(data);
+ }
+
+ private static async Task SetClipboardWithRetryAsync(DataObject data)
+ {
+ const int maxAttempts = 5;
+ for (int attempt = 1; attempt <= maxAttempts; attempt++)
+ {
+ try
+ {
+ Clipboard.SetDataObject(data, copy: true);
+ return;
+ }
+ catch (COMException) when (attempt < maxAttempts)
+ {
+ // Another process is transiently holding the clipboard - back off and retry.
+ await Task.Delay(75);
+ }
+ }
+ }
+
+ ///
+ /// Builds a CF_HTML clipboard payload. WPF's DataObject.SetData(DataFormats.Html, ...) does
+ /// NOT wrap plain HTML into CF_HTML automatically - the Version/StartHTML/EndHTML/
+ /// StartFragment/EndFragment header with exact byte offsets has to be built by hand, or
+ /// paste targets will reject or mis-render the fragment.
+ ///
+ private static string BuildCfHtml(string htmlFragment)
+ {
+ const string prefix = "";
+ const string suffix = "";
+
+ int headerLength = Encoding.UTF8.GetByteCount(FormatHeader(0, 0, 0, 0));
+ int startHtml = headerLength;
+ int startFragment = startHtml + Encoding.UTF8.GetByteCount(prefix);
+ int endFragment = startFragment + Encoding.UTF8.GetByteCount(htmlFragment);
+ int endHtml = endFragment + Encoding.UTF8.GetByteCount(suffix);
+
+ return FormatHeader(startHtml, endHtml, startFragment, endFragment) + prefix + htmlFragment + suffix;
+ }
+
+ private static string FormatHeader(int startHtml, int endHtml, int startFragment, int endFragment) =>
+ "Version:0.9\r\n" +
+ $"StartHTML:{startHtml:D10}\r\n" +
+ $"EndHTML:{endHtml:D10}\r\n" +
+ $"StartFragment:{startFragment:D10}\r\n" +
+ $"EndFragment:{endFragment:D10}\r\n";
+}
diff --git a/src/EbbesMemeClipboard/Services/GlobalHotkeyService.cs b/src/EbbesMemeClipboard/Services/GlobalHotkeyService.cs
new file mode 100644
index 0000000..aa1999d
--- /dev/null
+++ b/src/EbbesMemeClipboard/Services/GlobalHotkeyService.cs
@@ -0,0 +1,74 @@
+using System.Windows.Input;
+using System.Windows.Interop;
+using EbbesMemeClipboard.Native;
+
+namespace EbbesMemeClipboard.Services;
+
+public sealed class GlobalHotkeyService : IGlobalHotkeyService
+{
+ private const int HotkeyId = 0xB001;
+ private const int HwndMessage = -3;
+
+ private readonly HwndSource _messageSource;
+ private bool _registered;
+
+ public event EventHandler? HotkeyPressed;
+
+ public GlobalHotkeyService()
+ {
+ var parameters = new HwndSourceParameters("EbbesMemeClipboardHotkeyWindow")
+ {
+ WindowStyle = 0,
+ ParentWindow = new IntPtr(HwndMessage),
+ };
+ _messageSource = new HwndSource(parameters);
+ _messageSource.AddHook(WndProc);
+ }
+
+ public bool Register(ModifierKeys modifiers, Key key)
+ {
+ Unregister();
+
+ uint nativeModifiers = ToNativeModifiers(modifiers) | HotkeyConstants.MOD_NOREPEAT;
+ uint virtualKey = (uint)KeyInterop.VirtualKeyFromKey(key);
+
+ _registered = NativeMethods.RegisterHotKey(_messageSource.Handle, HotkeyId, nativeModifiers, virtualKey);
+ return _registered;
+ }
+
+ public void Unregister()
+ {
+ if (_registered)
+ {
+ NativeMethods.UnregisterHotKey(_messageSource.Handle, HotkeyId);
+ _registered = false;
+ }
+ }
+
+ private IntPtr WndProc(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled)
+ {
+ if (msg == HotkeyConstants.WM_HOTKEY && wParam.ToInt32() == HotkeyId)
+ {
+ HotkeyPressed?.Invoke(this, EventArgs.Empty);
+ handled = true;
+ }
+ return IntPtr.Zero;
+ }
+
+ private static uint ToNativeModifiers(ModifierKeys modifiers)
+ {
+ uint result = 0;
+ if (modifiers.HasFlag(ModifierKeys.Alt)) result |= HotkeyConstants.MOD_ALT;
+ if (modifiers.HasFlag(ModifierKeys.Control)) result |= HotkeyConstants.MOD_CONTROL;
+ if (modifiers.HasFlag(ModifierKeys.Shift)) result |= HotkeyConstants.MOD_SHIFT;
+ if (modifiers.HasFlag(ModifierKeys.Windows)) result |= HotkeyConstants.MOD_WIN;
+ return result;
+ }
+
+ public void Dispose()
+ {
+ Unregister();
+ _messageSource.RemoveHook(WndProc);
+ _messageSource.Dispose();
+ }
+}
diff --git a/src/EbbesMemeClipboard/Services/IClipboardService.cs b/src/EbbesMemeClipboard/Services/IClipboardService.cs
new file mode 100644
index 0000000..482b220
--- /dev/null
+++ b/src/EbbesMemeClipboard/Services/IClipboardService.cs
@@ -0,0 +1,11 @@
+namespace EbbesMemeClipboard.Services;
+
+public interface IClipboardService
+{
+ ///
+ /// Places a local file on the clipboard using multiple formats simultaneously so both
+ /// file-paste apps (Discord/Slack/Teams) and bitmap-only apps (Paint) get something
+ /// useful, and animated GIFs survive as animations wherever possible.
+ ///
+ Task CopyLocalFileAsync(string filePath);
+}
diff --git a/src/EbbesMemeClipboard/Services/IGlobalHotkeyService.cs b/src/EbbesMemeClipboard/Services/IGlobalHotkeyService.cs
new file mode 100644
index 0000000..c5cc69a
--- /dev/null
+++ b/src/EbbesMemeClipboard/Services/IGlobalHotkeyService.cs
@@ -0,0 +1,13 @@
+using System.Windows.Input;
+
+namespace EbbesMemeClipboard.Services;
+
+public interface IGlobalHotkeyService : IDisposable
+{
+ event EventHandler? HotkeyPressed;
+
+ /// Returns false if the combo is already claimed by the OS or another app.
+ bool Register(ModifierKeys modifiers, Key key);
+
+ void Unregister();
+}
diff --git a/src/EbbesMemeClipboard/Services/ILocalMemeLibraryService.cs b/src/EbbesMemeClipboard/Services/ILocalMemeLibraryService.cs
new file mode 100644
index 0000000..80c0474
--- /dev/null
+++ b/src/EbbesMemeClipboard/Services/ILocalMemeLibraryService.cs
@@ -0,0 +1,11 @@
+using EbbesMemeClipboard.Models;
+
+namespace EbbesMemeClipboard.Services;
+
+public interface ILocalMemeLibraryService
+{
+ IReadOnlyList GetAll();
+ IReadOnlyList Search(string query);
+ string GetFullPath(LocalMemeRecord record);
+ Task> ImportAsync(IEnumerable sourceFilePaths);
+}
diff --git a/src/EbbesMemeClipboard/Services/ImageDecoding.cs b/src/EbbesMemeClipboard/Services/ImageDecoding.cs
new file mode 100644
index 0000000..3183d71
--- /dev/null
+++ b/src/EbbesMemeClipboard/Services/ImageDecoding.cs
@@ -0,0 +1,38 @@
+using System.IO;
+using System.Windows.Media.Imaging;
+
+namespace EbbesMemeClipboard.Services;
+
+internal static class ImageDecoding
+{
+ internal static bool IsGif(string path) =>
+ Path.GetExtension(path).Equals(".gif", StringComparison.OrdinalIgnoreCase);
+
+ ///
+ /// Decodes just the first frame of an image (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.
+ ///
+ internal static BitmapSource DecodeFirstFrame(string path, int? decodePixelWidth = null)
+ {
+ if (IsGif(path))
+ {
+ var decoder = new GifBitmapDecoder(new Uri(path), BitmapCreateOptions.PreservePixelFormat, BitmapCacheOption.OnLoad);
+ var frame = decoder.Frames[0];
+ frame.Freeze();
+ return frame;
+ }
+
+ var bitmap = new BitmapImage();
+ bitmap.BeginInit();
+ bitmap.UriSource = new Uri(path);
+ bitmap.CacheOption = BitmapCacheOption.OnLoad;
+ if (decodePixelWidth is int width)
+ {
+ bitmap.DecodePixelWidth = width;
+ }
+ bitmap.EndInit();
+ bitmap.Freeze();
+ return bitmap;
+ }
+}
diff --git a/src/EbbesMemeClipboard/Services/LocalMemeLibraryService.cs b/src/EbbesMemeClipboard/Services/LocalMemeLibraryService.cs
new file mode 100644
index 0000000..ff7e6a6
--- /dev/null
+++ b/src/EbbesMemeClipboard/Services/LocalMemeLibraryService.cs
@@ -0,0 +1,101 @@
+using System.IO;
+using System.Text.Json;
+using EbbesMemeClipboard.Models;
+
+namespace EbbesMemeClipboard.Services;
+
+public sealed class LocalMemeLibraryService : ILocalMemeLibraryService
+{
+ private static readonly HashSet AllowedExtensions = new(StringComparer.OrdinalIgnoreCase)
+ {
+ ".jpg", ".jpeg", ".png", ".gif",
+ };
+
+ private readonly string _libraryRoot;
+ private readonly string _indexPath;
+ private readonly List _records;
+ private readonly JsonSerializerOptions _jsonOptions = new() { WriteIndented = true };
+
+ public LocalMemeLibraryService()
+ {
+ var appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
+ _libraryRoot = Path.Combine(appData, "EbbesMemeClipboard", "Library");
+ Directory.CreateDirectory(_libraryRoot);
+ _indexPath = Path.Combine(_libraryRoot, "index.json");
+ _records = LoadIndex();
+ }
+
+ public IReadOnlyList GetAll() =>
+ _records.OrderByDescending(r => r.DateAdded).ToList();
+
+ public IReadOnlyList Search(string query) =>
+ _records
+ .Where(r => r.OriginalFileName.Contains(query, StringComparison.OrdinalIgnoreCase))
+ .OrderByDescending(r => r.DateAdded)
+ .ToList();
+
+ public string GetFullPath(LocalMemeRecord record) => Path.Combine(_libraryRoot, record.FileName);
+
+ public async Task> ImportAsync(IEnumerable sourceFilePaths)
+ {
+ var imported = new List();
+
+ foreach (var sourcePath in sourceFilePaths)
+ {
+ var extension = Path.GetExtension(sourcePath);
+ if (!AllowedExtensions.Contains(extension) || !File.Exists(sourcePath))
+ continue;
+
+ var storedFileName = $"{Guid.NewGuid():N}{extension.ToLowerInvariant()}";
+ var destinationPath = Path.Combine(_libraryRoot, storedFileName);
+
+ using (var sourceStream = File.OpenRead(sourcePath))
+ using (var destStream = File.Create(destinationPath))
+ {
+ await sourceStream.CopyToAsync(destStream);
+ }
+
+ var record = new LocalMemeRecord
+ {
+ Id = Guid.NewGuid().ToString("N"),
+ FileName = storedFileName,
+ OriginalFileName = Path.GetFileName(sourcePath),
+ DateAdded = DateTimeOffset.Now,
+ };
+
+ _records.Add(record);
+ imported.Add(record);
+ }
+
+ if (imported.Count > 0)
+ {
+ await SaveIndexAsync();
+ }
+
+ return imported;
+ }
+
+ private List LoadIndex()
+ {
+ if (!File.Exists(_indexPath))
+ return new List();
+
+ try
+ {
+ var json = File.ReadAllText(_indexPath);
+ return JsonSerializer.Deserialize>(json, _jsonOptions) ?? new List();
+ }
+ catch (JsonException)
+ {
+ // Corrupt index: start fresh rather than crashing. Previously imported files stay
+ // on disk but drop out of search until re-imported.
+ return new List();
+ }
+ }
+
+ private async Task SaveIndexAsync()
+ {
+ var json = JsonSerializer.Serialize(_records, _jsonOptions);
+ await File.WriteAllTextAsync(_indexPath, json);
+ }
+}
diff --git a/src/EbbesMemeClipboard/ViewModels/MemeTileViewModel.cs b/src/EbbesMemeClipboard/ViewModels/MemeTileViewModel.cs
new file mode 100644
index 0000000..c6f49cd
--- /dev/null
+++ b/src/EbbesMemeClipboard/ViewModels/MemeTileViewModel.cs
@@ -0,0 +1,30 @@
+using System.Windows.Media.Imaging;
+using CommunityToolkit.Mvvm.ComponentModel;
+using EbbesMemeClipboard.Models;
+using EbbesMemeClipboard.Services;
+
+namespace EbbesMemeClipboard.ViewModels;
+
+public partial class MemeTileViewModel : ObservableObject
+{
+ private const int ThumbnailPixelWidth = 150;
+
+ public LocalMemeRecord Record { get; }
+ public string FullPath { get; }
+ public string DisplayName => Record.OriginalFileName;
+
+ [ObservableProperty]
+ private BitmapSource? _thumbnail;
+
+ public MemeTileViewModel(LocalMemeRecord record, string fullPath)
+ {
+ Record = record;
+ FullPath = fullPath;
+ }
+
+ public async Task LoadThumbnailAsync()
+ {
+ var path = FullPath;
+ Thumbnail = await Task.Run(() => ImageDecoding.DecodeFirstFrame(path, ThumbnailPixelWidth));
+ }
+}
diff --git a/src/EbbesMemeClipboard/ViewModels/PickerViewModel.cs b/src/EbbesMemeClipboard/ViewModels/PickerViewModel.cs
new file mode 100644
index 0000000..0ef4b06
--- /dev/null
+++ b/src/EbbesMemeClipboard/ViewModels/PickerViewModel.cs
@@ -0,0 +1,103 @@
+using System.Collections.ObjectModel;
+using System.IO;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using EbbesMemeClipboard.Services;
+
+namespace EbbesMemeClipboard.ViewModels;
+
+public partial class PickerViewModel : ObservableObject
+{
+ private readonly ILocalMemeLibraryService _library;
+ private readonly IClipboardService _clipboard;
+
+ public ObservableCollection Items { get; } = new();
+
+ [ObservableProperty]
+ private string _searchText = string.Empty;
+
+ [ObservableProperty]
+ private string _statusMessage = string.Empty;
+
+ public event EventHandler? RequestClose;
+
+ public PickerViewModel(ILocalMemeLibraryService library, IClipboardService clipboard)
+ {
+ _library = library;
+ _clipboard = clipboard;
+ RefreshItems();
+ }
+
+ partial void OnSearchTextChanged(string value) => RefreshItems();
+
+ [RelayCommand]
+ private async Task SelectItemAsync(MemeTileViewModel? tile)
+ {
+ if (tile is null) return;
+
+ try
+ {
+ await _clipboard.CopyLocalFileAsync(tile.FullPath);
+ StatusMessage = $"Copied {tile.DisplayName}";
+ RequestClose?.Invoke(this, EventArgs.Empty);
+ }
+ catch (IOException ex)
+ {
+ StatusMessage = $"Couldn't copy {tile.DisplayName}: {ex.Message}";
+ }
+ }
+
+ [RelayCommand]
+ private async Task ImportFilesAsync()
+ {
+ var dialog = new Microsoft.Win32.OpenFileDialog
+ {
+ Multiselect = true,
+ Filter = "Images and GIFs|*.jpg;*.jpeg;*.png;*.gif",
+ Title = "Add memes",
+ };
+
+ if (dialog.ShowDialog() == true)
+ {
+ await ImportPathsAsync(dialog.FileNames);
+ }
+ }
+
+ public async Task ImportPathsAsync(IEnumerable paths)
+ {
+ var candidates = paths
+ .Where(p => Path.GetExtension(p).ToLowerInvariant() is ".jpg" or ".jpeg" or ".png" or ".gif")
+ .ToList();
+
+ if (candidates.Count == 0)
+ {
+ StatusMessage = "No supported image/GIF files in that selection.";
+ return;
+ }
+
+ var imported = await _library.ImportAsync(candidates);
+ RefreshItems();
+ StatusMessage = $"Added {imported.Count} meme(s).";
+ }
+
+ public void OnShown()
+ {
+ SearchText = string.Empty;
+ RefreshItems();
+ }
+
+ private void RefreshItems()
+ {
+ var records = string.IsNullOrWhiteSpace(SearchText)
+ ? _library.GetAll()
+ : _library.Search(SearchText);
+
+ Items.Clear();
+ foreach (var record in records)
+ {
+ var tile = new MemeTileViewModel(record, _library.GetFullPath(record));
+ Items.Add(tile);
+ _ = tile.LoadThumbnailAsync();
+ }
+ }
+}
diff --git a/src/EbbesMemeClipboard/Views/PickerWindow.xaml b/src/EbbesMemeClipboard/Views/PickerWindow.xaml
new file mode 100644
index 0000000..330f17c
--- /dev/null
+++ b/src/EbbesMemeClipboard/Views/PickerWindow.xaml
@@ -0,0 +1,114 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/EbbesMemeClipboard/Views/PickerWindow.xaml.cs b/src/EbbesMemeClipboard/Views/PickerWindow.xaml.cs
new file mode 100644
index 0000000..d863d8e
--- /dev/null
+++ b/src/EbbesMemeClipboard/Views/PickerWindow.xaml.cs
@@ -0,0 +1,98 @@
+using System.ComponentModel;
+using System.Windows;
+using System.Windows.Input;
+using System.Windows.Media;
+using EbbesMemeClipboard.ViewModels;
+
+namespace EbbesMemeClipboard.Views;
+
+public partial class PickerWindow : Window
+{
+ private readonly PickerViewModel _viewModel;
+
+ public PickerWindow(PickerViewModel viewModel)
+ {
+ InitializeComponent();
+ _viewModel = viewModel;
+ DataContext = _viewModel;
+ _viewModel.RequestClose += (_, _) => Hide();
+ }
+
+ public void ShowNearCursor()
+ {
+ PositionNearCursor();
+ _viewModel.OnShown();
+ Show();
+ Activate();
+ SearchBox.Focus();
+ }
+
+ public void ToggleVisibility()
+ {
+ if (IsVisible)
+ {
+ Hide();
+ }
+ else
+ {
+ ShowNearCursor();
+ }
+ }
+
+ ///
+ /// Positions the popup near the mouse cursor, clamped to the current monitor's working
+ /// area. The real Windows Emoji Panel can follow the text caret because it's a privileged
+ /// shell component; a third-party app can't replicate that, so cursor position is the
+ /// practical stand-in. Uses a single DPI scale factor for the conversion from WinForms'
+ /// physical pixels to WPF's device-independent units, which is exact for same-DPI
+ /// multi-monitor setups (the common case) and only approximate when the cursor is on a
+ /// secondary monitor running a different DPI scale than the one this window last rendered on.
+ ///
+ private void PositionNearCursor()
+ {
+ var cursor = System.Windows.Forms.Cursor.Position;
+ var workingArea = System.Windows.Forms.Screen.FromPoint(cursor).WorkingArea;
+
+ double scale = VisualTreeHelper.GetDpi(this).DpiScaleX;
+
+ double left = cursor.X / scale;
+ double top = cursor.Y / scale;
+ double maxLeft = (workingArea.Right / scale) - Width;
+ double maxTop = (workingArea.Bottom / scale) - Height;
+ double minLeft = workingArea.Left / scale;
+ double minTop = workingArea.Top / scale;
+
+ Left = Math.Clamp(left, minLeft, Math.Max(minLeft, maxLeft));
+ Top = Math.Clamp(top, minTop, Math.Max(minTop, maxTop));
+ }
+
+ private void PickerWindow_OnDeactivated(object? sender, EventArgs e) => Hide();
+
+ private void PickerWindow_OnKeyDown(object sender, KeyEventArgs e)
+ {
+ if (e.Key == Key.Escape) Hide();
+ }
+
+ private void PickerWindow_OnDragOver(object sender, DragEventArgs e)
+ {
+ e.Effects = e.Data.GetDataPresent(DataFormats.FileDrop) ? DragDropEffects.Copy : DragDropEffects.None;
+ e.Handled = true;
+ }
+
+ private async void PickerWindow_OnDrop(object sender, DragEventArgs e)
+ {
+ if (e.Data.GetData(DataFormats.FileDrop) is string[] paths)
+ {
+ await _viewModel.ImportPathsAsync(paths);
+ }
+ }
+
+ protected override void OnClosing(CancelEventArgs e)
+ {
+ // This window is reused for the app's whole lifetime - closing it (e.g. Alt+F4) should
+ // just hide it. Real shutdown only happens via the tray icon's Exit command.
+ e.Cancel = true;
+ Hide();
+ base.OnClosing(e);
+ }
+}
diff --git a/src/EbbesMemeClipboard/app.manifest b/src/EbbesMemeClipboard/app.manifest
new file mode 100644
index 0000000..ab00740
--- /dev/null
+++ b/src/EbbesMemeClipboard/app.manifest
@@ -0,0 +1,24 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ PerMonitorV2
+ true/PM
+ true
+
+
+