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