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));
+ }
+}