Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
37 changes: 37 additions & 0 deletions ImGui.App/Clipboard/ClipboardImage.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
// Copyright (c) 2023-2026 ktsu-dev contributors

namespace ktsu.ImGui.App.Clipboard;

using ktsu.ImGui.App.Images;

/// <summary>An image on its way to the clipboard, in the forms the platforms want it.</summary>
/// <remarks>
/// 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; <see cref="FromPixels"/> encodes one here.
/// </remarks>
/// <param name="Png">The image as a PNG file.</param>
/// <param name="Width">The width in pixels.</param>
/// <param name="Height">The height in pixels.</param>
/// <param name="Bgra">The pixels as 8-bit sRGB, blue, green, red and straight alpha, top row first.</param>
public sealed record ClipboardImage(ReadOnlyMemory<byte> Png, int Width, int Height, ReadOnlyMemory<byte> Bgra)
{
/// <summary>Builds a clipboard image from decoded pixels, encoding the PNG.</summary>
/// <param name="pixels">The pixels, straight-alpha RGBA8.</param>
/// <returns>The image.</returns>
public static ClipboardImage FromPixels(ImagePixels pixels)
{
Ensure.NotNull(pixels);
ReadOnlySpan<byte> 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);
}
}
105 changes: 105 additions & 0 deletions ImGui.App/Clipboard/DeviceIndependentBitmap.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
// Copyright (c) 2023-2026 ktsu-dev contributors

namespace ktsu.ImGui.App.Clipboard;

using System.Buffers.Binary;

/// <summary>Converts between the Windows clipboard's device-independent bitmaps and files the codec reads.</summary>
/// <remarks>
/// 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.
/// </remarks>
internal static class DeviceIndependentBitmap
{
/// <summary>The size of a <c>BITMAPINFOHEADER</c>.</summary>
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;

/// <summary>Puts a BMP file header in front of a clipboard DIB.</summary>
/// <param name="dib">The DIB: an info header of any version, then masks, palette and pixels.</param>
/// <returns>The BMP file, or null when the DIB is too short to be one.</returns>
public static byte[]? ToBitmapFile(ReadOnlySpan<byte> 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;

Check warning on line 50 in ImGui.App/Clipboard/DeviceIndependentBitmap.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Extract this nested ternary operation into an independent statement.

Check warning on line 50 in ImGui.App/Clipboard/DeviceIndependentBitmap.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Extract this nested ternary operation into an independent statement.

Check warning on line 50 in ImGui.App/Clipboard/DeviceIndependentBitmap.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Extract this nested ternary operation into an independent statement.

Check warning on line 50 in ImGui.App/Clipboard/DeviceIndependentBitmap.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Extract this nested ternary operation into an independent statement.

Check warning on line 50 in ImGui.App/Clipboard/DeviceIndependentBitmap.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Extract this nested ternary operation into an independent statement.

Check warning on line 50 in ImGui.App/Clipboard/DeviceIndependentBitmap.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Extract this nested ternary operation into an independent statement.

Check warning on line 50 in ImGui.App/Clipboard/DeviceIndependentBitmap.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Extract this nested ternary operation into an independent statement.

Check warning on line 50 in ImGui.App/Clipboard/DeviceIndependentBitmap.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Extract this nested ternary operation into an independent statement.

Check warning on line 50 in ImGui.App/Clipboard/DeviceIndependentBitmap.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Extract this nested ternary operation into an independent statement.

Check warning on line 50 in ImGui.App/Clipboard/DeviceIndependentBitmap.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Extract this nested ternary operation into an independent statement.

Check warning on line 50 in ImGui.App/Clipboard/DeviceIndependentBitmap.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Extract this nested ternary operation into an independent statement.

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_ImGuiApp&issues=AaEwAB4rlH6ne9HeYK4x&open=AaEwAB4rlH6ne9HeYK4x&pullRequest=631
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;
}

/// <summary>Builds a 32-bit bottom-up DIB, with an original info header, from top-down BGRA rows.</summary>
/// <param name="width">The width in pixels.</param>
/// <param name="height">The height in pixels.</param>
/// <param name="bgra">The pixels, top row first, four bytes each.</param>
/// <returns>The DIB.</returns>
public static byte[] FromBgra(int width, int height, ReadOnlySpan<byte> 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<byte> 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;
}
}
25 changes: 25 additions & 0 deletions ImGui.App/Clipboard/IClipboardProcesses.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
// Copyright (c) 2023-2026 ktsu-dev contributors

namespace ktsu.ImGui.App.Clipboard;

/// <summary>Runs the command-line tools a platform's clipboard is reached through.</summary>
/// <remarks>An interface so tests can stand in for tools the machine running them doesn't have.</remarks>
internal interface IClipboardProcesses
{
/// <summary>Runs a program and waits for it to finish.</summary>
/// <param name="program">The program's file name, looked for in the system's own folders.</param>
/// <param name="arguments">Its arguments, passed exactly.</param>
/// <param name="input">What to write to its standard input, or null for nothing.</param>
/// <param name="captureOutput">
/// Whether to read its standard output. Off when writing to the clipboard: <c>xclip</c> and
/// <c>wl-copy</c> 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.
/// </param>
/// <returns>How it ended, or null when it couldn't be started, which usually means it isn't installed.</returns>
public ClipboardProcessResult? Run(string program, IReadOnlyList<string> arguments, byte[]? input, bool captureOutput);
}

/// <summary>How a clipboard tool ended.</summary>
/// <param name="ExitCode">Its exit code, or -1 when it was stopped for taking too long.</param>
/// <param name="Output">What it wrote to standard output, when that was read.</param>
internal sealed record ClipboardProcessResult(int ExitCode, byte[] Output);
27 changes: 27 additions & 0 deletions ImGui.App/Clipboard/IImageClipboard.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
// Copyright (c) 2023-2026 ktsu-dev contributors

namespace ktsu.ImGui.App.Clipboard;

/// <summary>Reads an image from the system clipboard and puts one on it.</summary>
/// <remarks>
/// 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; <see cref="ImageClipboard.TryReadPixels"/> decodes it with <see cref="Images.ImageDecoder"/>.
/// Get the platform's clipboard from <see cref="ImageClipboard.ForThisPlatform"/>; an application's
/// tests can stand in their own implementation.
/// </remarks>
public interface IImageClipboard
{
/// <summary>Reads the image on the clipboard.</summary>
/// <param name="image">The encoded image (a PNG, JPEG or BMP file's bytes) when there is one.</param>
/// <param name="message">Why there isn't one: the clipboard holds no image, or it can't be reached.</param>
/// <returns>Whether an image was read.</returns>
public bool TryRead(out byte[]? image, out string? message);

/// <summary>Replaces what the clipboard holds with an image.</summary>
/// <param name="image">The image, as a PNG and as top-down straight-alpha BGRA rows.</param>
/// <param name="message">Why it couldn't be written, when it couldn't.</param>
/// <returns>Whether the clipboard now holds the image.</returns>
public bool TryWrite(ClipboardImage image, out string? message);
}
89 changes: 89 additions & 0 deletions ImGui.App/Clipboard/ImageClipboard.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
// Copyright (c) 2023-2026 ktsu-dev contributors

namespace ktsu.ImGui.App.Clipboard;

using ktsu.ImGui.App.Images;

/// <summary>The image clipboard for the platform the application runs on, and helpers over any <see cref="IImageClipboard"/>.</summary>
/// <remarks>
/// <para>
/// Windows reads the registered <c>PNG</c> format, then <c>CF_DIBV5</c> and <c>CF_DIB</c>, and writes PNG and
/// <c>CF_DIB</c>. Linux and FreeBSD run <c>wl-paste</c>/<c>wl-copy</c> under Wayland and <c>xclip</c> under
/// X11, and macOS runs <c>osascript</c>; 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.
/// </para>
/// <para>
/// 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.
/// </para>
/// </remarks>
public static class ImageClipboard
{
/// <summary>Said when the clipboard holds something that isn't an image.</summary>
public const string NoImageMessage = "The clipboard doesn't hold an image.";

/// <summary>Said on Linux when neither wl-clipboard nor xclip is installed.</summary>
public const string NoToolMessage = "Copying and pasting images needs wl-clipboard (on Wayland) or xclip (on X11). Install one with your package manager.";

/// <summary>Gets the image clipboard for this platform.</summary>
/// <returns>The clipboard.</returns>
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
}

/// <summary>Reads the clipboard's image and decodes it.</summary>
/// <param name="clipboard">The clipboard.</param>
/// <param name="pixels">The decoded image, straight-alpha RGBA8, when there is one.</param>
/// <param name="message">Why there isn't one: no image, an unreachable clipboard, or an image <see cref="ImageDecoder"/> can't read.</param>
/// <returns>Whether an image was read and decoded.</returns>
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;
}
}

/// <summary>Puts decoded pixels on the clipboard, as a PNG and as a bitmap.</summary>
/// <param name="clipboard">The clipboard.</param>
/// <param name="pixels">The image, straight-alpha RGBA8.</param>
/// <param name="message">Why it couldn't be written, when it couldn't.</param>
/// <returns>Whether the clipboard now holds the image.</returns>
public static bool TryWritePixels(this IImageClipboard clipboard, ImagePixels pixels, out string? message)
{
Ensure.NotNull(clipboard);
return clipboard.TryWrite(ClipboardImage.FromPixels(pixels), out message);
}
}
Loading
Loading