diff --git a/src/EbbesMemeClipboard/App.xaml b/src/EbbesMemeClipboard/App.xaml
index 915156f..b04b49e 100644
--- a/src/EbbesMemeClipboard/App.xaml
+++ b/src/EbbesMemeClipboard/App.xaml
@@ -3,18 +3,25 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:tb="clr-namespace:H.NotifyIcon;assembly=H.NotifyIcon.Wpf">
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/EbbesMemeClipboard/App.xaml.cs b/src/EbbesMemeClipboard/App.xaml.cs
index 6089095..4ad6394 100644
--- a/src/EbbesMemeClipboard/App.xaml.cs
+++ b/src/EbbesMemeClipboard/App.xaml.cs
@@ -1,5 +1,4 @@
using System.Windows;
-using System.Windows.Input;
using EbbesMemeClipboard.Services;
using EbbesMemeClipboard.ViewModels;
using EbbesMemeClipboard.Views;
@@ -10,13 +9,11 @@ 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 ISettingsService _settingsService = null!;
private PickerWindow _pickerWindow = null!;
- private PickerViewModel _pickerViewModel = null!;
+ private SettingsWindow _settingsWindow = null!;
private TaskbarIcon? _trayIcon;
protected override void OnStartup(StartupEventArgs e)
@@ -28,26 +25,45 @@ public partial class App : Application
services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
_services = services.BuildServiceProvider();
- _pickerViewModel = _services.GetRequiredService();
+ _settingsService = _services.GetRequiredService();
_pickerWindow = _services.GetRequiredService();
+ _settingsWindow = _services.GetRequiredService();
+
+ // Shell_NotifyIcon registration silently no-ops if no window in the process has a real
+ // Win32 handle yet, which is otherwise the case here until the picker is first shown -
+ // force the (still invisible) picker window's handle to exist before creating the tray
+ // icon. Found by comparing tray state via UI Automation with/without this line; there's
+ // no exception either way; the icon just never reaches the shell without it.
+ new System.Windows.Interop.WindowInteropHelper(_pickerWindow).EnsureHandle();
_trayIcon = (TaskbarIcon)Resources["TrayIcon"];
+ // TaskbarIcon normally creates its Win32 icon in response to its own Loaded event, which
+ // only fires for elements that are part of a visual tree. Declared here as an
+ // Application.Resources entry (so it lives for the whole app, with no host window
+ // needed), it never gets a Loaded event, so the icon has to be created explicitly.
+ _trayIcon.ForceCreate(enablesEfficiencyMode: false);
_trayIcon.TrayLeftMouseUp += (_, _) => _pickerWindow.ToggleVisibility();
_hotkeyService = _services.GetRequiredService();
- if (_hotkeyService.Register(DefaultHotkeyModifiers, DefaultHotkeyKey))
+ var hotkey = _settingsService.Current;
+ if (_hotkeyService.Register(hotkey.HotkeyModifiers, hotkey.HotkeyKey))
{
_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.",
+ $"Couldn't register the global hotkey ({HotkeyFormatter.Format(hotkey.HotkeyModifiers, hotkey.HotkeyKey)}) - " +
+ "it may already be in use by another application. You can still open the picker from the tray icon, " +
+ "or pick a different shortcut in Settings.",
"Ebbe's Meme Clipboard",
MessageBoxButton.OK,
MessageBoxImage.Warning);
@@ -56,10 +72,10 @@ public partial class App : Application
private void TrayOpenPicker_Click(object sender, RoutedEventArgs e) => _pickerWindow.ShowNearCursor();
- private async void TrayAddMemes_Click(object sender, RoutedEventArgs e)
+ private void TraySettings_Click(object sender, RoutedEventArgs e)
{
- _pickerWindow.ShowNearCursor();
- await _pickerViewModel.ImportFilesCommand.ExecuteAsync(null);
+ _settingsWindow.Show();
+ _settingsWindow.Activate();
}
private void TrayExit_Click(object sender, RoutedEventArgs e) => Shutdown();
diff --git a/src/EbbesMemeClipboard/Models/AppSettings.cs b/src/EbbesMemeClipboard/Models/AppSettings.cs
new file mode 100644
index 0000000..110522a
--- /dev/null
+++ b/src/EbbesMemeClipboard/Models/AppSettings.cs
@@ -0,0 +1,10 @@
+using System.Windows.Input;
+
+namespace EbbesMemeClipboard.Models;
+
+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;
+}
diff --git a/src/EbbesMemeClipboard/Models/InsertMode.cs b/src/EbbesMemeClipboard/Models/InsertMode.cs
new file mode 100644
index 0000000..07903c4
--- /dev/null
+++ b/src/EbbesMemeClipboard/Models/InsertMode.cs
@@ -0,0 +1,13 @@
+namespace EbbesMemeClipboard.Models;
+
+public enum InsertMode
+{
+ /// Just copy the meme to the clipboard; the user pastes it manually.
+ CopyOnly,
+
+ /// Copy to the clipboard, then paste it into whatever app was focused before the picker opened.
+ PasteIntoActiveWindow,
+
+ /// Same as PasteIntoActiveWindow, then also press Enter to submit/send it.
+ PasteAndSend,
+}
diff --git a/src/EbbesMemeClipboard/Native/InputSimulation.cs b/src/EbbesMemeClipboard/Native/InputSimulation.cs
new file mode 100644
index 0000000..a6b7ba9
--- /dev/null
+++ b/src/EbbesMemeClipboard/Native/InputSimulation.cs
@@ -0,0 +1,65 @@
+using System.Runtime.InteropServices;
+
+namespace EbbesMemeClipboard.Native;
+
+[StructLayout(LayoutKind.Sequential)]
+internal struct KEYBDINPUT
+{
+ internal ushort wVk;
+ internal ushort wScan;
+ internal uint dwFlags;
+ internal uint time;
+ internal IntPtr dwExtraInfo;
+}
+
+// The real Win32 INPUT union also has MOUSEINPUT/HARDWAREINPUT members, which are what actually
+// determine its size (32 bytes on x64). SendInput validates cbSize against that real size, so the
+// union must be padded to 32 bytes even though only the keyboard member is used here - a smaller
+// struct silently fails every call with ERROR_INVALID_PARAMETER.
+[StructLayout(LayoutKind.Explicit, Size = 32)]
+internal struct InputUnion
+{
+ [FieldOffset(0)] internal KEYBDINPUT ki;
+}
+
+[StructLayout(LayoutKind.Sequential)]
+internal struct INPUT
+{
+ internal uint type;
+ internal InputUnion U;
+}
+
+internal static class InputSimulation
+{
+ internal const ushort VK_CONTROL = 0x11;
+ internal const ushort VK_MENU = 0x12;
+ internal const ushort VK_RETURN = 0x0D;
+ internal const ushort VK_V = 0x56;
+
+ private const uint InputKeyboard = 1;
+ private const uint KeyEventFKeyUp = 0x0002;
+
+ internal static void SendKeyTap(ushort vk) => Send(Down(vk), Up(vk));
+
+ internal static void SendKeyCombo(ushort vk1, ushort vk2) => Send(Down(vk1), Down(vk2), Up(vk2), Up(vk1));
+
+ private static void Send(params INPUT[] inputs) =>
+ NativeMethods.SendInput((uint)inputs.Length, inputs, Marshal.SizeOf(typeof(INPUT)));
+
+ private static INPUT Down(ushort vk) => KeyInput(vk, up: false);
+
+ private static INPUT Up(ushort vk) => KeyInput(vk, up: true);
+
+ private static INPUT KeyInput(ushort vk, bool up)
+ {
+ var ki = new KEYBDINPUT
+ {
+ wVk = vk,
+ wScan = 0,
+ dwFlags = up ? KeyEventFKeyUp : 0,
+ time = 0,
+ dwExtraInfo = IntPtr.Zero,
+ };
+ return new INPUT { type = InputKeyboard, U = new InputUnion { ki = ki } };
+ }
+}
diff --git a/src/EbbesMemeClipboard/Native/MonitorInterop.cs b/src/EbbesMemeClipboard/Native/MonitorInterop.cs
new file mode 100644
index 0000000..57fca26
--- /dev/null
+++ b/src/EbbesMemeClipboard/Native/MonitorInterop.cs
@@ -0,0 +1,39 @@
+using System.Runtime.InteropServices;
+
+namespace EbbesMemeClipboard.Native;
+
+[StructLayout(LayoutKind.Sequential)]
+internal struct POINT
+{
+ internal int X;
+ internal int Y;
+}
+
+internal enum MONITOR_DPI_TYPE
+{
+ MDT_EFFECTIVE_DPI = 0,
+}
+
+internal static class MonitorInterop
+{
+ private const uint MONITOR_DEFAULTTONEAREST = 2;
+
+ ///
+ /// Resolves the DPI scale (1.0 = 96 DPI / 100%) of whichever monitor a physical-pixel point
+ /// falls on. Needed because a window's own DPI context only reflects the monitor it's
+ /// currently rendered on - on a mixed-DPI multi-monitor setup, positioning a not-yet-shown
+ /// window using that stale/wrong scale factor can place it partially or fully off-screen.
+ ///
+ internal static double GetDpiScaleForPoint(System.Drawing.Point point)
+ {
+ var pt = new POINT { X = point.X, Y = point.Y };
+ var hMonitor = NativeMethods.MonitorFromPoint(pt, MONITOR_DEFAULTTONEAREST);
+ if (hMonitor != IntPtr.Zero &&
+ NativeMethods.GetDpiForMonitor(hMonitor, MONITOR_DPI_TYPE.MDT_EFFECTIVE_DPI, out uint dpiX, out _) == 0)
+ {
+ return dpiX / 96.0;
+ }
+
+ return 1.0;
+ }
+}
diff --git a/src/EbbesMemeClipboard/Native/NativeMethods.cs b/src/EbbesMemeClipboard/Native/NativeMethods.cs
index dcb1486..823409d 100644
--- a/src/EbbesMemeClipboard/Native/NativeMethods.cs
+++ b/src/EbbesMemeClipboard/Native/NativeMethods.cs
@@ -9,4 +9,28 @@ internal static class NativeMethods
[DllImport("user32.dll", SetLastError = true)]
internal static extern bool UnregisterHotKey(IntPtr hWnd, int id);
+
+ [DllImport("user32.dll", SetLastError = true)]
+ internal static extern uint SendInput(uint nInputs, INPUT[] pInputs, int cbSize);
+
+ [DllImport("user32.dll")]
+ internal static extern IntPtr GetForegroundWindow();
+
+ [DllImport("user32.dll")]
+ internal static extern bool SetForegroundWindow(IntPtr hWnd);
+
+ [DllImport("user32.dll")]
+ internal static extern bool AttachThreadInput(uint idAttach, uint idAttachTo, [MarshalAs(UnmanagedType.Bool)] bool fAttach);
+
+ [DllImport("user32.dll")]
+ internal static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint processId);
+
+ [DllImport("kernel32.dll")]
+ internal static extern uint GetCurrentThreadId();
+
+ [DllImport("user32.dll")]
+ internal static extern IntPtr MonitorFromPoint(POINT pt, uint dwFlags);
+
+ [DllImport("shcore.dll")]
+ internal static extern int GetDpiForMonitor(IntPtr hmonitor, MONITOR_DPI_TYPE dpiType, out uint dpiX, out uint dpiY);
}
diff --git a/src/EbbesMemeClipboard/Services/AutoPasteService.cs b/src/EbbesMemeClipboard/Services/AutoPasteService.cs
new file mode 100644
index 0000000..8c03182
--- /dev/null
+++ b/src/EbbesMemeClipboard/Services/AutoPasteService.cs
@@ -0,0 +1,67 @@
+using EbbesMemeClipboard.Native;
+
+namespace EbbesMemeClipboard.Services;
+
+public sealed class AutoPasteService : IAutoPasteService
+{
+ // Both delays exist because target apps (Discord/Slack/Teams etc. especially) need a moment
+ // to actually process regaining focus, and then again to process the pasted image, before
+ // they're ready for the next synthetic keystroke - firing too soon is a common cause of the
+ // paste (or the follow-up Enter) silently getting dropped.
+ private static readonly TimeSpan FocusSettleDelay = TimeSpan.FromMilliseconds(200);
+ private static readonly TimeSpan PasteSettleDelay = TimeSpan.FromMilliseconds(400);
+
+ private IntPtr _capturedForeground;
+
+ public void CaptureForegroundWindow() => _capturedForeground = NativeMethods.GetForegroundWindow();
+
+ public async Task PasteAsync(bool alsoSend)
+ {
+ var target = _capturedForeground;
+ if (target == IntPtr.Zero || !RestoreForeground(target))
+ {
+ // Couldn't confirm we got the right window focused - the clipboard write already
+ // succeeded regardless, so bail rather than risk sending keystrokes somewhere else.
+ return;
+ }
+
+ await Task.Delay(FocusSettleDelay);
+ InputSimulation.SendKeyCombo(InputSimulation.VK_CONTROL, InputSimulation.VK_V);
+
+ if (alsoSend)
+ {
+ await Task.Delay(PasteSettleDelay);
+ InputSimulation.SendKeyTap(InputSimulation.VK_RETURN);
+ }
+ }
+
+ private static bool RestoreForeground(IntPtr target)
+ {
+ // No synthetic Alt tap here (a common trick to "unlock" SetForegroundWindow when focus-
+ // stealing prevention would otherwise block it) - it isn't needed in our case, since the
+ // picker window is already the foreground process at this point (from Activate() moments
+ // earlier), which is itself one of the documented conditions Windows exempts from that
+ // restriction. It was also actively harmful for Electron/Chromium targets like Teams:
+ // those apps often treat a lone Alt press as "toggle menu bar focus", which knocked their
+ // compose box's own internal focus away right before the paste - the window still came
+ // back to the foreground correctly, but Ctrl+V had nothing focused to land in.
+ uint targetThread = NativeMethods.GetWindowThreadProcessId(target, out _);
+ uint currentThread = NativeMethods.GetCurrentThreadId();
+
+ bool attached = targetThread != currentThread &&
+ NativeMethods.AttachThreadInput(currentThread, targetThread, true);
+ try
+ {
+ NativeMethods.SetForegroundWindow(target);
+ }
+ finally
+ {
+ if (attached)
+ {
+ NativeMethods.AttachThreadInput(currentThread, targetThread, false);
+ }
+ }
+
+ return NativeMethods.GetForegroundWindow() == target;
+ }
+}
diff --git a/src/EbbesMemeClipboard/Services/ClipboardService.cs b/src/EbbesMemeClipboard/Services/ClipboardService.cs
index 6eb0cd7..9475b80 100644
--- a/src/EbbesMemeClipboard/Services/ClipboardService.cs
+++ b/src/EbbesMemeClipboard/Services/ClipboardService.cs
@@ -3,6 +3,7 @@ using System.IO;
using System.Runtime.InteropServices;
using System.Text;
using System.Windows;
+using System.Windows.Media.Imaging;
namespace EbbesMemeClipboard.Services;
@@ -10,8 +11,6 @@ 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();
@@ -23,18 +22,36 @@ public sealed class ClipboardService : IClipboardService
// 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));
- }
+ // "PNG": a registered clipboard format (not one of the classic CF_* constants) that
+ // Chromium-based apps specifically look for and prefer over CF_DIB/CF_BITMAP when
+ // 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);
+
+ // 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.
+ var bytes = await File.ReadAllBytesAsync(filePath);
+ var fragment = $"
";
+ data.SetData(DataFormats.Html, BuildCfHtml(fragment));
await SetClipboardWithRetryAsync(data);
}
+ private static string GetMimeType(string filePath) =>
+ Path.GetExtension(filePath).ToLowerInvariant() switch
+ {
+ ".png" => "image/png",
+ ".jpg" or ".jpeg" => "image/jpeg",
+ ".gif" => "image/gif",
+ _ => "application/octet-stream",
+ };
+
private static async Task SetClipboardWithRetryAsync(DataObject data)
{
const int maxAttempts = 5;
diff --git a/src/EbbesMemeClipboard/Services/HotkeyFormatter.cs b/src/EbbesMemeClipboard/Services/HotkeyFormatter.cs
new file mode 100644
index 0000000..ff6301d
--- /dev/null
+++ b/src/EbbesMemeClipboard/Services/HotkeyFormatter.cs
@@ -0,0 +1,18 @@
+using System.Text;
+using System.Windows.Input;
+
+namespace EbbesMemeClipboard.Services;
+
+internal static class HotkeyFormatter
+{
+ internal static string Format(ModifierKeys modifiers, Key key)
+ {
+ var sb = new StringBuilder();
+ if (modifiers.HasFlag(ModifierKeys.Control)) sb.Append("Ctrl + ");
+ if (modifiers.HasFlag(ModifierKeys.Alt)) sb.Append("Alt + ");
+ if (modifiers.HasFlag(ModifierKeys.Shift)) sb.Append("Shift + ");
+ if (modifiers.HasFlag(ModifierKeys.Windows)) sb.Append("Win + ");
+ sb.Append(key);
+ return sb.ToString();
+ }
+}
diff --git a/src/EbbesMemeClipboard/Services/IAutoPasteService.cs b/src/EbbesMemeClipboard/Services/IAutoPasteService.cs
new file mode 100644
index 0000000..0a4ad54
--- /dev/null
+++ b/src/EbbesMemeClipboard/Services/IAutoPasteService.cs
@@ -0,0 +1,10 @@
+namespace EbbesMemeClipboard.Services;
+
+public interface IAutoPasteService
+{
+ /// Remembers whatever window was focused before the picker is shown, so it can be restored afterward.
+ void CaptureForegroundWindow();
+
+ /// Restores focus to the captured window and pastes (Ctrl+V); optionally also presses Enter to send it.
+ Task PasteAsync(bool alsoSend);
+}
diff --git a/src/EbbesMemeClipboard/Services/ILocalMemeLibraryService.cs b/src/EbbesMemeClipboard/Services/ILocalMemeLibraryService.cs
index 80c0474..995f272 100644
--- a/src/EbbesMemeClipboard/Services/ILocalMemeLibraryService.cs
+++ b/src/EbbesMemeClipboard/Services/ILocalMemeLibraryService.cs
@@ -8,4 +8,5 @@ public interface ILocalMemeLibraryService
IReadOnlyList Search(string query);
string GetFullPath(LocalMemeRecord record);
Task> ImportAsync(IEnumerable sourceFilePaths);
+ Task RemoveAsync(LocalMemeRecord record);
}
diff --git a/src/EbbesMemeClipboard/Services/ISettingsService.cs b/src/EbbesMemeClipboard/Services/ISettingsService.cs
new file mode 100644
index 0000000..856d47b
--- /dev/null
+++ b/src/EbbesMemeClipboard/Services/ISettingsService.cs
@@ -0,0 +1,9 @@
+using EbbesMemeClipboard.Models;
+
+namespace EbbesMemeClipboard.Services;
+
+public interface ISettingsService
+{
+ AppSettings Current { get; }
+ Task SaveAsync();
+}
diff --git a/src/EbbesMemeClipboard/Services/LocalMemeLibraryService.cs b/src/EbbesMemeClipboard/Services/LocalMemeLibraryService.cs
index ff7e6a6..8d76246 100644
--- a/src/EbbesMemeClipboard/Services/LocalMemeLibraryService.cs
+++ b/src/EbbesMemeClipboard/Services/LocalMemeLibraryService.cs
@@ -75,6 +75,31 @@ public sealed class LocalMemeLibraryService : ILocalMemeLibraryService
return imported;
}
+ public async Task RemoveAsync(LocalMemeRecord record)
+ {
+ var path = GetFullPath(record);
+
+ _records.RemoveAll(r => r.Id == record.Id);
+ await SaveIndexAsync();
+
+ try
+ {
+ if (File.Exists(path))
+ {
+ // Recycle Bin, not a hard delete - a misclick on "Remove" shouldn't be unrecoverable.
+ Microsoft.VisualBasic.FileIO.FileSystem.DeleteFile(
+ path,
+ Microsoft.VisualBasic.FileIO.UIOption.OnlyErrorDialogs,
+ Microsoft.VisualBasic.FileIO.RecycleOption.SendToRecycleBin);
+ }
+ }
+ catch (IOException)
+ {
+ // File locked or otherwise inaccessible - it's already out of the index (what the
+ // picker shows), so leave the orphaned file for manual cleanup rather than failing.
+ }
+ }
+
private List LoadIndex()
{
if (!File.Exists(_indexPath))
diff --git a/src/EbbesMemeClipboard/Services/SettingsService.cs b/src/EbbesMemeClipboard/Services/SettingsService.cs
new file mode 100644
index 0000000..e1c45d2
--- /dev/null
+++ b/src/EbbesMemeClipboard/Services/SettingsService.cs
@@ -0,0 +1,49 @@
+using System.IO;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using EbbesMemeClipboard.Models;
+
+namespace EbbesMemeClipboard.Services;
+
+public sealed class SettingsService : ISettingsService
+{
+ private readonly string _settingsPath;
+ private readonly JsonSerializerOptions _jsonOptions = new()
+ {
+ WriteIndented = true,
+ Converters = { new JsonStringEnumConverter() },
+ };
+
+ public AppSettings Current { get; }
+
+ public SettingsService()
+ {
+ var appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
+ var dir = Path.Combine(appData, "EbbesMemeClipboard");
+ Directory.CreateDirectory(dir);
+ _settingsPath = Path.Combine(dir, "settings.json");
+ Current = Load();
+ }
+
+ private AppSettings Load()
+ {
+ if (!File.Exists(_settingsPath))
+ return new AppSettings();
+
+ try
+ {
+ var json = File.ReadAllText(_settingsPath);
+ return JsonSerializer.Deserialize(json, _jsonOptions) ?? new AppSettings();
+ }
+ catch (JsonException)
+ {
+ return new AppSettings();
+ }
+ }
+
+ public async Task SaveAsync()
+ {
+ var json = JsonSerializer.Serialize(Current, _jsonOptions);
+ await File.WriteAllTextAsync(_settingsPath, json);
+ }
+}
diff --git a/src/EbbesMemeClipboard/Styles/DarkMenuStyles.xaml b/src/EbbesMemeClipboard/Styles/DarkMenuStyles.xaml
new file mode 100644
index 0000000..be14f22
--- /dev/null
+++ b/src/EbbesMemeClipboard/Styles/DarkMenuStyles.xaml
@@ -0,0 +1,82 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/EbbesMemeClipboard/ViewModels/PickerViewModel.cs b/src/EbbesMemeClipboard/ViewModels/PickerViewModel.cs
index 0ef4b06..685758b 100644
--- a/src/EbbesMemeClipboard/ViewModels/PickerViewModel.cs
+++ b/src/EbbesMemeClipboard/ViewModels/PickerViewModel.cs
@@ -2,6 +2,7 @@ using System.Collections.ObjectModel;
using System.IO;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
+using EbbesMemeClipboard.Models;
using EbbesMemeClipboard.Services;
namespace EbbesMemeClipboard.ViewModels;
@@ -10,6 +11,8 @@ public partial class PickerViewModel : ObservableObject
{
private readonly ILocalMemeLibraryService _library;
private readonly IClipboardService _clipboard;
+ private readonly ISettingsService _settings;
+ private readonly IAutoPasteService _autoPaste;
public ObservableCollection Items { get; } = new();
@@ -19,12 +22,26 @@ public partial class PickerViewModel : ObservableObject
[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 _isDialogOpen;
+
public event EventHandler? RequestClose;
- public PickerViewModel(ILocalMemeLibraryService library, IClipboardService clipboard)
+ public PickerViewModel(
+ ILocalMemeLibraryService library,
+ IClipboardService clipboard,
+ ISettingsService settings,
+ IAutoPasteService autoPaste)
{
_library = library;
_clipboard = clipboard;
+ _settings = settings;
+ _autoPaste = autoPaste;
RefreshItems();
}
@@ -40,13 +57,32 @@ public partial class PickerViewModel : ObservableObject
await _clipboard.CopyLocalFileAsync(tile.FullPath);
StatusMessage = $"Copied {tile.DisplayName}";
RequestClose?.Invoke(this, EventArgs.Empty);
+
+ var mode = _settings.Current.InsertMode;
+ if (mode is InsertMode.PasteIntoActiveWindow or InsertMode.PasteAndSend)
+ {
+ await _autoPaste.PasteAsync(alsoSend: mode == InsertMode.PasteAndSend);
+ }
}
- catch (IOException ex)
+ catch (Exception ex)
{
- StatusMessage = $"Couldn't copy {tile.DisplayName}: {ex.Message}";
+ // 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.
+ StatusMessage = $"Couldn't insert {tile.DisplayName}: {ex.Message}";
}
}
+ [RelayCommand]
+ private async Task RemoveItemAsync(MemeTileViewModel? tile)
+ {
+ if (tile is null) return;
+
+ await _library.RemoveAsync(tile.Record);
+ RefreshItems();
+ StatusMessage = $"Removed {tile.DisplayName}";
+ }
+
[RelayCommand]
private async Task ImportFilesAsync()
{
@@ -57,9 +93,17 @@ public partial class PickerViewModel : ObservableObject
Title = "Add memes",
};
- if (dialog.ShowDialog() == true)
+ IsDialogOpen = true;
+ try
{
- await ImportPathsAsync(dialog.FileNames);
+ if (dialog.ShowDialog() == true)
+ {
+ await ImportPathsAsync(dialog.FileNames);
+ }
+ }
+ finally
+ {
+ IsDialogOpen = false;
}
}
diff --git a/src/EbbesMemeClipboard/ViewModels/SettingsViewModel.cs b/src/EbbesMemeClipboard/ViewModels/SettingsViewModel.cs
new file mode 100644
index 0000000..a718b55
--- /dev/null
+++ b/src/EbbesMemeClipboard/ViewModels/SettingsViewModel.cs
@@ -0,0 +1,76 @@
+using System.Windows.Input;
+using CommunityToolkit.Mvvm.ComponentModel;
+using EbbesMemeClipboard.Models;
+using EbbesMemeClipboard.Services;
+
+namespace EbbesMemeClipboard.ViewModels;
+
+public partial class SettingsViewModel : ObservableObject
+{
+ private readonly ISettingsService _settings;
+ private readonly IGlobalHotkeyService _hotkeyService;
+
+ [ObservableProperty]
+ private string _hotkeyDisplay = string.Empty;
+
+ [ObservableProperty]
+ private string _hotkeyError = string.Empty;
+
+ [ObservableProperty]
+ private InsertMode _insertMode;
+
+ public bool IsCopyOnly
+ {
+ get => InsertMode == InsertMode.CopyOnly;
+ set { if (value) InsertMode = InsertMode.CopyOnly; }
+ }
+
+ public bool IsPasteIntoActiveWindow
+ {
+ get => InsertMode == InsertMode.PasteIntoActiveWindow;
+ set { if (value) InsertMode = InsertMode.PasteIntoActiveWindow; }
+ }
+
+ public bool IsPasteAndSend
+ {
+ get => InsertMode == InsertMode.PasteAndSend;
+ set { if (value) InsertMode = InsertMode.PasteAndSend; }
+ }
+
+ public SettingsViewModel(ISettingsService settings, IGlobalHotkeyService hotkeyService)
+ {
+ _settings = settings;
+ _hotkeyService = hotkeyService;
+ _insertMode = _settings.Current.InsertMode;
+ _hotkeyDisplay = HotkeyFormatter.Format(_settings.Current.HotkeyModifiers, _settings.Current.HotkeyKey);
+ }
+
+ partial void OnInsertModeChanged(InsertMode value)
+ {
+ _settings.Current.InsertMode = value;
+ _ = _settings.SaveAsync();
+ OnPropertyChanged(nameof(IsCopyOnly));
+ OnPropertyChanged(nameof(IsPasteIntoActiveWindow));
+ OnPropertyChanged(nameof(IsPasteAndSend));
+ }
+
+ public void TrySetHotkey(ModifierKeys modifiers, Key key)
+ {
+ if (_hotkeyService.Register(modifiers, key))
+ {
+ _settings.Current.HotkeyModifiers = modifiers;
+ _settings.Current.HotkeyKey = key;
+ _ = _settings.SaveAsync();
+ HotkeyDisplay = HotkeyFormatter.Format(modifiers, key);
+ HotkeyError = string.Empty;
+ }
+ else
+ {
+ // Register() unregisters the old combo before trying the new one, so on failure
+ // nothing is registered at all - restore the last-known-working combo rather than
+ // leaving the app with no working hotkey.
+ _hotkeyService.Register(_settings.Current.HotkeyModifiers, _settings.Current.HotkeyKey);
+ HotkeyError = "That shortcut is already in use - try a different one.";
+ }
+ }
+}
diff --git a/src/EbbesMemeClipboard/Views/PickerWindow.xaml b/src/EbbesMemeClipboard/Views/PickerWindow.xaml
index 330f17c..73daaa4 100644
--- a/src/EbbesMemeClipboard/Views/PickerWindow.xaml
+++ b/src/EbbesMemeClipboard/Views/PickerWindow.xaml
@@ -16,7 +16,9 @@
Deactivated="PickerWindow_OnDeactivated"
KeyDown="PickerWindow_OnKeyDown"
Drop="PickerWindow_OnDrop"
- DragOver="PickerWindow_OnDragOver">
+ DragOver="PickerWindow_OnDragOver"
+ ContextMenuOpening="PickerWindow_OnContextMenuOpening"
+ ContextMenuClosing="PickerWindow_OnContextMenuClosing">