From 82cb3648587c04b8ba73bb36b37cb0e1f9781aff Mon Sep 17 00:00:00 2001 From: Cody Mullins <1738479+codymullins@users.noreply.github.com> Date: Tue, 16 Jun 2026 21:45:00 -0400 Subject: [PATCH] feat(clipboard): typed clipboard (image and HTML beyond plain text) Add AppWindow.GetClipboardData / SetClipboardData / ClipboardFormats so a browser can copy and paste images and HTML, not only text. ClipboardText stays as the text convenience. - AppKit backend maps text/plain, text/html, and image/png to NSPasteboard types and reads/writes typed data. - GLFW backend stores text only; other MIME types are dropped, and it reports text/plain alone when text is present. Closes #21 --- src/Skyline.Apple/AppKitWindowBackend.cs | 51 +++++++++++++++++++ src/Skyline/AppWindow.cs | 15 ++++++ src/Skyline/GlfwWindowBackend.cs | 25 +++++++++ src/Skyline/IWindowBackend.cs | 9 ++++ .../WindowBackendFactoryTests.cs | 3 ++ tests/Skyline.WindowedTests/Program.cs | 11 ++++ 6 files changed, 114 insertions(+) diff --git a/src/Skyline.Apple/AppKitWindowBackend.cs b/src/Skyline.Apple/AppKitWindowBackend.cs index 91524de..14d3f02 100644 --- a/src/Skyline.Apple/AppKitWindowBackend.cs +++ b/src/Skyline.Apple/AppKitWindowBackend.cs @@ -129,6 +129,57 @@ public string? ClipboardText } } + // MIME type to macOS pasteboard UTI, for the formats a browser moves most. + private static readonly (string Mime, string Uti)[] ClipboardTypeMap = + [ + ("text/plain", PasteboardText), + ("text/html", "public.html"), + ("image/png", "public.png"), + ]; + + private static string? MimeToUti(string mime) + { + foreach (var (m, uti) in ClipboardTypeMap) + { + if (m == mime) + { + return uti; + } + } + return null; + } + + public IReadOnlyList ClipboardFormats + { + get + { + var pb = NSPasteboard.GeneralPasteboard; + var formats = new List(); + foreach (var (mime, uti) in ClipboardTypeMap) + { + if (pb.GetDataForType(uti) is not null) + { + formats.Add(mime); + } + } + return formats; + } + } + + public byte[]? GetClipboardData(string mimeType) => + MimeToUti(mimeType) is { } uti ? NSPasteboard.GeneralPasteboard.GetDataForType(uti)?.ToArray() : null; + + public void SetClipboardData(string mimeType, byte[] data) + { + if (MimeToUti(mimeType) is not { } uti) + { + return; + } + var pb = NSPasteboard.GeneralPasteboard; + pb.ClearContents(); + pb.SetDataForType(NSData.FromArray(data), uti); + } + public bool IsClosing => _closing; public WindowSurfaceSource SurfaceSource => _surfaceSource; diff --git a/src/Skyline/AppWindow.cs b/src/Skyline/AppWindow.cs index cb7909c..8963e95 100644 --- a/src/Skyline/AppWindow.cs +++ b/src/Skyline/AppWindow.cs @@ -158,6 +158,21 @@ public string? ClipboardText set => _backend.ClipboardText = value; } + /// The MIME types currently on the clipboard. + public IReadOnlyList ClipboardFormats => _backend.ClipboardFormats; + + /// + /// Read clipboard data for a MIME type (for example "image/png" or + /// "text/html"), or null if the clipboard holds no such type. + /// + public byte[]? GetClipboardData(string mimeType) => _backend.GetClipboardData(mimeType); + + /// + /// Write clipboard data under a MIME type, replacing the clipboard. The + /// GLFW backend stores text only; other types are dropped there. + /// + public void SetClipboardData(string mimeType, byte[] data) => _backend.SetClipboardData(mimeType, data); + public FrameInfo CurrentFrame => Frame(0); /// True when this window has been adopted by an . Hosted windows render on the host's thread, so use AppHost.Run, not . diff --git a/src/Skyline/GlfwWindowBackend.cs b/src/Skyline/GlfwWindowBackend.cs index 39a7a50..e124a14 100644 --- a/src/Skyline/GlfwWindowBackend.cs +++ b/src/Skyline/GlfwWindowBackend.cs @@ -1,4 +1,5 @@ // SPDX-License-Identifier: Apache-2.0 +using System.Text; using Silk.NET.GLFW; using Silk.NET.Input; using Silk.NET.Maths; @@ -88,6 +89,30 @@ public unsafe string? ClipboardText set => _glfw.SetClipboardString(_glfwHandle, value ?? string.Empty); } + // GLFW's clipboard is text only, so it reports and stores text/plain alone. + private static readonly string[] TextFormats = ["text/plain"]; + + public unsafe IReadOnlyList ClipboardFormats => + string.IsNullOrEmpty(_glfw.GetClipboardString(_glfwHandle)) ? [] : TextFormats; + + public unsafe byte[]? GetClipboardData(string mimeType) + { + if (mimeType == "text/plain" && _glfw.GetClipboardString(_glfwHandle) is { Length: > 0 } s) + { + return Encoding.UTF8.GetBytes(s); + } + return null; + } + + public unsafe void SetClipboardData(string mimeType, byte[] data) + { + // Other MIME types are dropped: GLFW has nowhere to put them. + if (mimeType == "text/plain") + { + _glfw.SetClipboardString(_glfwHandle, Encoding.UTF8.GetString(data)); + } + } + public bool IsClosing => _window.IsClosing; public WindowSurfaceSource SurfaceSource => _surfaceSource; diff --git a/src/Skyline/IWindowBackend.cs b/src/Skyline/IWindowBackend.cs index e1b01c9..cce6546 100644 --- a/src/Skyline/IWindowBackend.cs +++ b/src/Skyline/IWindowBackend.cs @@ -20,6 +20,15 @@ internal interface IWindowBackend : IDisposable string Title { get; set; } string? ClipboardText { get; set; } + /// The MIME types currently on the clipboard. + IReadOnlyList ClipboardFormats { get; } + + /// Read clipboard data for a MIME type, or null if absent. + byte[]? GetClipboardData(string mimeType); + + /// Write clipboard data under a MIME type, replacing the clipboard. + void SetClipboardData(string mimeType, byte[] data); + bool IsClosing { get; } /// How a presenter builds a surface for this window. diff --git a/tests/Skyline.Tests/WindowBackendFactoryTests.cs b/tests/Skyline.Tests/WindowBackendFactoryTests.cs index a7b3ff2..c34b564 100644 --- a/tests/Skyline.Tests/WindowBackendFactoryTests.cs +++ b/tests/Skyline.Tests/WindowBackendFactoryTests.cs @@ -16,6 +16,9 @@ private sealed class FakeBackend : IWindowBackend public (int Width, int Height) LogicalSize => (100, 50); public string Title { get; set; } = "fake"; public string? ClipboardText { get; set; } + public IReadOnlyList ClipboardFormats => []; + public byte[]? GetClipboardData(string mimeType) => null; + public void SetClipboardData(string mimeType, byte[] data) { } public bool IsClosing => false; public WindowSurfaceSource SurfaceSource { get; } = new WindowSurfaceSource.MetalLayer(0x1234); diff --git a/tests/Skyline.WindowedTests/Program.cs b/tests/Skyline.WindowedTests/Program.cs index 9c69096..41a97d3 100644 --- a/tests/Skyline.WindowedTests/Program.cs +++ b/tests/Skyline.WindowedTests/Program.cs @@ -42,6 +42,17 @@ void Check(bool condition, string what) win.ClipboardText = "skyline-test"; Check(win.ClipboardText == "skyline-test", "clipboard round-trips"); +// Typed clipboard on the GLFW backend (text only there). +using (var clipWin = new AppWindow(new AppWindowOptions { Title = "clip", Width = 160, Height = 120, ForceGlfw = true })) +{ + clipWin.SetClipboardData("text/plain", System.Text.Encoding.UTF8.GetBytes("typed-clip")); + var back = clipWin.GetClipboardData("text/plain"); + Check(back is not null && System.Text.Encoding.UTF8.GetString(back) == "typed-clip", "typed text clipboard round-trips on GLFW"); + Check(clipWin.GetClipboardData("image/png") is null, "GLFW reports no data for an unsupported type"); + clipWin.SetClipboardData("image/png", [1, 2, 3]); // dropped, must not throw + Check(clipWin.ClipboardFormats.Contains("text/plain"), "ClipboardFormats lists text/plain when text is present"); +} + win.PumpEvents(); // must not throw without a host // --- Chrome modes construct on the GLFW backend ------------------------