Added pasting and sending features

This commit is contained in:
Ebbe Baß
2026-08-13 15:43:34 +02:00
parent 940e80b630
commit ceadaff995
22 changed files with 771 additions and 55 deletions
+20 -13
View File
@@ -3,18 +3,25 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:tb="clr-namespace:H.NotifyIcon;assembly=H.NotifyIcon.Wpf"> xmlns:tb="clr-namespace:H.NotifyIcon;assembly=H.NotifyIcon.Wpf">
<Application.Resources> <Application.Resources>
<tb:TaskbarIcon x:Key="TrayIcon" <ResourceDictionary>
IconSource="/Assets/tray-icon.ico" <ResourceDictionary.MergedDictionaries>
ToolTipText="Ebbe's Meme Clipboard" <ResourceDictionary Source="Styles/DarkMenuStyles.xaml"/>
MenuActivation="RightClick"> </ResourceDictionary.MergedDictionaries>
<tb:TaskbarIcon.ContextMenu>
<ContextMenu> <tb:TaskbarIcon x:Key="TrayIcon"
<MenuItem Header="Open Picker" Click="TrayOpenPicker_Click"/> IconSource="/Assets/tray-icon.ico"
<MenuItem Header="Add Memes..." Click="TrayAddMemes_Click"/> ToolTipText="Ebbe's Meme Clipboard"
<Separator/> Visibility="Visible"
<MenuItem Header="Exit" Click="TrayExit_Click"/> MenuActivation="RightClick">
</ContextMenu> <tb:TaskbarIcon.ContextMenu>
</tb:TaskbarIcon.ContextMenu> <ContextMenu>
</tb:TaskbarIcon> <MenuItem Header="Open" Click="TrayOpenPicker_Click"/>
<MenuItem Header="Settings" Click="TraySettings_Click"/>
<Separator/>
<MenuItem Header="Exit" Click="TrayExit_Click"/>
</ContextMenu>
</tb:TaskbarIcon.ContextMenu>
</tb:TaskbarIcon>
</ResourceDictionary>
</Application.Resources> </Application.Resources>
</Application> </Application>
+28 -12
View File
@@ -1,5 +1,4 @@
using System.Windows; using System.Windows;
using System.Windows.Input;
using EbbesMemeClipboard.Services; using EbbesMemeClipboard.Services;
using EbbesMemeClipboard.ViewModels; using EbbesMemeClipboard.ViewModels;
using EbbesMemeClipboard.Views; using EbbesMemeClipboard.Views;
@@ -10,13 +9,11 @@ namespace EbbesMemeClipboard;
public partial class App : Application 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 IServiceProvider _services = null!;
private IGlobalHotkeyService _hotkeyService = null!; private IGlobalHotkeyService _hotkeyService = null!;
private ISettingsService _settingsService = null!;
private PickerWindow _pickerWindow = null!; private PickerWindow _pickerWindow = null!;
private PickerViewModel _pickerViewModel = null!; private SettingsWindow _settingsWindow = null!;
private TaskbarIcon? _trayIcon; private TaskbarIcon? _trayIcon;
protected override void OnStartup(StartupEventArgs e) protected override void OnStartup(StartupEventArgs e)
@@ -28,26 +25,45 @@ public partial class App : Application
services.AddSingleton<ILocalMemeLibraryService, LocalMemeLibraryService>(); services.AddSingleton<ILocalMemeLibraryService, LocalMemeLibraryService>();
services.AddSingleton<IClipboardService, ClipboardService>(); services.AddSingleton<IClipboardService, ClipboardService>();
services.AddSingleton<IGlobalHotkeyService, GlobalHotkeyService>(); services.AddSingleton<IGlobalHotkeyService, GlobalHotkeyService>();
services.AddSingleton<ISettingsService, SettingsService>();
services.AddSingleton<IAutoPasteService, AutoPasteService>();
services.AddSingleton<PickerViewModel>(); services.AddSingleton<PickerViewModel>();
services.AddSingleton<PickerWindow>(); services.AddSingleton<PickerWindow>();
services.AddSingleton<SettingsViewModel>();
services.AddSingleton<SettingsWindow>();
_services = services.BuildServiceProvider(); _services = services.BuildServiceProvider();
_pickerViewModel = _services.GetRequiredService<PickerViewModel>(); _settingsService = _services.GetRequiredService<ISettingsService>();
_pickerWindow = _services.GetRequiredService<PickerWindow>(); _pickerWindow = _services.GetRequiredService<PickerWindow>();
_settingsWindow = _services.GetRequiredService<SettingsWindow>();
// 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"]; _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(); _trayIcon.TrayLeftMouseUp += (_, _) => _pickerWindow.ToggleVisibility();
_hotkeyService = _services.GetRequiredService<IGlobalHotkeyService>(); _hotkeyService = _services.GetRequiredService<IGlobalHotkeyService>();
if (_hotkeyService.Register(DefaultHotkeyModifiers, DefaultHotkeyKey)) var hotkey = _settingsService.Current;
if (_hotkeyService.Register(hotkey.HotkeyModifiers, hotkey.HotkeyKey))
{ {
_hotkeyService.HotkeyPressed += (_, _) => Dispatcher.Invoke(() => _pickerWindow.ToggleVisibility()); _hotkeyService.HotkeyPressed += (_, _) => Dispatcher.Invoke(() => _pickerWindow.ToggleVisibility());
} }
else else
{ {
MessageBox.Show( MessageBox.Show(
"Couldn't register the global hotkey (Ctrl+Alt+M) - it may already be in use by another application. " + $"Couldn't register the global hotkey ({HotkeyFormatter.Format(hotkey.HotkeyModifiers, hotkey.HotkeyKey)}) - " +
"You can still open the picker from the tray icon.", "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", "Ebbe's Meme Clipboard",
MessageBoxButton.OK, MessageBoxButton.OK,
MessageBoxImage.Warning); MessageBoxImage.Warning);
@@ -56,10 +72,10 @@ public partial class App : Application
private void TrayOpenPicker_Click(object sender, RoutedEventArgs e) => _pickerWindow.ShowNearCursor(); 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(); _settingsWindow.Show();
await _pickerViewModel.ImportFilesCommand.ExecuteAsync(null); _settingsWindow.Activate();
} }
private void TrayExit_Click(object sender, RoutedEventArgs e) => Shutdown(); private void TrayExit_Click(object sender, RoutedEventArgs e) => Shutdown();
@@ -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;
}
@@ -0,0 +1,13 @@
namespace EbbesMemeClipboard.Models;
public enum InsertMode
{
/// <summary>Just copy the meme to the clipboard; the user pastes it manually.</summary>
CopyOnly,
/// <summary>Copy to the clipboard, then paste it into whatever app was focused before the picker opened.</summary>
PasteIntoActiveWindow,
/// <summary>Same as PasteIntoActiveWindow, then also press Enter to submit/send it.</summary>
PasteAndSend,
}
@@ -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 } };
}
}
@@ -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;
/// <summary>
/// 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.
/// </summary>
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;
}
}
@@ -9,4 +9,28 @@ internal static class NativeMethods
[DllImport("user32.dll", SetLastError = true)] [DllImport("user32.dll", SetLastError = true)]
internal static extern bool UnregisterHotKey(IntPtr hWnd, int id); 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);
} }
@@ -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;
}
}
@@ -3,6 +3,7 @@ using System.IO;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
using System.Text; using System.Text;
using System.Windows; using System.Windows;
using System.Windows.Media.Imaging;
namespace EbbesMemeClipboard.Services; namespace EbbesMemeClipboard.Services;
@@ -10,8 +11,6 @@ public sealed class ClipboardService : IClipboardService
{ {
public async Task CopyLocalFileAsync(string filePath) public async Task CopyLocalFileAsync(string filePath)
{ {
bool isGif = ImageDecoding.IsGif(filePath);
var firstFrame = await Task.Run(() => ImageDecoding.DecodeFirstFrame(filePath)); var firstFrame = await Task.Run(() => ImageDecoding.DecodeFirstFrame(filePath));
var data = new DataObject(); 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. // WPF's DataObject.SetImage auto-converts to CF_DIB - no manual header work needed here.
data.SetImage(firstFrame); data.SetImage(firstFrame);
if (isGif) // "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
// CF_HTML: the format most browser/rich-text paste targets use, and the only one of // reading a pasted image, because those older formats are lossy for transparency. This
// the three that preserves animation for those targets (CF_DIB is a single frame). // covers Teams, Slack, Discord's web layer, and browsers - without it, paste into any of
var bytes = await File.ReadAllBytesAsync(filePath); // them can silently produce nothing even though CF_DIB is present and perfectly valid.
var fragment = $"<img src=\"data:image/gif;base64,{Convert.ToBase64String(bytes)}\">"; using var pngStream = new MemoryStream();
data.SetData(DataFormats.Html, BuildCfHtml(fragment)); 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 = $"<img src=\"data:{GetMimeType(filePath)};base64,{Convert.ToBase64String(bytes)}\">";
data.SetData(DataFormats.Html, BuildCfHtml(fragment));
await SetClipboardWithRetryAsync(data); 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) private static async Task SetClipboardWithRetryAsync(DataObject data)
{ {
const int maxAttempts = 5; const int maxAttempts = 5;
@@ -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();
}
}
@@ -0,0 +1,10 @@
namespace EbbesMemeClipboard.Services;
public interface IAutoPasteService
{
/// <summary>Remembers whatever window was focused before the picker is shown, so it can be restored afterward.</summary>
void CaptureForegroundWindow();
/// <summary>Restores focus to the captured window and pastes (Ctrl+V); optionally also presses Enter to send it.</summary>
Task PasteAsync(bool alsoSend);
}
@@ -8,4 +8,5 @@ public interface ILocalMemeLibraryService
IReadOnlyList<LocalMemeRecord> Search(string query); IReadOnlyList<LocalMemeRecord> Search(string query);
string GetFullPath(LocalMemeRecord record); string GetFullPath(LocalMemeRecord record);
Task<IReadOnlyList<LocalMemeRecord>> ImportAsync(IEnumerable<string> sourceFilePaths); Task<IReadOnlyList<LocalMemeRecord>> ImportAsync(IEnumerable<string> sourceFilePaths);
Task RemoveAsync(LocalMemeRecord record);
} }
@@ -0,0 +1,9 @@
using EbbesMemeClipboard.Models;
namespace EbbesMemeClipboard.Services;
public interface ISettingsService
{
AppSettings Current { get; }
Task SaveAsync();
}
@@ -75,6 +75,31 @@ public sealed class LocalMemeLibraryService : ILocalMemeLibraryService
return imported; 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<LocalMemeRecord> LoadIndex() private List<LocalMemeRecord> LoadIndex()
{ {
if (!File.Exists(_indexPath)) if (!File.Exists(_indexPath))
@@ -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<AppSettings>(json, _jsonOptions) ?? new AppSettings();
}
catch (JsonException)
{
return new AppSettings();
}
}
public async Task SaveAsync()
{
var json = JsonSerializer.Serialize(Current, _jsonOptions);
await File.WriteAllTextAsync(_settingsPath, json);
}
}
@@ -0,0 +1,82 @@
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<!-- Shared dark styling for every ContextMenu/MenuItem in the app (tray icon menu, tile
right-click menu, and any added later) so they all match the app's own dark UI instead
of the default OS menu chrome. Implicit (no x:Key), so merging this dictionary into
Application.Resources is enough to apply it everywhere - no per-menu wiring needed. -->
<Style TargetType="ContextMenu">
<Setter Property="Background" Value="#F0202225"/>
<Setter Property="BorderBrush" Value="#3A3B3E"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="Foreground" Value="White"/>
<Setter Property="Padding" Value="4"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ContextMenu">
<Border Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="8"
Padding="{TemplateBinding Padding}"
SnapsToDevicePixels="True">
<StackPanel IsItemsHost="True"/>
</Border>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style TargetType="MenuItem">
<Setter Property="Background" Value="Transparent"/>
<Setter Property="Foreground" Value="White"/>
<Setter Property="Padding" Value="10,7"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="MenuItem">
<Border x:Name="Bd" Background="{TemplateBinding Background}" CornerRadius="4"
SnapsToDevicePixels="True">
<Grid Margin="2,1">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<ContentPresenter Grid.Column="0" ContentSource="Header" RecognizesAccessKey="True"
Margin="{TemplateBinding Padding}" VerticalAlignment="Center"/>
<Path x:Name="SubmenuArrow" Grid.Column="1" Visibility="Collapsed"
Data="M0,0 L4,4 L0,8" Stroke="#9A9CA3" StrokeThickness="1.3"
Width="8" Height="8" Margin="0,0,10,0" VerticalAlignment="Center"/>
<Popup x:Name="PART_Popup" Placement="Right" AllowsTransparency="True"
Focusable="False" PopupAnimation="Fade"
IsOpen="{Binding IsSubmenuOpen, RelativeSource={RelativeSource TemplatedParent}}">
<Border Background="#F0202225" BorderBrush="#3A3B3E" BorderThickness="1"
CornerRadius="8" Padding="4">
<StackPanel IsItemsHost="True"/>
</Border>
</Popup>
</Grid>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsHighlighted" Value="True">
<Setter TargetName="Bd" Property="Background" Value="#3A3D42"/>
</Trigger>
<Trigger Property="Role" Value="SubmenuHeader">
<Setter TargetName="SubmenuArrow" Property="Visibility" Value="Visible"/>
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter Property="Foreground" Value="#5A5C63"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style TargetType="Separator">
<Setter Property="Background" Value="#3A3B3E"/>
<Setter Property="Margin" Value="6,4"/>
<Setter Property="Height" Value="1"/>
</Style>
</ResourceDictionary>
@@ -2,6 +2,7 @@ using System.Collections.ObjectModel;
using System.IO; using System.IO;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using EbbesMemeClipboard.Models;
using EbbesMemeClipboard.Services; using EbbesMemeClipboard.Services;
namespace EbbesMemeClipboard.ViewModels; namespace EbbesMemeClipboard.ViewModels;
@@ -10,6 +11,8 @@ public partial class PickerViewModel : ObservableObject
{ {
private readonly ILocalMemeLibraryService _library; private readonly ILocalMemeLibraryService _library;
private readonly IClipboardService _clipboard; private readonly IClipboardService _clipboard;
private readonly ISettingsService _settings;
private readonly IAutoPasteService _autoPaste;
public ObservableCollection<MemeTileViewModel> Items { get; } = new(); public ObservableCollection<MemeTileViewModel> Items { get; } = new();
@@ -19,12 +22,26 @@ public partial class PickerViewModel : ObservableObject
[ObservableProperty] [ObservableProperty]
private string _statusMessage = string.Empty; private string _statusMessage = string.Empty;
/// <summary>
/// True while a modal dialog (currently: the "add memes" file picker) opened from within the
/// picker is showing. The window's Deactivated handler checks this so opening that dialog
/// doesn't get treated as "user clicked away" and hide the picker out from under it.
/// </summary>
[ObservableProperty]
private bool _isDialogOpen;
public event EventHandler? RequestClose; public event EventHandler? RequestClose;
public PickerViewModel(ILocalMemeLibraryService library, IClipboardService clipboard) public PickerViewModel(
ILocalMemeLibraryService library,
IClipboardService clipboard,
ISettingsService settings,
IAutoPasteService autoPaste)
{ {
_library = library; _library = library;
_clipboard = clipboard; _clipboard = clipboard;
_settings = settings;
_autoPaste = autoPaste;
RefreshItems(); RefreshItems();
} }
@@ -40,13 +57,32 @@ public partial class PickerViewModel : ObservableObject
await _clipboard.CopyLocalFileAsync(tile.FullPath); await _clipboard.CopyLocalFileAsync(tile.FullPath);
StatusMessage = $"Copied {tile.DisplayName}"; StatusMessage = $"Copied {tile.DisplayName}";
RequestClose?.Invoke(this, EventArgs.Empty); 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] [RelayCommand]
private async Task ImportFilesAsync() private async Task ImportFilesAsync()
{ {
@@ -57,9 +93,17 @@ public partial class PickerViewModel : ObservableObject
Title = "Add memes", 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;
} }
} }
@@ -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.";
}
}
}
+22 -4
View File
@@ -16,7 +16,9 @@
Deactivated="PickerWindow_OnDeactivated" Deactivated="PickerWindow_OnDeactivated"
KeyDown="PickerWindow_OnKeyDown" KeyDown="PickerWindow_OnKeyDown"
Drop="PickerWindow_OnDrop" Drop="PickerWindow_OnDrop"
DragOver="PickerWindow_OnDragOver"> DragOver="PickerWindow_OnDragOver"
ContextMenuOpening="PickerWindow_OnContextMenuOpening"
ContextMenuClosing="PickerWindow_OnContextMenuClosing">
<Window.Resources> <Window.Resources>
<Style x:Key="FlatButtonStyle" TargetType="Button"> <Style x:Key="FlatButtonStyle" TargetType="Button">
<Setter Property="Background" Value="#2B2D31"/> <Setter Property="Background" Value="#2B2D31"/>
@@ -46,12 +48,20 @@
<Border Background="#F0202225" CornerRadius="10" BorderBrush="#3A3B3E" BorderThickness="1"> <Border Background="#F0202225" CornerRadius="10" BorderBrush="#3A3B3E" BorderThickness="1">
<Grid Margin="12"> <Grid Margin="12">
<Grid.RowDefinitions> <Grid.RowDefinitions>
<RowDefinition Height="16"/>
<RowDefinition Height="Auto"/> <RowDefinition Height="Auto"/>
<RowDefinition Height="*"/> <RowDefinition Height="*"/>
<RowDefinition Height="Auto"/> <RowDefinition Height="Auto"/>
</Grid.RowDefinitions> </Grid.RowDefinitions>
<Grid Grid.Row="0" Margin="0,0,0,10"> <Border Grid.Row="0" Background="Transparent" Cursor="SizeAll"
MouseLeftButtonDown="DragHandle_OnMouseLeftButtonDown"
ToolTip="Drag to move">
<Border Width="36" Height="4" CornerRadius="2" Background="#4A4C52"
HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<Grid Grid.Row="1" Margin="0,4,0,10">
<Grid.ColumnDefinitions> <Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/> <ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/> <ColumnDefinition Width="Auto"/>
@@ -69,7 +79,7 @@
ToolTip="Add memes"/> ToolTip="Add memes"/>
</Grid> </Grid>
<ScrollViewer Grid.Row="1" VerticalScrollBarVisibility="Auto"> <ScrollViewer Grid.Row="2" VerticalScrollBarVisibility="Auto">
<Grid> <Grid>
<TextBlock Text="No memes yet. Click + or drag files in here." <TextBlock Text="No memes yet. Click + or drag files in here."
Foreground="#7B7D85" FontSize="13" Foreground="#7B7D85" FontSize="13"
@@ -99,7 +109,15 @@
Style="{StaticResource FlatButtonStyle}" Style="{StaticResource FlatButtonStyle}"
Command="{Binding DataContext.SelectItemCommand, ElementName=RootWindow}" Command="{Binding DataContext.SelectItemCommand, ElementName=RootWindow}"
CommandParameter="{Binding}" CommandParameter="{Binding}"
Tag="{Binding DataContext, ElementName=RootWindow}"
ToolTip="{Binding DisplayName}"> ToolTip="{Binding DisplayName}">
<Button.ContextMenu>
<ContextMenu>
<MenuItem Header="Remove"
Command="{Binding PlacementTarget.Tag.RemoveItemCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}"
CommandParameter="{Binding PlacementTarget.DataContext, RelativeSource={RelativeSource AncestorType=ContextMenu}}"/>
</ContextMenu>
</Button.ContextMenu>
<Image Source="{Binding Thumbnail}" Stretch="Uniform" Width="80" Height="80"/> <Image Source="{Binding Thumbnail}" Stretch="Uniform" Width="80" Height="80"/>
</Button> </Button>
</DataTemplate> </DataTemplate>
@@ -108,7 +126,7 @@
</Grid> </Grid>
</ScrollViewer> </ScrollViewer>
<TextBlock Grid.Row="2" Text="{Binding StatusMessage}" Foreground="#9A9CA3" FontSize="11" Margin="2,8,0,0"/> <TextBlock Grid.Row="3" Text="{Binding StatusMessage}" Foreground="#9A9CA3" FontSize="11" Margin="2,8,0,0"/>
</Grid> </Grid>
</Border> </Border>
</Window> </Window>
@@ -1,7 +1,9 @@
using System.ComponentModel; using System.ComponentModel;
using System.Windows; using System.Windows;
using System.Windows.Controls;
using System.Windows.Input; using System.Windows.Input;
using System.Windows.Media; using EbbesMemeClipboard.Native;
using EbbesMemeClipboard.Services;
using EbbesMemeClipboard.ViewModels; using EbbesMemeClipboard.ViewModels;
namespace EbbesMemeClipboard.Views; namespace EbbesMemeClipboard.Views;
@@ -9,17 +11,22 @@ namespace EbbesMemeClipboard.Views;
public partial class PickerWindow : Window public partial class PickerWindow : Window
{ {
private readonly PickerViewModel _viewModel; private readonly PickerViewModel _viewModel;
private readonly IAutoPasteService _autoPaste;
private bool _isContextMenuOpen;
public PickerWindow(PickerViewModel viewModel) public PickerWindow(PickerViewModel viewModel, IAutoPasteService autoPaste)
{ {
InitializeComponent(); InitializeComponent();
_viewModel = viewModel; _viewModel = viewModel;
_autoPaste = autoPaste;
DataContext = _viewModel; DataContext = _viewModel;
_viewModel.RequestClose += (_, _) => Hide(); _viewModel.RequestClose += (_, _) => Hide();
_viewModel.PropertyChanged += ViewModel_OnPropertyChanged;
} }
public void ShowNearCursor() public void ShowNearCursor()
{ {
_autoPaste.CaptureForegroundWindow();
PositionNearCursor(); PositionNearCursor();
_viewModel.OnShown(); _viewModel.OnShown();
Show(); Show();
@@ -40,20 +47,19 @@ public partial class PickerWindow : Window
} }
/// <summary> /// <summary>
/// Positions the popup near the mouse cursor, clamped to the current monitor's working /// Positions the popup near the mouse cursor, clamped to the current monitor's working area.
/// area. The real Windows Emoji Panel can follow the text caret because it's a privileged /// The real Windows Emoji Panel can follow the text caret because it's a privileged shell
/// shell component; a third-party app can't replicate that, so cursor position is the /// component; a third-party app can't replicate that, so cursor position is the practical
/// practical stand-in. Uses a single DPI scale factor for the conversion from WinForms' /// stand-in. Resolves the DPI scale of whichever monitor the cursor is actually on (not just
/// physical pixels to WPF's device-independent units, which is exact for same-DPI /// whichever monitor this window last rendered on), so the popup stays fully on-screen on
/// multi-monitor setups (the common case) and only approximate when the cursor is on a /// mixed-DPI multi-monitor setups too.
/// secondary monitor running a different DPI scale than the one this window last rendered on.
/// </summary> /// </summary>
private void PositionNearCursor() private void PositionNearCursor()
{ {
var cursor = System.Windows.Forms.Cursor.Position; var cursor = System.Windows.Forms.Cursor.Position;
var workingArea = System.Windows.Forms.Screen.FromPoint(cursor).WorkingArea; var workingArea = System.Windows.Forms.Screen.FromPoint(cursor).WorkingArea;
double scale = VisualTreeHelper.GetDpi(this).DpiScaleX; double scale = MonitorInterop.GetDpiScaleForPoint(cursor);
double left = cursor.X / scale; double left = cursor.X / scale;
double top = cursor.Y / scale; double top = cursor.Y / scale;
@@ -66,7 +72,36 @@ public partial class PickerWindow : Window
Top = Math.Clamp(top, minTop, Math.Max(minTop, maxTop)); Top = Math.Clamp(top, minTop, Math.Max(minTop, maxTop));
} }
private void PickerWindow_OnDeactivated(object? sender, EventArgs e) => Hide(); private void DragHandle_OnMouseLeftButtonDown(object sender, MouseButtonEventArgs e)
{
if (e.LeftButton == MouseButtonState.Pressed)
{
DragMove();
}
}
private void ViewModel_OnPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
// Once the "add memes" dialog closes, hand keyboard focus back to the picker so the
// user can keep searching/clicking without having to click into it again.
if (e.PropertyName == nameof(PickerViewModel.IsDialogOpen) && !_viewModel.IsDialogOpen && IsVisible)
{
Activate();
}
}
private void PickerWindow_OnDeactivated(object? sender, EventArgs e)
{
// Don't auto-hide while our own file-picker dialog or a tile's right-click context menu
// is showing - both take keyboard focus away from the window in a way that looks like
// "user clicked away" but isn't, and hiding here would close the menu/dialog too.
if (_viewModel.IsDialogOpen || _isContextMenuOpen) return;
Hide();
}
private void PickerWindow_OnContextMenuOpening(object sender, ContextMenuEventArgs e) => _isContextMenuOpen = true;
private void PickerWindow_OnContextMenuClosing(object sender, ContextMenuEventArgs e) => _isContextMenuOpen = false;
private void PickerWindow_OnKeyDown(object sender, KeyEventArgs e) private void PickerWindow_OnKeyDown(object sender, KeyEventArgs e)
{ {
@@ -0,0 +1,36 @@
<Window x:Class="EbbesMemeClipboard.Views.SettingsWindow"
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"
Background="#F0202225"
WindowStartupLocation="CenterScreen"
ResizeMode="NoResize"
SizeToContent="Height">
<StackPanel Margin="20">
<TextBlock Text="Global Hotkey" Foreground="White" FontSize="14" FontWeight="Bold" Margin="0,0,0,8"/>
<Border Background="#2B2D31" CornerRadius="6">
<TextBox x:Name="HotkeyBox"
Text="{Binding HotkeyDisplay, Mode=OneWay}"
IsReadOnly="True" Focusable="True"
Background="Transparent" Foreground="White" BorderThickness="0"
Padding="10,8" FontSize="13"
CaretBrush="Transparent"
PreviewKeyDown="HotkeyBox_OnPreviewKeyDown"/>
</Border>
<TextBlock Text="Click the box above, then press a new key combination." TextWrapping="Wrap"
Foreground="#7B7D85" FontSize="11" Margin="0,4,0,0"/>
<TextBlock Text="{Binding HotkeyError}" TextWrapping="Wrap"
Foreground="#E06060" FontSize="11" Margin="0,4,0,0"/>
<TextBlock Text="Insert Mode" Foreground="White" FontSize="14" FontWeight="Bold" Margin="0,24,0,8"/>
<RadioButton GroupName="InsertMode" Content="Copy to clipboard" Foreground="White"
IsChecked="{Binding IsCopyOnly}" Margin="0,0,0,8"/>
<RadioButton GroupName="InsertMode" Content="Paste into active window" Foreground="White"
IsChecked="{Binding IsPasteIntoActiveWindow}" Margin="0,0,0,8"/>
<RadioButton GroupName="InsertMode" Content="Paste and send instantly" Foreground="White"
IsChecked="{Binding IsPasteAndSend}"/>
<TextBlock Text="Presses Enter right after pasting to submit it - use with care in chat apps."
Foreground="#7B7D85" FontSize="10" TextWrapping="Wrap" Margin="20,4,0,0"/>
</StackPanel>
</Window>
@@ -0,0 +1,55 @@
using System.ComponentModel;
using System.Windows;
using System.Windows.Input;
using EbbesMemeClipboard.ViewModels;
namespace EbbesMemeClipboard.Views;
public partial class SettingsWindow : Window
{
private readonly SettingsViewModel _viewModel;
public SettingsWindow(SettingsViewModel viewModel)
{
InitializeComponent();
_viewModel = viewModel;
DataContext = _viewModel;
}
private void HotkeyBox_OnPreviewKeyDown(object sender, KeyEventArgs e)
{
e.Handled = true;
var key = e.Key == Key.System ? e.SystemKey : e.Key;
if (key is Key.LeftCtrl or Key.RightCtrl or Key.LeftAlt or Key.RightAlt
or Key.LeftShift or Key.RightShift or Key.LWin or Key.RWin or Key.System)
{
return;
}
var modifiers = Keyboard.Modifiers;
// Keyboard.Modifiers only tracks Ctrl/Alt/Shift - the Windows key isn't a "modifier" as
// far as WPF's own key-state tracking is concerned, so it has to be checked separately.
if (Keyboard.IsKeyDown(Key.LWin) || Keyboard.IsKeyDown(Key.RWin))
{
modifiers |= ModifierKeys.Windows;
}
if (modifiers == ModifierKeys.None)
{
// A bare, unmodified key (e.g. plain "M") would fire on every keystroke system-wide.
return;
}
_viewModel.TrySetHotkey(modifiers, key);
}
protected override void OnClosing(CancelEventArgs e)
{
// Reused DI singleton for the app's whole lifetime - closing (title-bar X) should just
// hide it, not tear it down.
e.Cancel = true;
Hide();
base.OnClosing(e);
}
}