Added pasting and sending features
This commit is contained in:
@@ -3,18 +3,25 @@
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:tb="clr-namespace:H.NotifyIcon;assembly=H.NotifyIcon.Wpf">
|
||||
<Application.Resources>
|
||||
<tb:TaskbarIcon x:Key="TrayIcon"
|
||||
IconSource="/Assets/tray-icon.ico"
|
||||
ToolTipText="Ebbe's Meme Clipboard"
|
||||
MenuActivation="RightClick">
|
||||
<tb:TaskbarIcon.ContextMenu>
|
||||
<ContextMenu>
|
||||
<MenuItem Header="Open Picker" Click="TrayOpenPicker_Click"/>
|
||||
<MenuItem Header="Add Memes..." Click="TrayAddMemes_Click"/>
|
||||
<Separator/>
|
||||
<MenuItem Header="Exit" Click="TrayExit_Click"/>
|
||||
</ContextMenu>
|
||||
</tb:TaskbarIcon.ContextMenu>
|
||||
</tb:TaskbarIcon>
|
||||
<ResourceDictionary>
|
||||
<ResourceDictionary.MergedDictionaries>
|
||||
<ResourceDictionary Source="Styles/DarkMenuStyles.xaml"/>
|
||||
</ResourceDictionary.MergedDictionaries>
|
||||
|
||||
<tb:TaskbarIcon x:Key="TrayIcon"
|
||||
IconSource="/Assets/tray-icon.ico"
|
||||
ToolTipText="Ebbe's Meme Clipboard"
|
||||
Visibility="Visible"
|
||||
MenuActivation="RightClick">
|
||||
<tb:TaskbarIcon.ContextMenu>
|
||||
<ContextMenu>
|
||||
<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>
|
||||
|
||||
@@ -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<ILocalMemeLibraryService, LocalMemeLibraryService>();
|
||||
services.AddSingleton<IClipboardService, ClipboardService>();
|
||||
services.AddSingleton<IGlobalHotkeyService, GlobalHotkeyService>();
|
||||
services.AddSingleton<ISettingsService, SettingsService>();
|
||||
services.AddSingleton<IAutoPasteService, AutoPasteService>();
|
||||
services.AddSingleton<PickerViewModel>();
|
||||
services.AddSingleton<PickerWindow>();
|
||||
services.AddSingleton<SettingsViewModel>();
|
||||
services.AddSingleton<SettingsWindow>();
|
||||
_services = services.BuildServiceProvider();
|
||||
|
||||
_pickerViewModel = _services.GetRequiredService<PickerViewModel>();
|
||||
_settingsService = _services.GetRequiredService<ISettingsService>();
|
||||
_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"];
|
||||
// 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<IGlobalHotkeyService>();
|
||||
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();
|
||||
|
||||
@@ -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)]
|
||||
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.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 = $"<img src=\"data:image/gif;base64,{Convert.ToBase64String(bytes)}\">";
|
||||
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 = $"<img src=\"data:{GetMimeType(filePath)};base64,{Convert.ToBase64String(bytes)}\">";
|
||||
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;
|
||||
|
||||
@@ -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);
|
||||
string GetFullPath(LocalMemeRecord record);
|
||||
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;
|
||||
}
|
||||
|
||||
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()
|
||||
{
|
||||
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 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<MemeTileViewModel> Items { get; } = new();
|
||||
|
||||
@@ -19,12 +22,26 @@ public partial class PickerViewModel : ObservableObject
|
||||
[ObservableProperty]
|
||||
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 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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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">
|
||||
<Window.Resources>
|
||||
<Style x:Key="FlatButtonStyle" TargetType="Button">
|
||||
<Setter Property="Background" Value="#2B2D31"/>
|
||||
@@ -46,12 +48,20 @@
|
||||
<Border Background="#F0202225" CornerRadius="10" BorderBrush="#3A3B3E" BorderThickness="1">
|
||||
<Grid Margin="12">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="16"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="*"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
</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>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
@@ -69,7 +79,7 @@
|
||||
ToolTip="Add memes"/>
|
||||
</Grid>
|
||||
|
||||
<ScrollViewer Grid.Row="1" VerticalScrollBarVisibility="Auto">
|
||||
<ScrollViewer Grid.Row="2" VerticalScrollBarVisibility="Auto">
|
||||
<Grid>
|
||||
<TextBlock Text="No memes yet. Click + or drag files in here."
|
||||
Foreground="#7B7D85" FontSize="13"
|
||||
@@ -99,7 +109,15 @@
|
||||
Style="{StaticResource FlatButtonStyle}"
|
||||
Command="{Binding DataContext.SelectItemCommand, ElementName=RootWindow}"
|
||||
CommandParameter="{Binding}"
|
||||
Tag="{Binding DataContext, ElementName=RootWindow}"
|
||||
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"/>
|
||||
</Button>
|
||||
</DataTemplate>
|
||||
@@ -108,7 +126,7 @@
|
||||
</Grid>
|
||||
</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>
|
||||
</Border>
|
||||
</Window>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
using System.ComponentModel;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Input;
|
||||
using System.Windows.Media;
|
||||
using EbbesMemeClipboard.Native;
|
||||
using EbbesMemeClipboard.Services;
|
||||
using EbbesMemeClipboard.ViewModels;
|
||||
|
||||
namespace EbbesMemeClipboard.Views;
|
||||
@@ -9,17 +11,22 @@ namespace EbbesMemeClipboard.Views;
|
||||
public partial class PickerWindow : Window
|
||||
{
|
||||
private readonly PickerViewModel _viewModel;
|
||||
private readonly IAutoPasteService _autoPaste;
|
||||
private bool _isContextMenuOpen;
|
||||
|
||||
public PickerWindow(PickerViewModel viewModel)
|
||||
public PickerWindow(PickerViewModel viewModel, IAutoPasteService autoPaste)
|
||||
{
|
||||
InitializeComponent();
|
||||
_viewModel = viewModel;
|
||||
_autoPaste = autoPaste;
|
||||
DataContext = _viewModel;
|
||||
_viewModel.RequestClose += (_, _) => Hide();
|
||||
_viewModel.PropertyChanged += ViewModel_OnPropertyChanged;
|
||||
}
|
||||
|
||||
public void ShowNearCursor()
|
||||
{
|
||||
_autoPaste.CaptureForegroundWindow();
|
||||
PositionNearCursor();
|
||||
_viewModel.OnShown();
|
||||
Show();
|
||||
@@ -40,20 +47,19 @@ public partial class PickerWindow : Window
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Positions the popup near the mouse cursor, clamped to the current monitor's working
|
||||
/// area. The real Windows Emoji Panel can follow the text caret because it's a privileged
|
||||
/// shell component; a third-party app can't replicate that, so cursor position is the
|
||||
/// practical stand-in. Uses a single DPI scale factor for the conversion from WinForms'
|
||||
/// physical pixels to WPF's device-independent units, which is exact for same-DPI
|
||||
/// multi-monitor setups (the common case) and only approximate when the cursor is on a
|
||||
/// secondary monitor running a different DPI scale than the one this window last rendered on.
|
||||
/// Positions the popup near the mouse cursor, clamped to the current monitor's working area.
|
||||
/// The real Windows Emoji Panel can follow the text caret because it's a privileged shell
|
||||
/// component; a third-party app can't replicate that, so cursor position is the practical
|
||||
/// stand-in. Resolves the DPI scale of whichever monitor the cursor is actually on (not just
|
||||
/// whichever monitor this window last rendered on), so the popup stays fully on-screen on
|
||||
/// mixed-DPI multi-monitor setups too.
|
||||
/// </summary>
|
||||
private void PositionNearCursor()
|
||||
{
|
||||
var cursor = System.Windows.Forms.Cursor.Position;
|
||||
var workingArea = System.Windows.Forms.Screen.FromPoint(cursor).WorkingArea;
|
||||
|
||||
double scale = VisualTreeHelper.GetDpi(this).DpiScaleX;
|
||||
double scale = MonitorInterop.GetDpiScaleForPoint(cursor);
|
||||
|
||||
double left = cursor.X / scale;
|
||||
double top = cursor.Y / scale;
|
||||
@@ -66,7 +72,36 @@ public partial class PickerWindow : Window
|
||||
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)
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user