Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 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
15 changes: 8 additions & 7 deletions osu.Framework/Allocation/ObjectHandle.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ public struct ObjectHandle<T> : IDisposable

private GCHandle handle;

private readonly bool fromPointer;
private readonly bool canFree;

/// <summary>
/// Wraps the provided object with a <see cref="GCHandle" />, using the given <see cref="GCHandleType" />.
Expand All @@ -39,18 +39,19 @@ public struct ObjectHandle<T> : IDisposable
public ObjectHandle(T target, GCHandleType handleType)
{
handle = GCHandle.Alloc(target, handleType);
fromPointer = false;
canFree = true;
}

/// <summary>
/// Recreates an <see cref="ObjectHandle{T}" /> based on the passed <see cref="IntPtr" />.
/// Disposing this object will not free the handle, the original object must be disposed instead.
/// If <paramref name="ownsHandle"/> is <c>true</c>, disposing this object will free the handle.
/// </summary>
/// <param name="handle">Handle.</param>
public ObjectHandle(IntPtr handle)
/// <param name="handle"><see cref="Handle"/> from a previously constructed <see cref="ObjectHandle{T}(T, GCHandleType)"/>.</param>
/// <param name="ownsHandle">Whether this instance owns the underlying <see cref="GCHandle"/>.</param>
public ObjectHandle(IntPtr handle, bool ownsHandle = false)
{
this.handle = GCHandle.FromIntPtr(handle);
fromPointer = true;
canFree = ownsHandle;
}

/// <summary>
Expand Down Expand Up @@ -87,7 +88,7 @@ public bool GetTarget(out T target)

public void Dispose()
{
if (!fromPointer && handle.IsAllocated)
if (canFree && handle.IsAllocated)
handle.Free();
}

Expand Down
195 changes: 194 additions & 1 deletion osu.Framework/Platform/SDL/SDL3Clipboard.cs
Original file line number Diff line number Diff line change
@@ -1,13 +1,42 @@
// Copyright (c) ppy Pty Ltd <[email protected]>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.

using System;
using System.Buffers;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using System.Text;
using osu.Framework.Allocation;
using osu.Framework.Logging;
using SDL;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Formats;

namespace osu.Framework.Platform.SDL
{
public class SDL3Clipboard : Clipboard
{
/// <summary>
/// Supported formats for decoding images from the clipboard.
/// </summary>
// It's possible for a format to not have a registered decoder, but all default formats will have one:
// https://github.com/SixLabors/ImageSharp/discussions/1353#discussioncomment-9142056
private static IEnumerable<string> supportedImageMimeTypes => SixLabors.ImageSharp.Configuration.Default.ImageFormats.SelectMany(f => f.MimeTypes);

/// <summary>
/// Format used for encoding (saving) images to the clipboard.
/// </summary>
private readonly IImageFormat imageFormat;

public SDL3Clipboard(IImageFormat imageFormat)
{
this.imageFormat = imageFormat;
}

// SDL cannot differentiate between string.Empty and no text (eg. empty clipboard or an image)
// doesn't matter as text editors don't really allow copying empty strings.
// assume that empty text means no text.
Expand All @@ -17,12 +46,176 @@ public class SDL3Clipboard : Clipboard

public override Image<TPixel>? GetImage<TPixel>()
{
foreach (string mimeType in supportedImageMimeTypes)
{
if (tryGetData(mimeType, Image.Load<TPixel>, out var image))
{
Logger.Log($"Decoded {mimeType} from clipboard.");
return image;
}
}

return null;
}

public override bool SetImage(Image image)
{
return false;
ReadOnlyMemory<byte> memory;

// we can't save the image in the callback as the caller owns the image and might dispose it from under us.

using (var stream = new MemoryStream())
{
image.Save(stream, imageFormat);

// The buffer is allowed to escape the lifetime of the MemoryStream.
// https://learn.microsoft.com/en-us/dotnet/api/system.io.memorystream.getbuffer?view=net-8.0
// "This method works when the memory stream is closed."
memory = new ReadOnlyMemory<byte>(stream.GetBuffer(), 0, (int)stream.Length);
}

return trySetData(imageFormat.DefaultMimeType, () => memory);
}

/// <summary>
/// Decodes data from a native memory span. Return null or throw an exception if the data couldn't be decoded.
/// </summary>
/// <typeparam name="T">Type of decoded data.</typeparam>
private delegate T? SpanDecoder<out T>(ReadOnlySpan<byte> span);

private static unsafe bool tryGetData<T>(string mimeType, SpanDecoder<T> decoder, out T? data)
{
if (SDL3.SDL_HasClipboardData(mimeType) == SDL_bool.SDL_FALSE)
{
data = default;
return false;
}

UIntPtr nativeSize;
IntPtr pointer = SDL3.SDL_GetClipboardData(mimeType, &nativeSize);

if (pointer == IntPtr.Zero)
{
Logger.Log($"Failed to get SDL clipboard data for {mimeType}. SDL error: {SDL3.SDL_GetError()}");
data = default;
return false;
}

try
{
var nativeMemory = new ReadOnlySpan<byte>((void*)pointer, (int)nativeSize);
data = decoder(nativeMemory);
return data != null;
}
catch (Exception e)
{
Logger.Error(e, $"Failed to decode clipboard data for {mimeType}.");
data = default;
return false;
}
finally
{
SDL3.SDL_free(pointer);
}
}

private static unsafe bool trySetData(string mimeType, Func<ReadOnlyMemory<byte>> dataProvider)
{
var callbackContext = new ClipboardCallbackContext(mimeType, dataProvider);
var objectHandle = new ObjectHandle<ClipboardCallbackContext>(callbackContext, GCHandleType.Normal);

// TODO: support multiple mime types in a single callback
fixed (byte* ptr = Encoding.UTF8.GetBytes(mimeType + '\0'))
{
int ret = SDL3.SDL_SetClipboardData(&dataCallback, &cleanupCallback, objectHandle.Handle, &ptr, 1);

if (ret < 0)
{
objectHandle.Dispose();
Logger.Log($"Failed to set clipboard data callback. SDL error: {SDL3.SDL_GetError()}");
}

return ret == 0;
}
}

[UnmanagedCallersOnly(CallConvs = new[] { typeof(CallConvCdecl) })]
private static unsafe IntPtr dataCallback(IntPtr userdata, byte* mimeType, UIntPtr* length)
{
var objectHandle = new ObjectHandle<ClipboardCallbackContext>(userdata, true);
Copy link
Contributor

@smoogipoo smoogipoo May 8, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We're passing true here which reads as if we should be cleaning this up, however that's not being done and appears incorrect to do in this callback. Should this be changed to false to prevent incorrect use?


if (!objectHandle.GetTarget(out var context) || context.MimeType != SDL3.PtrToStringUTF8(mimeType))
{
*length = 0;
return IntPtr.Zero;
}

context.EnsureDataValid();
*length = context.DataLength;
return context.Address;
}

[UnmanagedCallersOnly(CallConvs = new[] { typeof(CallConvCdecl) })]
private static void cleanupCallback(IntPtr userdata)
{
var objectHandle = new ObjectHandle<ClipboardCallbackContext>(userdata, true);

if (objectHandle.GetTarget(out var context))
{
context.Dispose();
objectHandle.Dispose();
}
Copy link
Contributor

@smoogipoo smoogipoo May 8, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any reason to not do using var objectHandle = ... here and above (with the appropriate false parameter)? Simply based on the principle of ensuring all IDisposable objects are disposed.

I suppose there should be no case where GetTarget() fails to resolve but the GCHandle is still allocated, though it strikes me as a bit odd to manually handle disposal like this.

}

private class ClipboardCallbackContext : IDisposable
{
public readonly string MimeType;

/// <summary>
/// Provider of data suitable for the <see cref="MimeType"/>.
/// </summary>
/// <remarks>Called when another application requests that mime type from the OS clipboard.</remarks>
private Func<ReadOnlyMemory<byte>>? dataProvider;

private MemoryHandle memoryHandle;

/// <summary>
/// Address of the <see cref="ReadOnlyMemory{T}"/> returned by the <see cref="dataProvider"/>.
/// </summary>
/// <remarks>Pinned and suitable for passing to unmanaged code.</remarks>
public unsafe IntPtr Address => (IntPtr)memoryHandle.Pointer;

/// <summary>
/// Length of the <see cref="ReadOnlyMemory{T}"/> returned by the <see cref="dataProvider"/>.
/// </summary>
public UIntPtr DataLength;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How about { private set; }?


public ClipboardCallbackContext(string mimeType, Func<ReadOnlyMemory<byte>> dataProvider)
{
MimeType = mimeType;
this.dataProvider = dataProvider;
}

public void EnsureDataValid()
{
if (dataProvider == null)
{
Debug.Assert(Address != IntPtr.Zero);
Debug.Assert(DataLength != 0);
return;
}

var data = dataProvider();
dataProvider = null!;
DataLength = (UIntPtr)data.Length;
memoryHandle = data.Pin();
}
Comment on lines +198 to +211
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If this is pinned once and nulled afterwards, what's the purpose of this existing as a method as opposed to being done directly in the ctor and avoiding having to call this method/do the assertions?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The idea is to make use of deferred rendering of clipboard content. If no application requests the clipboard data, this code never runs and we don't need to serialise it to bytes.

This isn't of much use in this PR as the image is immediately serialised.


public void Dispose()
{
memoryHandle.Dispose();
DataLength = 0;
}
}
}
}
4 changes: 3 additions & 1 deletion osu.Framework/Platform/SDL3GameHost.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
using osu.Framework.Input.Handlers.Tablet;
using osu.Framework.Input.Handlers.Touch;
using osu.Framework.Platform.SDL;
using SixLabors.ImageSharp.Formats.Png;

namespace osu.Framework.Platform
{
Expand All @@ -31,7 +32,8 @@ protected override TextInputSource CreateTextInput()
return base.CreateTextInput();
}

protected override Clipboard CreateClipboard() => new SDL3Clipboard();
// PNG works well on linux
protected override Clipboard CreateClipboard() => new SDL3Clipboard(PngFormat.Instance);

protected override IEnumerable<InputHandler> CreateAvailableInputHandlers() =>
new InputHandler[]
Expand Down