From 12cedcd7ce85222807b6fbeafedcc236d5c526da Mon Sep 17 00:00:00 2001 From: Hendrik Mennen Date: Tue, 20 Oct 2026 20:29:53 +0200 Subject: [PATCH] Fix image paste in AI Chat on macOS - Accept Cmd+V (Meta) in addition to Ctrl+V for the image paste path. - Fall back to the platform-decoded clipboard bitmap (re-encoded as PNG) when no PNG/JPEG format name is present, covering macOS PNG/TIFF pasteboard types. Fixes #407 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/OneWare.Chat/Views/ChatView.axaml.cs | 81 +++++++++++++------ .../ChatViewPasteShortcutTests.cs | 30 +++++++ 2 files changed, 88 insertions(+), 23 deletions(-) create mode 100644 tests/OneWare.Chat.UnitTests/ChatViewPasteShortcutTests.cs diff --git a/src/OneWare.Chat/Views/ChatView.axaml.cs b/src/OneWare.Chat/Views/ChatView.axaml.cs index c490b5eb..80a09d0a 100644 --- a/src/OneWare.Chat/Views/ChatView.axaml.cs +++ b/src/OneWare.Chat/Views/ChatView.axaml.cs @@ -104,10 +104,8 @@ private void OnCommandBoxKeyDown(object? sender, KeyEventArgs e) if (e.Key is not (Key.Enter or Key.Return or Key.V)) return; if ((DataContext as ChatViewModel)?.ComposerSession is not { } vm) return; - // --- Ctrl+V: try image paste first --- - if (e.Key == Key.V && e.KeyModifiers.HasFlag(KeyModifiers.Control) - && !e.KeyModifiers.HasFlag(KeyModifiers.Shift) - && !e.KeyModifiers.HasFlag(KeyModifiers.Alt) + // --- Ctrl+V / Cmd+V: try image paste first --- + if (e.Key == Key.V && IsPasteModifier(e.KeyModifiers) && vm.Service is IChatService chatService) { // Mark as handled immediately (synchronously) to prevent the TextBox from @@ -141,44 +139,81 @@ private void OnCommandBoxKeyDown(object? sender, KeyEventArgs e) Execute(vm.IsBusy ? vm.SteerCommand : vm.SendCommand, e); } + /// + /// Paste is Ctrl+V on Windows/Linux and Cmd+V (Meta) on macOS. Ctrl+V is accepted on every platform. + /// + internal static bool IsPasteModifier(KeyModifiers modifiers) + { + if ((modifiers & (KeyModifiers.Shift | KeyModifiers.Alt)) != 0) return false; + return (modifiers & (KeyModifiers.Control | KeyModifiers.Meta)) != 0; + } + private async Task HandleClipboardPasteAsync(IChatService chatService) { var clipboard = TopLevel.GetTopLevel(this)?.Clipboard; if (clipboard == null) return; + if (await TryPasteEncodedImageAsync(clipboard, chatService)) return; + if (await TryPasteBitmapAsync(clipboard, chatService)) return; + + // No image (or service doesn't support it): fall back to pasting text. + await FallbackTextPasteAsync(); + } + + /// + /// Attaches PNG/JPEG bytes as they are when the clipboard exposes them under a well-known format name. + /// + private static async Task TryPasteEncodedImageAsync(Avalonia.Input.Platform.IClipboard clipboard, + IChatService chatService) + { try { var formats = await clipboard.GetFormatsAsync(); - // Check for image data first. var imageFormat = formats.FirstOrDefault(f => string.Equals(f, "PNG", StringComparison.OrdinalIgnoreCase) || string.Equals(f, "image/png", StringComparison.OrdinalIgnoreCase) || string.Equals(f, "image/jpeg", StringComparison.OrdinalIgnoreCase)); - if (imageFormat != null) - { - var raw = await clipboard.GetDataAsync(imageFormat); - if (raw is byte[] bytes && bytes.Length > 0) - { - var mimeType = imageFormat.Equals("image/jpeg", StringComparison.OrdinalIgnoreCase) - ? "image/jpeg" - : "image/png"; - var ext = mimeType == "image/jpeg" ? ".jpg" : ".png"; - var name = $"image{ext}"; - - if (chatService.TryAddImageAttachment(bytes, mimeType, name)) - return; - } - } + if (imageFormat == null) return false; + + var raw = await clipboard.GetDataAsync(imageFormat); + if (raw is not byte[] { Length: > 0 } bytes) return false; + + var isJpeg = imageFormat.Equals("image/jpeg", StringComparison.OrdinalIgnoreCase); + return chatService.TryAddImageAttachment(bytes, isJpeg ? "image/jpeg" : "image/png", + isJpeg ? "image.jpg" : "image.png"); } catch { - // Clipboard access failed — fall through to text paste. + return false; } + } - // No image (or service doesn't support it): fall back to pasting text. - await FallbackTextPasteAsync(); + /// + /// Attaches any image the platform can decode (e.g. macOS PNG/TIFF pasteboard types), re-encoded as PNG. + /// + private static async Task TryPasteBitmapAsync(Avalonia.Input.Platform.IClipboard clipboard, + IChatService chatService) + { + try + { + using var data = await clipboard.TryGetDataAsync(); + if (data == null) return false; + + using var bitmap = await data.TryGetBitmapAsync(); + if (bitmap == null) return false; + + using var stream = new MemoryStream(); + bitmap.Save(stream); + if (stream.Length == 0) return false; + + return chatService.TryAddImageAttachment(stream.ToArray(), "image/png", "image.png"); + } + catch + { + return false; + } } private async Task FallbackTextPasteAsync() diff --git a/tests/OneWare.Chat.UnitTests/ChatViewPasteShortcutTests.cs b/tests/OneWare.Chat.UnitTests/ChatViewPasteShortcutTests.cs new file mode 100644 index 00000000..d5e16179 --- /dev/null +++ b/tests/OneWare.Chat.UnitTests/ChatViewPasteShortcutTests.cs @@ -0,0 +1,30 @@ +using Avalonia.Input; +using OneWare.Chat.Views; +using Xunit; + +namespace OneWare.Chat.UnitTests; + +public class ChatViewPasteShortcutTests +{ + [Theory] + [InlineData(KeyModifiers.Control)] + [InlineData(KeyModifiers.Meta)] + [InlineData(KeyModifiers.Control | KeyModifiers.Meta)] + public void IsPasteModifier_AcceptsControlAndCommand(KeyModifiers modifiers) + { + Assert.True(ChatView.IsPasteModifier(modifiers)); + } + + [Theory] + [InlineData(KeyModifiers.None)] + [InlineData(KeyModifiers.Shift)] + [InlineData(KeyModifiers.Alt)] + [InlineData(KeyModifiers.Control | KeyModifiers.Shift)] + [InlineData(KeyModifiers.Meta | KeyModifiers.Shift)] + [InlineData(KeyModifiers.Control | KeyModifiers.Alt)] + [InlineData(KeyModifiers.Meta | KeyModifiers.Alt)] + public void IsPasteModifier_RejectsOtherCombinations(KeyModifiers modifiers) + { + Assert.False(ChatView.IsPasteModifier(modifiers)); + } +}