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">