From 793951391f7e5860ca5053185bc51bdfca2d083b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 11:35:50 +0000 Subject: [PATCH 1/3] [minor] Copy and paste images through the system clipboard (#630) Moves ImageGui's image clipboard here: Win32, wl-clipboard/xclip and osascript behind IImageClipboard, with ImageClipboard.ForThisPlatform, TryReadPixels/TryWritePixels over ImageDecoder and a small PNG encoder. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_016dGrC6C1oU6RQupTHitLVE --- CLAUDE.md | 20 ++ ImGui.App/Clipboard/ClipboardImage.cs | 37 +++ .../Clipboard/DeviceIndependentBitmap.cs | 105 ++++++++ ImGui.App/Clipboard/IClipboardProcesses.cs | 25 ++ ImGui.App/Clipboard/IImageClipboard.cs | 27 ++ ImGui.App/Clipboard/ImageClipboard.cs | 89 +++++++ ImGui.App/Clipboard/LinuxImageClipboard.cs | 116 ++++++++ ImGui.App/Clipboard/MacImageClipboard.cs | 86 ++++++ ImGui.App/Clipboard/NativeMethods.cs | 74 ++++++ ImGui.App/Clipboard/PngEncoder.cs | 108 ++++++++ .../Clipboard/SystemClipboardProcesses.cs | 110 ++++++++ .../Clipboard/UnsupportedImageClipboard.cs | 24 ++ ImGui.App/Clipboard/WindowsImageClipboard.cs | 158 +++++++++++ ImGui.App/ImGui.App.csproj | 3 + ImGui.App/README.md | 32 +++ README.md | 2 +- .../Clipboard/ImageClipboardTests.cs | 251 ++++++++++++++++++ 17 files changed, 1266 insertions(+), 1 deletion(-) create mode 100644 ImGui.App/Clipboard/ClipboardImage.cs create mode 100644 ImGui.App/Clipboard/DeviceIndependentBitmap.cs create mode 100644 ImGui.App/Clipboard/IClipboardProcesses.cs create mode 100644 ImGui.App/Clipboard/IImageClipboard.cs create mode 100644 ImGui.App/Clipboard/ImageClipboard.cs create mode 100644 ImGui.App/Clipboard/LinuxImageClipboard.cs create mode 100644 ImGui.App/Clipboard/MacImageClipboard.cs create mode 100644 ImGui.App/Clipboard/NativeMethods.cs create mode 100644 ImGui.App/Clipboard/PngEncoder.cs create mode 100644 ImGui.App/Clipboard/SystemClipboardProcesses.cs create mode 100644 ImGui.App/Clipboard/UnsupportedImageClipboard.cs create mode 100644 ImGui.App/Clipboard/WindowsImageClipboard.cs create mode 100644 tests/ImGui.App.Tests/Clipboard/ImageClipboardTests.cs diff --git a/CLAUDE.md b/CLAUDE.md index 92d161ec..5442e529 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -112,6 +112,7 @@ This is the **ktsu ImGui Suite**, a collection of .NET libraries for building De - `ImGui.App/Images/BmpDecoder.cs` - BMP: core and info headers, 1/4/8/16/24/32 bit, `BI_RGB` and `BI_BITFIELDS` - `ImGui.App/Images/TgaDecoder.cs` - TGA: colour-mapped, true-colour and greyscale, raw and run-length encoded - `ImGui.App/Images/PixelConverter.cs` - Converts caller buffers in any `PixelLayout` (RGBA/BGRA/RGB/BGR/grey, optional row stride) to the RGBA8 behind the layout overloads of `ImGuiApp.CreateTexture`/`UpdateTexture` +- `ImGui.App/Clipboard/ImageClipboard.cs` - Image copy and paste through the system clipboard; see [Image clipboard](#image-clipboard) - `ImGui.App/Images/ImageResampler.cs` - Separable Lanczos-3 scaling on premultiplied alpha, behind `SetWindowIcon` - `ImGui.Widgets/PropertyGrid.cs` - Two-column property grid: options, the path-browse request, and the table/row plumbing (`PropertyGridRows.cs` holds the typed rows, `PropertyGridLists.cs` the list rows). See [Property grid](#property-grid) below - `ImGui.Widgets/Waveform.cs` - Waveform overview: draws a caller-supplied min/max peak overview reduced to the box's pixel width (every peak kept, so sub-pixel transients show), a playhead, and a loop region. `TimelineGestureState.cs` holds the gestures (scrub, loop edge, Shift-drag loop, pan, scrollbar thumb) with no ImGui dependency, shared with any widget over a `TimelineView`, and delegates the loop edges to `HandleTrackState`, so they stay ordered and `minLoopLength` apart by the same rules as a `HandleTrack`. A loop whose ends are equal is "no loop": not drawn, not grabbable, and a press there seeks @@ -306,6 +307,25 @@ Lanczos-3 filter whose support widens by the reduction factor when downscaling, rather than point-samples. It resamples premultiplied alpha and unpremultiplies afterwards, so the colour of fully transparent pixels does not bleed into their visible neighbours. +### Image clipboard + +`ImGui.App/Clipboard/` puts pictures on the system clipboard and reads them back, since GLFW's and SDL's +clipboards carry text only. It came from ImageGui (#630), where it was built for Paste as New Layer. +`ImageClipboard.ForThisPlatform()` picks the implementation; everything behind it is internal and +covered by `tests/ImGui.App.Tests/Clipboard/`. + +- **Windows** goes through `user32`/`kernel32` with `LibraryImport` and `Marshal.Copy`, no `unsafe` + (Sonar fails new pointer code). It reads `PNG`, then `CF_DIBV5`, then `CF_DIB`, putting a BMP file + header back on a DIB so `ImageDecoder` reads it, and writes both PNG and a bottom-up `CF_DIB`. +- **Linux and macOS** run `wl-paste`/`wl-copy`, `xclip` or `osascript` through `IClipboardProcesses`, + found in fixed system folders rather than on the path (Sonar S4036), with a five-second timeout. A + writer's standard output is never read: `xclip` and `wl-copy` leave a child serving the clipboard, + which holds it open until something else is copied. +- **iOS** gets the unsupported clipboard, and `SystemClipboardProcesses.cs` isn't compiled there. + +`PngEncoder` exists only so `ClipboardImage.FromPixels` can build the PNG form: eight-bit RGBA, the Sub +filter on every row, no ancillary chunks. + ### Backend-agnostic 3D `IRenderer3D` is an **optional extension** a renderer backend may also implement, discovered with diff --git a/ImGui.App/Clipboard/ClipboardImage.cs b/ImGui.App/Clipboard/ClipboardImage.cs new file mode 100644 index 00000000..0f008c91 --- /dev/null +++ b/ImGui.App/Clipboard/ClipboardImage.cs @@ -0,0 +1,37 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.ImGui.App.Clipboard; + +using ktsu.ImGui.App.Images; + +/// An image on its way to the clipboard, in the forms the platforms want it. +/// +/// Both forms, because Windows applications differ in which they read: browsers and Office read the +/// registered PNG format and keep transparency, while older ones read only a device-independent bitmap. +/// An application with its own PNG encoder builds one directly; encodes one here. +/// +/// The image as a PNG file. +/// The width in pixels. +/// The height in pixels. +/// The pixels as 8-bit sRGB, blue, green, red and straight alpha, top row first. +public sealed record ClipboardImage(ReadOnlyMemory Png, int Width, int Height, ReadOnlyMemory Bgra) +{ + /// Builds a clipboard image from decoded pixels, encoding the PNG. + /// The pixels, straight-alpha RGBA8. + /// The image. + public static ClipboardImage FromPixels(ImagePixels pixels) + { + Ensure.NotNull(pixels); + ReadOnlySpan rgba = pixels.ReadOnlyPixels; + byte[] bgra = new byte[rgba.Length]; + for (int i = 0; i < rgba.Length; i += 4) + { + bgra[i] = rgba[i + 2]; + bgra[i + 1] = rgba[i + 1]; + bgra[i + 2] = rgba[i]; + bgra[i + 3] = rgba[i + 3]; + } + + return new ClipboardImage(PngEncoder.Encode(pixels), pixels.Width, pixels.Height, bgra); + } +} diff --git a/ImGui.App/Clipboard/DeviceIndependentBitmap.cs b/ImGui.App/Clipboard/DeviceIndependentBitmap.cs new file mode 100644 index 00000000..82f611fe --- /dev/null +++ b/ImGui.App/Clipboard/DeviceIndependentBitmap.cs @@ -0,0 +1,105 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.ImGui.App.Clipboard; + +using System.Buffers.Binary; + +/// Converts between the Windows clipboard's device-independent bitmaps and files the codec reads. +/// +/// A clipboard DIB is a BMP file without its 14-byte file header, so reading one means putting the header +/// back, with the offset to the pixels worked out from what follows the info header: three colour masks +/// for an old-style header with bit fields, and a palette for eight bits or fewer. +/// +internal static class DeviceIndependentBitmap +{ + /// The size of a BITMAPINFOHEADER. + public const int InfoHeaderSize = 40; + + private const int FileHeaderSize = 14; + private const uint Rgb = 0; + private const uint BitFields = 3; + private const uint AlphaBitFields = 6; + + /// Puts a BMP file header in front of a clipboard DIB. + /// The DIB: an info header of any version, then masks, palette and pixels. + /// The BMP file, or null when the DIB is too short to be one. + public static byte[]? ToBitmapFile(ReadOnlySpan dib) + { + if (dib.Length < InfoHeaderSize) + { + return null; + } + + int headerSize = BinaryPrimitives.ReadInt32LittleEndian(dib); + if (headerSize < InfoHeaderSize || headerSize > dib.Length) + { + return null; + } + + int bitCount = BinaryPrimitives.ReadUInt16LittleEndian(dib[14..]); + uint compression = BinaryPrimitives.ReadUInt32LittleEndian(dib[16..]); + uint colorsUsed = BinaryPrimitives.ReadUInt32LittleEndian(dib[32..]); + + // A version 4 or 5 header holds its masks itself; the original one is followed by them. + int masks = headerSize == InfoHeaderSize ? compression switch + { + BitFields => 12, + AlphaBitFields => 16, + _ => 0, + } : 0; + long paletteEntries = colorsUsed != 0 ? colorsUsed : bitCount <= 8 ? 1L << bitCount : 0; + long pixelOffset = headerSize + masks + (paletteEntries * 4); + if (pixelOffset > dib.Length) + { + return null; + } + + byte[] file = new byte[FileHeaderSize + dib.Length]; + file[0] = (byte)'B'; + file[1] = (byte)'M'; + BinaryPrimitives.WriteInt32LittleEndian(file.AsSpan(2), file.Length); + BinaryPrimitives.WriteInt32LittleEndian(file.AsSpan(10), FileHeaderSize + (int)pixelOffset); + dib.CopyTo(file.AsSpan(FileHeaderSize)); + + return file; + } + + /// Builds a 32-bit bottom-up DIB, with an original info header, from top-down BGRA rows. + /// The width in pixels. + /// The height in pixels. + /// The pixels, top row first, four bytes each. + /// The DIB. + public static byte[] FromBgra(int width, int height, ReadOnlySpan bgra) + { + ArgumentOutOfRangeException.ThrowIfLessThan(width, 1); + ArgumentOutOfRangeException.ThrowIfLessThan(height, 1); + int stride = width * 4; + if (bgra.Length != stride * height) + { + throw new ArgumentException("The pixels don't fill the size given.", nameof(bgra)); + } + + byte[] dib = new byte[InfoHeaderSize + bgra.Length]; + Span header = dib.AsSpan(0, InfoHeaderSize); + BinaryPrimitives.WriteInt32LittleEndian(header, InfoHeaderSize); + BinaryPrimitives.WriteInt32LittleEndian(header[4..], width); + + // Bottom-up, a positive height, because some applications still misread a top-down clipboard DIB. + BinaryPrimitives.WriteInt32LittleEndian(header[8..], height); + BinaryPrimitives.WriteUInt16LittleEndian(header[12..], 1); + BinaryPrimitives.WriteUInt16LittleEndian(header[14..], 32); + BinaryPrimitives.WriteUInt32LittleEndian(header[16..], Rgb); + BinaryPrimitives.WriteInt32LittleEndian(header[20..], bgra.Length); + + // 72 dots per inch, in dots per metre. + BinaryPrimitives.WriteInt32LittleEndian(header[24..], 2835); + BinaryPrimitives.WriteInt32LittleEndian(header[28..], 2835); + + for (int row = 0; row < height; row++) + { + bgra.Slice(row * stride, stride).CopyTo(dib.AsSpan(InfoHeaderSize + ((height - 1 - row) * stride))); + } + + return dib; + } +} diff --git a/ImGui.App/Clipboard/IClipboardProcesses.cs b/ImGui.App/Clipboard/IClipboardProcesses.cs new file mode 100644 index 00000000..a8157136 --- /dev/null +++ b/ImGui.App/Clipboard/IClipboardProcesses.cs @@ -0,0 +1,25 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.ImGui.App.Clipboard; + +/// Runs the command-line tools a platform's clipboard is reached through. +/// An interface so tests can stand in for tools the machine running them doesn't have. +internal interface IClipboardProcesses +{ + /// Runs a program and waits for it to finish. + /// The program's file name, looked for in the system's own folders. + /// Its arguments, passed exactly. + /// What to write to its standard input, or null for nothing. + /// + /// Whether to read its standard output. Off when writing to the clipboard: xclip and + /// wl-copy leave a child behind to serve the clipboard, which holds a redirected output open + /// for as long as it serves, so reading it would wait until something else is copied. + /// + /// How it ended, or null when it couldn't be started, which usually means it isn't installed. + public ClipboardProcessResult? Run(string program, IReadOnlyList arguments, byte[]? input, bool captureOutput); +} + +/// How a clipboard tool ended. +/// Its exit code, or -1 when it was stopped for taking too long. +/// What it wrote to standard output, when that was read. +internal sealed record ClipboardProcessResult(int ExitCode, byte[] Output); diff --git a/ImGui.App/Clipboard/IImageClipboard.cs b/ImGui.App/Clipboard/IImageClipboard.cs new file mode 100644 index 00000000..96051250 --- /dev/null +++ b/ImGui.App/Clipboard/IImageClipboard.cs @@ -0,0 +1,27 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.ImGui.App.Clipboard; + +/// Reads an image from the system clipboard and puts one on it. +/// +/// GLFW's and SDL's clipboards carry text only, so each platform reaches its own: the Win32 clipboard, +/// the Wayland and X11 command-line tools, and the macOS pasteboard through AppleScript. The image +/// travels as an encoded file rather than as pixels, so a reader only needs a codec to know what it +/// holds; decodes it with . +/// Get the platform's clipboard from ; an application's +/// tests can stand in their own implementation. +/// +public interface IImageClipboard +{ + /// Reads the image on the clipboard. + /// The encoded image (a PNG, JPEG or BMP file's bytes) when there is one. + /// Why there isn't one: the clipboard holds no image, or it can't be reached. + /// Whether an image was read. + public bool TryRead(out byte[]? image, out string? message); + + /// Replaces what the clipboard holds with an image. + /// The image, as a PNG and as top-down straight-alpha BGRA rows. + /// Why it couldn't be written, when it couldn't. + /// Whether the clipboard now holds the image. + public bool TryWrite(ClipboardImage image, out string? message); +} diff --git a/ImGui.App/Clipboard/ImageClipboard.cs b/ImGui.App/Clipboard/ImageClipboard.cs new file mode 100644 index 00000000..34ae1d98 --- /dev/null +++ b/ImGui.App/Clipboard/ImageClipboard.cs @@ -0,0 +1,89 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.ImGui.App.Clipboard; + +using ktsu.ImGui.App.Images; + +/// The image clipboard for the platform the application runs on, and helpers over any . +/// +/// +/// Windows reads the registered PNG format, then CF_DIBV5 and CF_DIB, and writes PNG and +/// CF_DIB. Linux and FreeBSD run wl-paste/wl-copy under Wayland and xclip under +/// X11, and macOS runs osascript; each tool is found in the system's own folders rather than on the +/// path, and every run is stopped after a few seconds. Anywhere else, every call fails with a message. +/// +/// +/// Reading starts a short-lived process everywhere but Windows, so it takes a moment; an application can +/// call it on the render thread, as a paste usually wants, or on a worker. +/// +/// +public static class ImageClipboard +{ + /// Said when the clipboard holds something that isn't an image. + public const string NoImageMessage = "The clipboard doesn't hold an image."; + + /// Said on Linux when neither wl-clipboard nor xclip is installed. + public const string NoToolMessage = "Copying and pasting images needs wl-clipboard (on Wayland) or xclip (on X11). Install one with your package manager."; + + /// Gets the image clipboard for this platform. + /// The clipboard. + public static IImageClipboard ForThisPlatform() + { +#if IOS + return new UnsupportedImageClipboard(); +#else + if (OperatingSystem.IsWindows()) + { + return new WindowsImageClipboard(); + } + + SystemClipboardProcesses processes = new(); + if (OperatingSystem.IsMacOS()) + { + return new MacImageClipboard(processes); + } + + return OperatingSystem.IsLinux() || OperatingSystem.IsFreeBSD() + ? LinuxImageClipboard.ForThisSession(processes) + : new UnsupportedImageClipboard(); +#endif + } + + /// Reads the clipboard's image and decodes it. + /// The clipboard. + /// The decoded image, straight-alpha RGBA8, when there is one. + /// Why there isn't one: no image, an unreachable clipboard, or an image can't read. + /// Whether an image was read and decoded. + public static bool TryReadPixels(this IImageClipboard clipboard, out ImagePixels? pixels, out string? message) + { + Ensure.NotNull(clipboard); + pixels = null; + if (!clipboard.TryRead(out byte[]? image, out message) || image is null) + { + message ??= NoImageMessage; + return false; + } + + try + { + pixels = ImageDecoder.Decode(image); + return true; + } + catch (InvalidImageDataException exception) + { + message = $"The clipboard's image couldn't be read: {exception.Message}"; + return false; + } + } + + /// Puts decoded pixels on the clipboard, as a PNG and as a bitmap. + /// The clipboard. + /// The image, straight-alpha RGBA8. + /// Why it couldn't be written, when it couldn't. + /// Whether the clipboard now holds the image. + public static bool TryWritePixels(this IImageClipboard clipboard, ImagePixels pixels, out string? message) + { + Ensure.NotNull(clipboard); + return clipboard.TryWrite(ClipboardImage.FromPixels(pixels), out message); + } +} diff --git a/ImGui.App/Clipboard/LinuxImageClipboard.cs b/ImGui.App/Clipboard/LinuxImageClipboard.cs new file mode 100644 index 00000000..4814a917 --- /dev/null +++ b/ImGui.App/Clipboard/LinuxImageClipboard.cs @@ -0,0 +1,116 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.ImGui.App.Clipboard; + +using System.Text; + +/// The clipboard on Linux, through wl-paste/wl-copy under Wayland and xclip under X11. +/// +/// Neither display server lets a client read or offer a clipboard without running an event loop GLFW +/// doesn't expose, so the standard command-line tools do it. A Wayland session prefers wl-clipboard and +/// falls back to xclip, which reaches XWayland's clipboard that most Wayland compositors keep in +/// step with their own. +/// +/// Runs the tools. +/// Whether the session is a Wayland one. +internal sealed class LinuxImageClipboard(IClipboardProcesses processes, bool wayland) : IImageClipboard +{ + private const string Png = "image/png"; + + /// Gets the clipboard for this session, Wayland or not as its environment says. + /// Runs the tools. + /// The clipboard. + public static LinuxImageClipboard ForThisSession(IClipboardProcesses processes) => + new(processes, !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("WAYLAND_DISPLAY"))); + + /// + public bool TryRead(out byte[]? image, out string? message) + { + image = null; + bool anyTool = false; + foreach (Tool tool in Tools()) + { + ClipboardProcessResult? types = processes.Run(tool.Program, tool.ListTypes, null, captureOutput: true); + if (types is null) + { + continue; + } + + anyTool = true; + if (types.ExitCode != 0 || PickType(Encoding.UTF8.GetString(types.Output)) is not string type) + { + continue; + } + + ClipboardProcessResult? read = processes.Run(tool.Program, tool.Read(type), null, captureOutput: true); + if (read is { ExitCode: 0, Output.Length: > 0 }) + { + image = read.Output; + message = null; + return true; + } + } + + message = anyTool ? ImageClipboard.NoImageMessage : ImageClipboard.NoToolMessage; + return false; + } + + /// + public bool TryWrite(ClipboardImage image, out string? message) + { + Ensure.NotNull(image); + byte[] png = image.Png.ToArray(); + bool anyTool = false; + foreach (Tool tool in Tools()) + { + ClipboardProcessResult? written = processes.Run(tool.WriteProgram, tool.Write, png, captureOutput: false); + if (written is null) + { + continue; + } + + anyTool = true; + if (written.ExitCode == 0) + { + message = null; + return true; + } + } + + message = anyTool ? "The image couldn't be put on the clipboard." : ImageClipboard.NoToolMessage; + return false; + } + + /// Picks the image type to read from a tool's list of what the clipboard offers: PNG first, then any image. + /// The list, one type per line. + /// The type, or null when nothing on offer is an image. + internal static string? PickType(string types) + { + string[] offered = [.. types.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)]; + return offered.FirstOrDefault(static type => type.Equals(Png, StringComparison.OrdinalIgnoreCase)) + ?? offered.FirstOrDefault(static type => type is "image/jpeg" or "image/bmp" or "image/webp"); + } + + private IEnumerable Tools() + { + if (wayland) + { + yield return new Tool( + "wl-paste", + ["--list-types"], + static type => ["--no-newline", "--type", type], + "wl-copy", + ["--type", Png]); + } + + yield return new Tool( + "xclip", + ["-selection", "clipboard", "-target", "TARGETS", "-out"], + static type => ["-selection", "clipboard", "-target", type, "-out"], + "xclip", + ["-selection", "clipboard", "-target", Png, "-in"]); + } + + /// One tool's commands. Reading and writing are separate programs under Wayland. + private sealed record Tool(string Program, string[] ListTypes, Func Read, string WriteProgram, string[] Write); +} diff --git a/ImGui.App/Clipboard/MacImageClipboard.cs b/ImGui.App/Clipboard/MacImageClipboard.cs new file mode 100644 index 00000000..c237ac23 --- /dev/null +++ b/ImGui.App/Clipboard/MacImageClipboard.cs @@ -0,0 +1,86 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.ImGui.App.Clipboard; + +/// The macOS pasteboard, reached through AppleScript. +/// +/// osascript ships with every macOS and reads and writes the general pasteboard's PNG flavour, +/// which screenshots, browsers and image editors all put there, without native code here. AppleScript +/// can't hand binary data to its standard output, so the image goes through a temporary file. +/// +/// Runs osascript. +internal sealed class MacImageClipboard(IClipboardProcesses processes) : IImageClipboard +{ + private const string Program = "osascript"; + + /// + public bool TryRead(out byte[]? image, out string? message) + { + image = null; + string path = TemporaryPath(); + try + { + ClipboardProcessResult? result = processes.Run(Program, ReadArguments(path), null, captureOutput: false); + if (result is null) + { + message = "The clipboard couldn't be reached: osascript couldn't be started."; + return false; + } + + // Real disk: osascript reads and writes these temporary files. + if (result.ExitCode != 0 || !File.Exists(path) || new FileInfo(path).Length == 0) + { + message = ImageClipboard.NoImageMessage; + return false; + } + + // Real disk: osascript reads and writes these temporary files. + image = File.ReadAllBytes(path); + message = null; + return true; + } + finally + { + File.Delete(path); + } + } + + /// + public bool TryWrite(ClipboardImage image, out string? message) + { + Ensure.NotNull(image); + string path = TemporaryPath(); + try + { + // Real disk: osascript reads and writes these temporary files. + File.WriteAllBytes(path, image.Png.ToArray()); + ClipboardProcessResult? result = processes.Run(Program, WriteArguments(path), null, captureOutput: false); + message = result is { ExitCode: 0 } ? null : "The image couldn't be put on the clipboard."; + return message is null; + } + finally + { + File.Delete(path); + } + } + + /// The script that writes the pasteboard's PNG to , failing when there is none. + internal static string[] ReadArguments(string path) => + [ + "-e", "set png to (the clipboard as «class PNGf»)", + "-e", $"set target to open for access (POSIX file \"{Escape(path)}\") with write permission", + "-e", "set eof target to 0", + "-e", "write png to target", + "-e", "close access target", + ]; + + /// The script that puts the PNG at on the pasteboard. + internal static string[] WriteArguments(string path) => + ["-e", $"set the clipboard to (read (POSIX file \"{Escape(path)}\") as «class PNGf»)"]; + + // The temporary directory can contain anything a user's name can; inside an AppleScript string only + // the quote and the backslash mean something. + private static string Escape(string path) => path.Replace("\\", "\\\\", StringComparison.Ordinal).Replace("\"", "\\\"", StringComparison.Ordinal); + + private static string TemporaryPath() => Path.Combine(Path.GetTempPath(), $"imgui-clipboard-{Guid.NewGuid():N}.png"); +} diff --git a/ImGui.App/Clipboard/NativeMethods.cs b/ImGui.App/Clipboard/NativeMethods.cs new file mode 100644 index 00000000..14a84c99 --- /dev/null +++ b/ImGui.App/Clipboard/NativeMethods.cs @@ -0,0 +1,74 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.ImGui.App.Clipboard; + +using System.Runtime.InteropServices; +using System.Runtime.Versioning; + +/// The Win32 clipboard and global memory calls the Windows clipboard needs. +/// Handles and pointers stay and are copied with , so nothing here is unsafe. +[SupportedOSPlatform("windows")] +internal static partial class NativeMethods +{ + /// CF_DIB: a BITMAPINFO followed by the pixels. + public const uint DibFormat = 8; + + /// CF_DIBV5: a BITMAPV5HEADER followed by the pixels. + public const uint DibV5Format = 17; + + /// GMEM_MOVEABLE, which memory handed to the clipboard has to be. + public const uint Moveable = 0x0002; + + [LibraryImport("user32.dll", SetLastError = true)] + [DefaultDllImportSearchPaths(DllImportSearchPath.System32)] + [return: MarshalAs(UnmanagedType.Bool)] + public static partial bool OpenClipboard(nint owner); + + [LibraryImport("user32.dll")] + [DefaultDllImportSearchPaths(DllImportSearchPath.System32)] + [return: MarshalAs(UnmanagedType.Bool)] + public static partial bool CloseClipboard(); + + [LibraryImport("user32.dll")] + [DefaultDllImportSearchPaths(DllImportSearchPath.System32)] + [return: MarshalAs(UnmanagedType.Bool)] + public static partial bool EmptyClipboard(); + + [LibraryImport("user32.dll")] + [DefaultDllImportSearchPaths(DllImportSearchPath.System32)] + [return: MarshalAs(UnmanagedType.Bool)] + public static partial bool IsClipboardFormatAvailable(uint format); + + [LibraryImport("user32.dll")] + [DefaultDllImportSearchPaths(DllImportSearchPath.System32)] + public static partial nint GetClipboardData(uint format); + + [LibraryImport("user32.dll")] + [DefaultDllImportSearchPaths(DllImportSearchPath.System32)] + public static partial nint SetClipboardData(uint format, nint memory); + + [LibraryImport("user32.dll", EntryPoint = "RegisterClipboardFormatW", StringMarshalling = StringMarshalling.Utf16)] + [DefaultDllImportSearchPaths(DllImportSearchPath.System32)] + public static partial uint RegisterClipboardFormat(string name); + + [LibraryImport("kernel32.dll")] + [DefaultDllImportSearchPaths(DllImportSearchPath.System32)] + public static partial nint GlobalAlloc(uint flags, nuint bytes); + + [LibraryImport("kernel32.dll")] + [DefaultDllImportSearchPaths(DllImportSearchPath.System32)] + public static partial nint GlobalFree(nint memory); + + [LibraryImport("kernel32.dll")] + [DefaultDllImportSearchPaths(DllImportSearchPath.System32)] + public static partial nint GlobalLock(nint memory); + + [LibraryImport("kernel32.dll")] + [DefaultDllImportSearchPaths(DllImportSearchPath.System32)] + [return: MarshalAs(UnmanagedType.Bool)] + public static partial bool GlobalUnlock(nint memory); + + [LibraryImport("kernel32.dll")] + [DefaultDllImportSearchPaths(DllImportSearchPath.System32)] + public static partial nuint GlobalSize(nint memory); +} diff --git a/ImGui.App/Clipboard/PngEncoder.cs b/ImGui.App/Clipboard/PngEncoder.cs new file mode 100644 index 00000000..ca1d9175 --- /dev/null +++ b/ImGui.App/Clipboard/PngEncoder.cs @@ -0,0 +1,108 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.ImGui.App.Clipboard; + +using System.Buffers.Binary; +using System.IO.Compression; +using System.Text; + +using ktsu.ImGui.App.Images; + +/// Writes RGBA8 pixels as a PNG file, the form a clipboard image travels in. +/// +/// The smallest encoder that does the job: one IHDR, one IDAT and an IEND, eight-bit +/// RGBA, no interlacing, and the Sub filter on every row, which costs nothing to choose and compresses +/// photographs and flat colour alike far better than no filter at all. Nothing ancillary is written. +/// +internal static class PngEncoder +{ + private const byte ColorTypeRgba = 6; + private const byte FilterSub = 1; + + private static readonly byte[] Signature = [0x89, (byte)'P', (byte)'N', (byte)'G', 0x0D, 0x0A, 0x1A, 0x0A]; + + private static readonly uint[] CrcTable = BuildCrcTable(); + + /// Encodes an image. + /// The image, straight-alpha RGBA8. + /// The PNG file's bytes. + public static byte[] Encode(ImagePixels pixels) + { + Ensure.NotNull(pixels); + int stride = pixels.Width * 4; + ReadOnlySpan source = pixels.ReadOnlyPixels; + + using MemoryStream compressed = new(); + using (ZLibStream zlib = new(compressed, CompressionLevel.Optimal, leaveOpen: true)) + { + byte[] row = new byte[stride + 1]; + row[0] = FilterSub; + for (int y = 0; y < pixels.Height; y++) + { + ReadOnlySpan line = source.Slice(y * stride, stride); + for (int x = 0; x < stride; x++) + { + row[x + 1] = x < 4 ? line[x] : (byte)(line[x] - line[x - 4]); + } + + zlib.Write(row); + } + } + + using MemoryStream file = new(); + file.Write(Signature); + + byte[] header = new byte[13]; + BinaryPrimitives.WriteInt32BigEndian(header, pixels.Width); + BinaryPrimitives.WriteInt32BigEndian(header.AsSpan(4), pixels.Height); + header[8] = 8; + header[9] = ColorTypeRgba; + WriteChunk(file, "IHDR", header); + WriteChunk(file, "IDAT", compressed.GetBuffer().AsSpan(0, (int)compressed.Length)); + WriteChunk(file, "IEND", []); + return file.ToArray(); + } + + private static void WriteChunk(MemoryStream file, string type, ReadOnlySpan data) + { + Span word = stackalloc byte[4]; + BinaryPrimitives.WriteInt32BigEndian(word, data.Length); + file.Write(word); + + Span typeBytes = stackalloc byte[4]; + _ = Encoding.ASCII.GetBytes(type, typeBytes); + file.Write(typeBytes); + file.Write(data); + + uint crc = Crc(Crc(uint.MaxValue, typeBytes), data) ^ uint.MaxValue; + BinaryPrimitives.WriteUInt32BigEndian(word, crc); + file.Write(word); + } + + private static uint Crc(uint crc, ReadOnlySpan data) + { + foreach (byte b in data) + { + crc = CrcTable[(crc ^ b) & 0xFF] ^ (crc >> 8); + } + + return crc; + } + + private static uint[] BuildCrcTable() + { + uint[] table = new uint[256]; + for (uint n = 0; n < 256; n++) + { + uint c = n; + for (int k = 0; k < 8; k++) + { + c = (c & 1) != 0 ? 0xEDB88320u ^ (c >> 1) : c >> 1; + } + + table[n] = c; + } + + return table; + } +} diff --git a/ImGui.App/Clipboard/SystemClipboardProcesses.cs b/ImGui.App/Clipboard/SystemClipboardProcesses.cs new file mode 100644 index 00000000..90d855a0 --- /dev/null +++ b/ImGui.App/Clipboard/SystemClipboardProcesses.cs @@ -0,0 +1,110 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.ImGui.App.Clipboard; + +using System.ComponentModel; +using System.Diagnostics; + +/// Runs clipboard tools as real processes. +/// +/// +/// Run on the render thread for a paste, so every run is bounded: a tool that hasn't finished in a few +/// seconds is stopped and reported as having failed, rather than leaving the window frozen. +/// +/// +/// A tool is found in the system's own folders rather than on the path, as the file type registrar +/// finds its tools, so nothing earlier on someone's path can stand in for it. +/// +/// +internal sealed class SystemClipboardProcesses : IClipboardProcesses +{ + private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(5); + + private static readonly string[] ToolDirectories = ["/usr/bin", "/usr/local/bin", "/bin", "/opt/homebrew/bin"]; + + /// + public ClipboardProcessResult? Run(string program, IReadOnlyList arguments, byte[]? input, bool captureOutput) + { + // Real disk: looks for an operating system tool to run. + string? path = ToolDirectories.Select(directory => Path.Combine(directory, program)).FirstOrDefault(File.Exists); + if (path is null) + { + return null; + } + + ProcessStartInfo start = new(path) + { + UseShellExecute = false, + RedirectStandardInput = input is not null, + RedirectStandardOutput = captureOutput, + CreateNoWindow = true, + }; + foreach (string argument in arguments) + { + start.ArgumentList.Add(argument); + } + + Process? process; + try + { + process = Process.Start(start); + } + catch (Win32Exception) + { + return null; + } + + if (process is null) + { + return null; + } + + using (process) + { + Task output = captureOutput ? ReadAllAsync(process.StandardOutput.BaseStream) : Task.FromResult(Array.Empty()); + if (input is not null) + { + try + { + using Stream stdin = process.StandardInput.BaseStream; + stdin.Write(input); + } + catch (IOException) + { + // It exited without reading everything; its exit code says why. + } + } + + if (!process.WaitForExit(Timeout) || !output.Wait(Timeout)) + { + Kill(process); + return new ClipboardProcessResult(-1, []); + } + + return new ClipboardProcessResult(process.ExitCode, output.Result); + } + } + + private static async Task ReadAllAsync(Stream stream) + { + using MemoryStream buffer = new(); + await stream.CopyToAsync(buffer).ConfigureAwait(false); + return buffer.ToArray(); + } + + private static void Kill(Process process) + { + try + { + process.Kill(entireProcessTree: true); + } + catch (InvalidOperationException) + { + // It finished on its own in the meantime. + } + catch (Win32Exception) + { + // It can't be stopped from here; there is nothing more to do about it. + } + } +} diff --git a/ImGui.App/Clipboard/UnsupportedImageClipboard.cs b/ImGui.App/Clipboard/UnsupportedImageClipboard.cs new file mode 100644 index 00000000..1f646337 --- /dev/null +++ b/ImGui.App/Clipboard/UnsupportedImageClipboard.cs @@ -0,0 +1,24 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.ImGui.App.Clipboard; + +/// The clipboard on a platform with no image clipboard this library can reach. +internal sealed class UnsupportedImageClipboard : IImageClipboard +{ + private const string Message = "Copying and pasting images isn't supported on this platform."; + + /// + public bool TryRead(out byte[]? image, out string? message) + { + image = null; + message = Message; + return false; + } + + /// + public bool TryWrite(ClipboardImage image, out string? message) + { + message = Message; + return false; + } +} diff --git a/ImGui.App/Clipboard/WindowsImageClipboard.cs b/ImGui.App/Clipboard/WindowsImageClipboard.cs new file mode 100644 index 00000000..697b6541 --- /dev/null +++ b/ImGui.App/Clipboard/WindowsImageClipboard.cs @@ -0,0 +1,158 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.ImGui.App.Clipboard; + +using System.Runtime.InteropServices; +using System.Runtime.Versioning; + +using static ktsu.ImGui.App.Clipboard.NativeMethods; + +/// The Win32 clipboard. +/// +/// Reads the registered PNG format first, which browsers, Office and most editors offer and which +/// keeps transparency, then CF_DIBV5 and CF_DIB, which Windows synthesizes from each other +/// and from any bitmap an older application copies. Writes both PNG and CF_DIB. +/// +[SupportedOSPlatform("windows")] +internal sealed class WindowsImageClipboard : IImageClipboard +{ + // Another application can hold the clipboard open for a moment; asking again shortly is the usual remedy. + private const int OpenAttempts = 10; + + private static readonly TimeSpan OpenRetryDelay = TimeSpan.FromMilliseconds(20); + + private readonly uint pngFormat = RegisterClipboardFormat("PNG"); + + /// + public bool TryRead(out byte[]? image, out string? message) + { + image = null; + if (!Open()) + { + message = "The clipboard is in use by another application. Try again in a moment."; + return false; + } + + try + { + if (pngFormat != 0 && Read(pngFormat) is byte[] png) + { + image = png; + } + else if (Read(DibV5Format) is byte[] v5) + { + image = DeviceIndependentBitmap.ToBitmapFile(v5); + } + else if (Read(DibFormat) is byte[] dib) + { + image = DeviceIndependentBitmap.ToBitmapFile(dib); + } + } + finally + { + _ = CloseClipboard(); + } + + message = image is null ? ImageClipboard.NoImageMessage : null; + return image is not null; + } + + /// + public bool TryWrite(ClipboardImage image, out string? message) + { + Ensure.NotNull(image); + if (!Open()) + { + message = "The clipboard is in use by another application. Try again in a moment."; + return false; + } + + try + { + _ = EmptyClipboard(); + bool written = pngFormat != 0 && Write(pngFormat, image.Png.ToArray()); + written |= Write(DibFormat, DeviceIndependentBitmap.FromBgra(image.Width, image.Height, image.Bgra.Span)); + message = written ? null : "The image couldn't be put on the clipboard."; + return written; + } + finally + { + _ = CloseClipboard(); + } + } + + private static bool Open() + { + for (int attempt = 0; attempt < OpenAttempts; attempt++) + { + if (OpenClipboard(0)) + { + return true; + } + + Thread.Sleep(OpenRetryDelay); + } + + return false; + } + + private static byte[]? Read(uint format) + { + if (!IsClipboardFormatAvailable(format)) + { + return null; + } + + nint handle = GetClipboardData(format); + if (handle == 0) + { + return null; + } + + nuint size = GlobalSize(handle); + nint data = GlobalLock(handle); + if (data == 0 || size == 0 || size > int.MaxValue) + { + return null; + } + + try + { + byte[] bytes = new byte[(int)size]; + Marshal.Copy(data, bytes, 0, bytes.Length); + return bytes; + } + finally + { + _ = GlobalUnlock(handle); + } + } + + private static bool Write(uint format, byte[] bytes) + { + nint handle = GlobalAlloc(Moveable, (nuint)bytes.Length); + if (handle == 0) + { + return false; + } + + nint data = GlobalLock(handle); + if (data == 0) + { + _ = GlobalFree(handle); + return false; + } + + Marshal.Copy(bytes, 0, data, bytes.Length); + _ = GlobalUnlock(handle); + + // On success the clipboard owns the memory; only a refusal leaves it ours to free. + if (SetClipboardData(format, handle) == 0) + { + _ = GlobalFree(handle); + return false; + } + + return true; + } +} diff --git a/ImGui.App/ImGui.App.csproj b/ImGui.App/ImGui.App.csproj index a4a1f6be..7f942b05 100644 --- a/ImGui.App/ImGui.App.csproj +++ b/ImGui.App/ImGui.App.csproj @@ -75,6 +75,9 @@ this type live in ImGuiController/, which is also removed below. --> + +