-
-
Notifications
You must be signed in to change notification settings - Fork 2.5k
Clipboard rework #19347
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Clipboard rework #19347
Conversation
# Conflicts: # src/Android/Avalonia.Android/Platform/SkiaPlatform/TopLevelImpl.cs
Can you support the Clipboard.SetImage() and Clipboard.GetImage() methods? |
These are planned in a subsequent PR. As mentioned in the description:
|
After feedback from @kekekeks, The reasoning is that drag and drop operations are inherently synchronous and providing an API that is only asynchronous causes several problems: mandatory While this duplicates the public API, the internal implementations are more robust. Compared to my first attempt, where the sync/async boundaries were unclear, it's much better now thanks to the knowledge I've gained since then. The PR description has been updated. |
You can test this PR using the following package version. |
/// <param name="dataTransfer">The <see cref="IDataTransfer"/> instance.</param> | ||
/// <param name="format">The format to retrieve.</param> | ||
/// <returns>A list of values for <paramref name="format"/>, or null if the format is not supported.</returns> | ||
public static T[]? TryGetValues<T>(this IDataTransfer dataTransfer, DataFormat format) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I am a bit worrying users might consider this API supporting (de)serialization, since we allow generics here
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's not new, we allowed arbitrary objects before too. It makes it easier for the in-process dnd, but less obvious for inter-process dnd and clipboard. We cannot reliably support (de)serialization.
I don't have a better ideas for the API here though.
My best thought before (for the old API) was following:
Task<string> GetTextAsync();
Task<IStorageFile> GetFileAsync();
Task<byte[]> GetByteArray(string format);
Without open generic overloads, and with corresponding set methods. Potentially with Set/GetInProcessObject<T>()
.
But this obviously would affect existing applications too much.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
At one point I had a generic version of DataFormat
, DataFormat<T>
where you could supply a type and a (de)serializer. While it worked fine when putting objects in the clipboard, retrieving them was a bit messy: the user had to ensure that known formats were registered to provide the correct type, which could vary from call to call. And unknown formats were left out as byte[]
anyway.
I'm not opposed to removing this and keep only overloads returning a byte[]
or other similar binary types (Stream
) when there's a DataFormat
argument.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Another idea: what about exposing a wrapper type, e.g. DataTransferValue
that only accepts byte[]
/Memory<byte>/string/Stream
(it could have implicit conversions)?
However... Thinking about it a little more, we can also have platform specific types.
For example, with this PR, the Android clipboard supports setting System.Uri
, Android.Net.Uri
and Android.Content.Intent
. These types are important because they're the only way to support advanced clipboard scenarios on Android.
If we don't expose generic or object
overloads, we're completely closing this possibility.
I'm a bit torn between being stricter in the types we accept, or leave them open and simply document this fact.
// TODO: this name isn't ideal. For instance, it's not a valid UTI for macOS. | ||
// We currently have a converter in place in native code for backwards compatibility with IDataObject, | ||
// but this should ideally be removed at some point. | ||
// Consider using DataFormat.CreateApplicationFormat() instead (breaking change). |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
From my understanding, CreateApplicationFormat
won't work here.
This data format is supposed to be "operating system format" one, and shared between devtools (old and new - both use the same data format) and AvaloniaVS extension.
See ElementsNavView.axaml.cs#L59 in new devtools and AvaloniaVS one.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Since it's primary for the Visual Studio extension, macOS is not so big of a problem. Unless Rider will support the same format
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If we want to have the format work on all platforms, it should not be an "operating system format" since the name would need to be different between OSes (macOS must use an UTI). By using an application format, we can tell Avalonia to generate an appropriate but deterministic name here.
However, the right thing to do here if we consider this format public is probably to have proper documented names per OS for it. Then Rider or other extensions are free to use them.
AvaloniaLocator.CurrentMutable | ||
.Bind<IDispatcherImpl>().ToConstant(new ManagedDispatcherImpl(null)) | ||
.Bind<IClipboard>().ToSingleton<HeadlessClipboardStub>() | ||
.Bind<IClipboardImpl>().ToConstant(clipboardImpl) | ||
.Bind<IClipboard>().ToConstant(clipboard) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Since Clipboard
type is the same on all platforms, do we need to register it via locator? As an alternative, it can be created in the application, when requested.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Inded, we can change Clipboard
to not be registered indeed.
I'm a bit worried about breaking testability by doing so though.
Not related to this PR directly, but winforms got new clipboard API - see dotnet/winforms#12362 |
I did review shared/core part, and a little of platform specific code. Will need another pass for the rest of platform code. |
Full API diff (updated 2025-09-15): Avalonia.Base (net6.0, net8.0, netstandard2.0) namespace Avalonia.Input
{
public static class DragDrop
{
+ public static Task<DragDropEffects> DoDragDropAsync(PointerEventArgs triggerEvent, IDataTransfer dataTransfer, DragDropEffects allowedEffects);
}
public class DragEventArgs : RoutedEventArgs
{
+ public DragEventArgs(RoutedEvent<DragEventArgs> routedEvent, IDataTransfer dataTransfer, Interactive target, Point targetLocation, KeyModifiers keyModifiers);
+ public IDataTransfer DataTransfer { get; }
}
+ public static class AsyncDataTransferExtensions
+ {
+ public static bool Contains(this IAsyncDataTransfer dataTransfer, DataFormat format);
+ public static IEnumerable<IAsyncDataTransferItem> GetItems(this IAsyncDataTransfer dataTransfer, DataFormat format);
+ public static Task<IStorageItem?> TryGetFileAsync(this IAsyncDataTransfer dataTransfer);
+ public static Task<IStorageItem[]?> TryGetFilesAsync(this IAsyncDataTransfer dataTransfer);
+ public static Task<string?> TryGetTextAsync(this IAsyncDataTransfer dataTransfer);
+ public static Task<T?> TryGetValueAsync<T>(this IAsyncDataTransfer dataTransfer, DataFormat format);
+ public static Task<T[]?> TryGetValuesAsync<T>(this IAsyncDataTransfer dataTransfer, DataFormat format);
+ }
+ public static class AsyncDataTransferItemExtensions
+ {
+ public static bool Contains(this IAsyncDataTransferItem dataTransferItem, DataFormat format);
+ }
+ public sealed record DataFormat
+ {
+ public static DataFormat CreateApplicationFormat(string identifier);
+ public static DataFormat CreatePlatformFormat(string identifier);
+ public static DataFormat FromSystemName(string systemName, string applicationPrefix);
+ public override string ToString();
+ public string ToSystemName(string applicationPrefix);
+ public static DataFormat File { get; }
+ public string Identifier { get; }
+ public DataFormatKind Kind { get; }
+ public static DataFormat Text { get; }
+ }
+ public enum DataFormatKind
+ {
+ Application = 0,
+ Platform = 1,
+ Universal = 2,
+ }
+ public sealed class DataTransfer : IDataTransfer, IAsyncDataTransfer
+ {
+ public DataTransfer();
+ public void Add(DataTransferItem item);
+ public IReadOnlyList<DataFormat> Formats { get; }
+ public IReadOnlyList<DataTransferItem> Items { get; }
+ }
+ public static class DataTransferExtensions
+ {
+ public static bool Contains(this IDataTransfer dataTransfer, DataFormat format);
+ public static IEnumerable<IDataTransferItem> GetItems(this IDataTransfer dataTransfer, DataFormat format);
+ public static IStorageItem? TryGetFile(this IDataTransfer dataTransfer);
+ public static IStorageItem[]? TryGetFiles(this IDataTransfer dataTransfer);
+ public static string? TryGetText(this IDataTransfer dataTransfer);
+ public static T? TryGetValue<T>(this IDataTransfer dataTransfer, DataFormat format);
+ public static T[]? TryGetValues<T>(this IDataTransfer dataTransfer, DataFormat format);
+ }
+ public sealed class DataTransferItem : IDataTransferItem, IAsyncDataTransferItem
+ {
+ public DataTransferItem();
+ public static DataTransferItem Create<T>(DataFormat format, T value);
+ public static DataTransferItem Create<T>(DataFormat format, Func<T> getValue);
+ public static DataTransferItem CreateFile(IStorageItem? value);
+ public static DataTransferItem CreateText(string? value);
+ public void Set<T>(DataFormat format, T value);
+ public void Set<T>(DataFormat format, System.Func<T> getValue);
+ public void SetFile(IStorageItem? value);
+ public void SetText(string? value);
+ public object? TryGet(DataFormat format);
+ public IReadOnlyList<DataFormat> Formats { get; }
+ }
+ public static class DataTransferItemExtensions
+ {
+ public static bool Contains(this IDataTransferItem dataTransferItem, DataFormat format);
+ }
+ public interface IAsyncDataTransfer
+ {
+ IReadOnlyList<DataFormat> Formats { get; }
+ IReadOnlyList<IAsyncDataTransferItem> Items { get; }
+ }
+ public interface IAsyncDataTransferItem
+ {
+ Task<object?> TryGetAsync(DataFormat format);
+ IReadOnlyList<DataFormat> Formats { get; }
+ }
+ public interface IDataTransfer
+ {
+ IReadOnlyList<DataFormat> Formats { get; }
+ IReadOnlyList<IDataTransferItem> Items { get; }
+ }
+ public interface IDataTransferItem
+ {
+ object? TryGet(DataFormat format);
+ IReadOnlyList<DataFormat> Formats { get; }
+ }
}
namespace Platform
{
public interface IClipboard
{
+ Task SetDataAsync(IAsyncDataTransfer? dataTransfer);
+ Task<IAsyncDataTransfer?> TryGetDataAsync();
+ Task<IAsyncDataTransfer?> TryGetInProcessDataAsync();
}
public interface IPlatformDragSource
{
+ Task<DragDropEffects> DoDragDropAsync(PointerEventArgs triggerEvent, IDataTransfer dataTransfer, DragDropEffects allowedEffects);
}
+ public static class ClipboardExtensions
+ {
+ public static Task<IReadOnlyList<DataFormat>> GetDataFormatsAsync(this IClipboard clipboard);
+ public static Task SetFileAsync(this IClipboard clipboard, IStorageItem? file);
+ public static Task SetFilesAsync(this IClipboard clipboard, IEnumerable<IStorageItem>? files);
+ public static Task SetTextAsync(this IClipboard clipboard, string? text);
+ public static Task SetValueAsync<T>(this IClipboard clipboard, DataFormat format, T? value);
+ public static Task SetValuesAsync<T>(this IClipboard clipboard, DataFormat format, IEnumerable<T>? values);
+ public static Task<IStorageItem?> TryGetFileAsync(this IClipboard clipboard);
+ public static Task<IStorageItem[]?> TryGetFilesAsync(this IClipboard clipboard);
+ public static Task<string?> TryGetTextAsync(this IClipboard clipboard);
+ public static Task<T?> TryGetValueAsync<T>(this IClipboard clipboard, DataFormat format);
+ public static Task<T[]?> TryGetValuesAsync<T>(this IClipboard clipboard, DataFormat format);
+ }
+ public interface IClipboardImpl
+ {
+ Task ClearAsync();
+ Task SetDataAsync(IAsyncDataTransfer dataTransfer);
+ Task<IAsyncDataTransfer?> TryGetDataAsync();
+ }
+ public interface IFlushableClipboardImpl : IClipboardImpl
+ {
+ Task FlushAsync();
+ }
+ public interface IOwnedClipboardImpl : IClipboardImpl
+ {
+ Task<bool> IsCurrentOwnerAsync();
+ }
}
namespace Raw
{
public class RawDragEvent : RawInputEventArgs
{
+ public RawDragEvent(IDragDropDevice inputDevice, RawDragEventType type, IInputRoot root, Avalonia.Point location, IDataTransfer dataTransfer, DragDropEffects effects, RawInputModifiers modifiers);
+ public IDataTransfer DataTransfer { get; }
}
} Avalonia.Diagnostics (net6.0, net8.0, netstandard2.0) namespace Avalonia.Diagnostics
{
+ public static class DevToolsDataFormats
+ {
+ public static DataFormat Selector { get; }
+ }
} Avalonia.Headless (net6.0, net8.0, netstandard2.0) namespace Avalonia.Headless
{
public static class HeadlessWindowExtensions
{
+ public static void DragDrop(this TopLevel topLevel, Point point, RawDragEventType type, IDataTransfer data, DragDropEffects effects, RawInputModifiers modifiers = 0);
}
} |
# Conflicts: # api/Avalonia.nupkg.xml # native/Avalonia.Native/src/OSX/WindowBaseImpl.mm
What does the pull request do?
This PR:
The aim of this PR isn't to implement all missing clipboard features, but rather to pose a solid foundation so those can be easily implemented later on top of it (such as bitmap support and data sharing).
New API
DataFormat
A new
DataFormat
class is introduced. Each equatable instance represents a single data format, for both the clipboard and DnD. The purpose ofDataFormat
is double:There are three kinds of
DataFormat
:Universal
: a cross-platform format, completely handled by Avalonia and automatically converted to the appropriate representation(s) for each platform. It can't be defined by the user. Two currently exist:DataFormat.Text
andDataFormat.File
. (DataFormat.Image
will be implemented in a later PR).Application
: an user defined format, created usingDataFormat.CreateApplicationFormat(string identifier)
. The identifier will be internally prefixed – with the exact format name determined by the platform – to avoid clashes with predefined operating system formats.Platform
: a platform system format, created usingDataFormat.CreateOperatingSystemFormat(string identifier)
. The identifier will be passed AS IS to the underlying operating system. This is the direct equivalent to the strings used previously, but without ambiguity. When using such a format, the user must ensure that the data is what the current platform expects. No automatic conversion takes place.Application
andOperatingSystem
support the following data types when being written to the clipboard:string
,byte[]
,Memory<byte>
andStream
. Other types will result in a warning and won't be placed onto the clipboard. Reading returns eitherstring
orbyte[]
.IDataTransfer
&IDataTransferItem
Guided by #12600, the main goal of this PR was to implement everything on top of
IDataObject
(or an equivalent), so the logic could be shared between the clipboard and DnD. There were several discussions internally around havingIAsyncDataObject
(mostly for the clipboard) andIDataObject
(mostly for DnD, which is synchronous by nature on almost all platforms). The first version of the refactor implemented both. After spending days on it, I wasn't satisfied with the result at all. Code was either duplicated everywhere, or conversions happening so often that the distinction betweenIDataObject
andIAsyncDataObject
was becoming meaningless.Edit 2025-08-08: due to concerns around async usages in synchronous drag handlers, this has been split back into synchronous and asynchronous types. While this duplicates the public API, it's now way better represented internally compared to my first attempt thanks to the knowledge I gained while working on this feature.
Moreover, after gluing those two classes together in a second attempt, I quickly realized that the existing
IDataObject
shape was hindering the design. While good on paper, it has a critical flaw: there's only one value available per format, which is good enough for Windows and X11 — they exhibit the same restriction – but certainly not for other platforms. As mentioned in #12600, macOS allows several instances ofNSPasteboardItem
to coexist on the clipboard. iOS clipboard has the same capability. Android, while a bit more limited, also allows severealClipData.Item
. Last but not least, the recent-ishClipboardItem
can exist in multiple copies in the browser's clipboard.Additionally, macOS requires multiple items support for DnD to work properly with files on modern macOS versions. While this could of course be hacked around, it didn't feel quite right.
At that point, it made complete sense to ditch the one-value-per-format paradigm and to shift towards have multiple items instead. Enter
IDataTransfer
andIDataTransferItem
and their async equivalentIAsyncDataTransfer
andIAsyncDataTransferItem
:These two interfaces went through several iterations. There were versions where
Formats
and/orItems
were either async methods or iterators. However, after implementing all platforms, it turns out that almost all of them provide the formats synchronously and eagerly (after getting a hold of the clipboard's contents): macOS, iOS, Android and the Web. Windows provides a synchronous but lazy enumeration, which was getting cached anyways... As such, exposing the format as a list felt natural. The same logic applies to the items, which are all directly exposed as a list for the four platforms supporting them.The important point is that for a given format, the value can be retrieved on demand and asynchronously through
TryGetAsync
(if the underlying platform supports it, of course).These interfaces are kept very simple on purpose, allowing them to be easily implemented by each platform.
Extension methods for
IDataTransfer
andIAsyncDataTransfer
Users of Avalonia might find the previous interfaces a bit too generic to use comfortably. This PR adds several extension methods over
IDataTransfer
andIDataTransferItem
for convenience:Asynchronous counterparts of all the
TryGetX
methods above exist forIAsyncDataTransfer
.IClipboard
&IClipboardImpl
IClipboard
has been rewritten in terms ofIAsyncDataTransfer
. Here is its new shape (with obsolete methods excluded):Notice that there isn't a way to retrieve the value of a specific format anymore. This capability is already provided by
IAsyncDataTransfer
and thus isn't duplicated byIClipboard
. The API is also now symmetric: gone are the days of usingIDataObject
only while writing.IClipboard
now has a single implementation, cleverly namedClipboard
. It provides common clipboard logic and delegates the specifics to a similar interface platforms need to implement,IClipboardImpl
:Optionally, platforms can provide the ability for the clipboard to be flushed, or to query whether it's been externally changed:
Extension methods for
IClipboard
In the same fashion as
IDataTransfer
, several extension methods toIClipboard
are provided for convenience, simplifying the code when a single format needs to be read or written.If the clipboard is to be accessed several times in a row, it is recommended to use
TryGetDataAsync()
and the extension methods over the returnedIAsyncDataTransfer
instead.IAsyncDataTransfer
lifetime managementThe returned
IAsyncDataTransfer
might hold onto native platform resources; consequently, it needs to be properly disposed.IClipboard
adopts a simple scheme:IAsyncDataTransfer
instance returned byIClipboard.TryGetDataAsync()
needs to be disposed by its caller as soon as possible (ownership is transferred to the caller).IAsyncDataTransfer
instance passed toIClipboard.SetDataAsync()
must not be disposed while it's on the clipboard. Avalonia take cares of disposing it when necessary (ownership is transferred to the clipboard).The extension methods for
IClipboard
take care of that automatically.Platform improvements
File
format is now handled, allowing copying and pasting files.IStorageItem
instances gets serialized as an URI list of typetext/uri-list
, which your favorite file manager probably supports.IStorageItem
,Uri
andIntent
objects can now be read and written (instead of just text before). By implementing aContentProvider
, users can now share arbitrary data.Other notes
As mentioned earlier, reading binary data from an
IDataTransferItem
object will return abyte[]
. While we could sometimes return aStream
directly to the underlying data without an extra copy, managing its lifetime can be problematic, especially whenobject
is returned. While that problem is definitely solvable, the current PR already contains many changes, and adding stream support can be done in a later PR.Reviewing this PR is probably best done project by project, starting with
Avalonia.Base
. I've tried to squash my numerous WIP commits into platform specific ones, but there are still changes from later commits that are impacting the overall API shape.XML documentation has been added to all new public types and members.
Breaking changes
Normally none for public API.
While a bunch of members have been obsoleted (see below), the binary breaking changes are only in
[PrivateApi]
types, which aren't meant to be implemented.Obsoletions / Deprecations
IDataObject
and all members using it are now obsolete. It should still work as expected, with string formats being converted to operating systemDataFormat
automatically internally.IClipboard
methods for specific formats (such asGetTextAsync()
) have been replaced by extension methods.Fixed issues