Browse Source

Xdnd: introduce SelectionDataProvider

pull/20926/head
Julien Lebosquain 7 months ago
parent
commit
bec005b006
No known key found for this signature in database GPG Key ID: 1833CAD10ACC46FD
  1. 332
      src/Avalonia.X11/Selections/Clipboard/X11Clipboard.cs
  2. 124
      src/Avalonia.X11/Selections/Clipboard/X11ClipboardImpl.cs
  3. 278
      src/Avalonia.X11/Selections/SelectionDataProvider.cs

332
src/Avalonia.X11/Selections/Clipboard/X11Clipboard.cs

@ -1,332 +0,0 @@
using System;
using System.Buffers;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Avalonia.Input;
using Avalonia.Input.Platform;
using Avalonia.Logging;
using static Avalonia.X11.XLib;
namespace Avalonia.X11.Selections.Clipboard
{
internal sealed class X11ClipboardImpl : IOwnedClipboardImpl
{
private readonly AvaloniaX11Platform _platform;
private readonly X11Info _x11;
private IAsyncDataTransfer? _storedDataTransfer;
private readonly IntPtr _handle;
private TaskCompletionSource<bool>? _storeAtomTcs;
private readonly int _maximumPropertySize;
public X11ClipboardImpl(AvaloniaX11Platform platform)
{
_platform = platform;
_x11 = platform.Info;
_handle = CreateEventWindow(platform, OnEvent);
var extendedMaxRequestSize = XExtendedMaxRequestSize(_platform.Display);
var maxRequestSize = XMaxRequestSize(_platform.Display);
_maximumPropertySize =
(int)Math.Min(0x100000, (extendedMaxRequestSize == IntPtr.Zero
? maxRequestSize
: extendedMaxRequestSize).ToInt64() - 0x100);
}
private unsafe void OnEvent(ref XEvent ev)
{
if (ev.type == XEventName.SelectionClear)
{
// We night have already regained the clipboard ownership by the time a SelectionClear message arrives.
if (GetOwner() != _handle)
_storedDataTransfer = null;
_storeAtomTcs?.TrySetResult(true);
}
else if (ev.type == XEventName.SelectionRequest)
{
var sel = ev.SelectionRequestEvent;
var resp = new XEvent
{
SelectionEvent =
{
type = XEventName.SelectionNotify,
send_event = 1,
display = _x11.Display,
selection = sel.selection,
target = sel.target,
requestor = sel.requestor,
time = sel.time,
property = IntPtr.Zero
}
};
if (sel.selection == _x11.Atoms.CLIPBOARD)
{
resp.SelectionEvent.property = WriteTargetToProperty(sel.target, sel.requestor, sel.property);
}
XSendEvent(_x11.Display, sel.requestor, false, new IntPtr((int)EventMask.NoEventMask), ref resp);
}
else if (ev.type == XEventName.SelectionNotify)
{
if (ev.SelectionEvent.selection == _x11.Atoms.CLIPBOARD_MANAGER &&
ev.SelectionEvent.target == _x11.Atoms.SAVE_TARGETS &&
_x11.Atoms.CLIPBOARD_MANAGER != IntPtr.Zero &&
_x11.Atoms.SAVE_TARGETS != IntPtr.Zero)
{
_storeAtomTcs?.TrySetResult(true);
}
}
IntPtr WriteTargetToProperty(IntPtr target, IntPtr window, IntPtr property)
{
if (target == _x11.Atoms.TARGETS)
{
var atoms = ConvertDataTransfer(_storedDataTransfer);
XChangeProperty(_x11.Display, window, property,
_x11.Atoms.ATOM, 32, PropertyMode.Replace, atoms, atoms.Length);
return property;
}
else if (target == _x11.Atoms.SAVE_TARGETS && _x11.Atoms.SAVE_TARGETS != IntPtr.Zero)
{
return property;
}
else if (DataFormatHelper.ToDataFormat(target, _x11.Atoms) is { } dataFormat)
{
if (_storedDataTransfer is null)
return IntPtr.Zero;
// Our default bitmap format is image/png
if (dataFormat.Identifier is "image/png" && _storedDataTransfer.Contains(DataFormat.Bitmap))
dataFormat = DataFormat.Bitmap;
if (!_storedDataTransfer.Contains(dataFormat))
return IntPtr.Zero;
if (TryGetDataAsBytes(_storedDataTransfer, dataFormat, target) is not { } bytes)
return IntPtr.Zero;
_ = SendDataToClientAsync(window, property, target, bytes);
return property;
}
else if (target == _x11.Atoms.MULTIPLE && _x11.Atoms.MULTIPLE != IntPtr.Zero)
{
XGetWindowProperty(_x11.Display, window, property, IntPtr.Zero, new IntPtr(0x7fffffff), false,
_x11.Atoms.ATOM_PAIR, out _, out var actualFormat, out var nitems, out _, out var prop);
if (nitems == IntPtr.Zero)
return IntPtr.Zero;
if (actualFormat == 32)
{
var data = (IntPtr*)prop.ToPointer();
for (var c = 0; c < nitems.ToInt32(); c += 2)
{
var subTarget = data[c];
var subProp = data[c + 1];
var converted = WriteTargetToProperty(subTarget, window, subProp);
data[c + 1] = converted;
}
XChangeProperty(_x11.Display, window, property, _x11.Atoms.ATOM_PAIR, 32, PropertyMode.Replace,
prop.ToPointer(), nitems.ToInt32());
}
XFree(prop);
return property;
}
else
return IntPtr.Zero;
}
}
private byte[]? TryGetDataAsBytes(IAsyncDataTransfer dataTransfer, DataFormat format, IntPtr targetFormatAtom)
{
if (DataFormat.Text.Equals(format))
{
var text = dataTransfer.TryGetValueAsync(DataFormat.Text).GetAwaiter().GetResult();
return DataFormatHelper.TryGetStringEncoding(targetFormatAtom, _x11.Atoms) is { } encoding ?
encoding.GetBytes(text ?? string.Empty) :
null;
}
if (DataFormat.Bitmap.Equals(format))
{
if (dataTransfer.TryGetValueAsync(DataFormat.Bitmap).GetAwaiter().GetResult() is not { } bitmap)
return null;
using var stream = new MemoryStream();
bitmap.Save(stream);
return stream.ToArray();
}
if (DataFormat.File.Equals(format))
{
if (dataTransfer.TryGetValuesAsync(DataFormat.File).GetAwaiter().GetResult() is not { } files)
return null;
return UriListHelper.FileUriListToUtf8Bytes(files);
}
if (format is DataFormat<string> stringFormat)
{
return dataTransfer.TryGetValueAsync(stringFormat).GetAwaiter().GetResult() is { } stringValue ?
Encoding.UTF8.GetBytes(stringValue) :
null;
}
if (format is DataFormat<byte[]> bytesFormat)
return dataTransfer.TryGetValueAsync(bytesFormat).GetAwaiter().GetResult();
Logger.TryGet(LogEventLevel.Warning, LogArea.X11Platform)
?.Log(this, "Unsupported data format {Format}", format);
return null;
}
private async Task SendIncrDataToClientAsync(IntPtr window, IntPtr property, IntPtr target, Stream data)
{
data.Position = 0;
using var events = new EventStreamWindow(_platform, window);
using var _ = data;
XSelectInput(_x11.Display, window, new IntPtr((int)XEventMask.PropertyChangeMask));
var size = new IntPtr(data.Length);
XChangeProperty(_x11.Display, window, property, _x11.Atoms.INCR, 32, PropertyMode.Replace, ref size, 1);
var buffer = ArrayPool<byte>.Shared.Rent((int)Math.Min(_maximumPropertySize, data.Length));
while (true)
{
if (null == await events.WaitForEventAsync(x =>
x.type == XEventName.PropertyNotify && x.PropertyEvent.atom == property
&& x.PropertyEvent.state == 1, TimeSpan.FromMinutes(1)))
break;
var read = await data.ReadAsync(buffer, 0, buffer.Length);
if(read == 0)
break;
XChangeProperty(_x11.Display, window, property, target, 8, PropertyMode.Replace, buffer, read);
}
ArrayPool<byte>.Shared.Return(buffer);
// Finish the transfer
XChangeProperty(_x11.Display, window, property, target, 8, PropertyMode.Replace, IntPtr.Zero, 0);
}
private Task SendDataToClientAsync(IntPtr window, IntPtr property, IntPtr target, byte[] bytes)
{
if (bytes.Length < _maximumPropertySize)
{
XChangeProperty(_x11.Display, window, property, target, 8,
PropertyMode.Replace,
bytes, bytes.Length);
return Task.CompletedTask;
}
return SendIncrDataToClientAsync(window, property, target, new MemoryStream(bytes));
}
private IntPtr GetOwner()
=> XGetSelectionOwner(_x11.Display, _x11.Atoms.CLIPBOARD);
private SelectionReadSession OpenReadSession() => ClipboardReadSessionFactory.CreateSession(_platform);
private IntPtr[] ConvertDataTransfer(IAsyncDataTransfer? dataTransfer)
{
var atoms = new List<IntPtr> { _x11.Atoms.TARGETS, _x11.Atoms.MULTIPLE };
if (dataTransfer is not null)
{
foreach (var format in dataTransfer.Formats)
{
foreach (var atom in DataFormatHelper.ToAtoms(format, _x11.Atoms))
atoms.Add(atom);
}
}
return atoms.ToArray();
}
private Task StoreAtomsInClipboardManager(IAsyncDataTransfer dataTransfer)
{
if (_x11.Atoms.CLIPBOARD_MANAGER == IntPtr.Zero || _x11.Atoms.SAVE_TARGETS == IntPtr.Zero)
return Task.CompletedTask;
var clipboardManager = XGetSelectionOwner(_x11.Display, _x11.Atoms.CLIPBOARD_MANAGER);
if (clipboardManager == IntPtr.Zero)
return Task.CompletedTask;
// Skip storing atoms if the data object contains any non-trivial formats
if (dataTransfer.Formats.Any(f => !DataFormat.Text.Equals(f)))
return Task.CompletedTask;
return StoreTextCoreAsync();
async Task StoreTextCoreAsync()
{
// Skip storing atoms if the trivial formats are too big
var text = await dataTransfer.TryGetTextAsync();
if (text is null || text.Length * 2 > 64 * 1024)
return;
if (_storeAtomTcs is null || _storeAtomTcs.Task.IsCompleted)
_storeAtomTcs = new TaskCompletionSource<bool>();
var atoms = ConvertDataTransfer(dataTransfer);
XChangeProperty(_x11.Display, _handle, _x11.Atoms.AVALONIA_SAVE_TARGETS_PROPERTY_ATOM, _x11.Atoms.ATOM, 32,
PropertyMode.Replace, atoms, atoms.Length);
XConvertSelection(_x11.Display, _x11.Atoms.CLIPBOARD_MANAGER, _x11.Atoms.SAVE_TARGETS,
_x11.Atoms.AVALONIA_SAVE_TARGETS_PROPERTY_ATOM, _handle, IntPtr.Zero);
await _storeAtomTcs.Task;
}
}
public Task ClearAsync()
{
_storedDataTransfer = null;
XSetSelectionOwner(_x11.Display, _x11.Atoms.CLIPBOARD, IntPtr.Zero, IntPtr.Zero);
return Task.CompletedTask;
}
public async Task<IAsyncDataTransfer?> TryGetDataAsync()
{
var owner = GetOwner();
if (owner == IntPtr.Zero)
return null;
if (owner == _handle && _storedDataTransfer is { } storedDataTransfer)
return storedDataTransfer;
// Get the formats while we're in an async method, since IAsyncDataTransfer.GetFormats() is synchronous.
var (dataFormats, textFormatAtoms) = await GetDataFormatsCoreAsync().ConfigureAwait(false);
if (dataFormats.Length == 0)
return null;
// Get the items while we're in an async method. This does not get values, except for DataFormat.File.
var reader = new ClipboardDataReader(_platform, textFormatAtoms, dataFormats, owner);
var items = await reader.CreateItemsAsync();
return new ClipboardDataTransfer(reader, dataFormats, items);
}
private async Task<(DataFormat[] DataFormats, IntPtr[] TextFormatAtoms)> GetDataFormatsCoreAsync()
{
using var session = OpenReadSession();
var formatAtoms = await session.SendFormatRequest(0) ?? [];
return DataFormatHelper.ToDataFormats(formatAtoms, _x11.Atoms);
}
public Task SetDataAsync(IAsyncDataTransfer dataTransfer)
{
_storedDataTransfer = dataTransfer;
XSetSelectionOwner(_x11.Display, _x11.Atoms.CLIPBOARD, _handle, IntPtr.Zero);
return StoreAtomsInClipboardManager(dataTransfer);
}
public Task<bool> IsCurrentOwnerAsync()
=> Task.FromResult(GetOwner() == _handle);
}
}

124
src/Avalonia.X11/Selections/Clipboard/X11ClipboardImpl.cs

@ -0,0 +1,124 @@
using System;
using System.Linq;
using System.Threading.Tasks;
using Avalonia.Input;
using Avalonia.Input.Platform;
using static Avalonia.X11.XLib;
namespace Avalonia.X11.Selections.Clipboard;
internal sealed class X11ClipboardImpl(AvaloniaX11Platform platform)
: SelectionDataProvider(platform, platform.Info.Atoms.CLIPBOARD), IOwnedClipboardImpl
{
private TaskCompletionSource? _storeAtomTcs;
protected override void OnEvent(ref XEvent ev)
{
if (ev.type == XEventName.SelectionClear)
{
// We night have already regained the clipboard ownership by the time a SelectionClear message arrives.
if (GetOwner() != Handle)
DataTransfer = null;
_storeAtomTcs?.TrySetResult();
}
else if (ev.type == XEventName.SelectionNotify)
{
var atoms = Platform.Info.Atoms;
if (ev.SelectionEvent.selection == atoms.CLIPBOARD_MANAGER &&
ev.SelectionEvent.target == atoms.SAVE_TARGETS)
{
_storeAtomTcs?.TrySetResult();
}
}
else
base.OnEvent(ref ev);
}
private SelectionReadSession OpenReadSession()
=> ClipboardReadSessionFactory.CreateSession(Platform);
private Task StoreAtomsInClipboardManager(IAsyncDataTransfer dataTransfer)
{
var atoms = Platform.Info.Atoms;
var clipboardManager = XGetSelectionOwner(Platform.Display, atoms.CLIPBOARD_MANAGER);
if (clipboardManager == IntPtr.Zero)
return Task.CompletedTask;
// Skip storing atoms if the data object contains any non-trivial formats
if (dataTransfer.Formats.Any(f => !DataFormat.Text.Equals(f)))
return Task.CompletedTask;
return StoreTextCoreAsync();
async Task StoreTextCoreAsync()
{
// Skip storing atoms if the trivial formats are too big
var text = await dataTransfer.TryGetTextAsync();
if (text is null || text.Length * 2 > 64 * 1024)
return;
if (_storeAtomTcs is null || _storeAtomTcs.Task.IsCompleted)
_storeAtomTcs = new TaskCompletionSource();
var atomValues = ConvertDataTransfer(dataTransfer);
XChangeProperty(Platform.Display, Handle, atoms.AVALONIA_SAVE_TARGETS_PROPERTY_ATOM, atoms.ATOM, 32,
PropertyMode.Replace, atomValues, atomValues.Length);
XConvertSelection(Platform.Display, atoms.CLIPBOARD_MANAGER, atoms.SAVE_TARGETS,
atoms.AVALONIA_SAVE_TARGETS_PROPERTY_ATOM, Handle, 0);
await _storeAtomTcs.Task;
}
}
public Task ClearAsync()
{
DataTransfer = null;
SetOwner(0);
return Task.CompletedTask;
}
public async Task<IAsyncDataTransfer?> TryGetDataAsync()
{
var owner = GetOwner();
if (owner == 0)
return null;
if (owner == Handle && DataTransfer is { } storedDataTransfer)
return storedDataTransfer;
// Get the formats while we're in an async method, since IAsyncDataTransfer.GetFormats() is synchronous.
var (dataFormats, textFormatAtoms) = await GetDataFormatsCoreAsync().ConfigureAwait(false);
if (dataFormats.Length == 0)
return null;
// Get the items while we're in an async method. This does not get values, except for DataFormat.File.
var reader = new ClipboardDataReader(Platform, textFormatAtoms, dataFormats, owner);
var items = await reader.CreateItemsAsync();
return new ClipboardDataTransfer(reader, dataFormats, items);
}
private async Task<(DataFormat[] DataFormats, IntPtr[] TextFormatAtoms)> GetDataFormatsCoreAsync()
{
using var session = OpenReadSession();
var formatAtoms = await session.SendFormatRequest(0) ?? [];
return DataFormatHelper.ToDataFormats(formatAtoms, Platform.Info.Atoms);
}
public Task SetDataAsync(IAsyncDataTransfer dataTransfer)
{
DataTransfer = dataTransfer;
SetOwner(Handle);
return StoreAtomsInClipboardManager(dataTransfer);
}
public Task<bool> IsCurrentOwnerAsync()
=> Task.FromResult(GetOwner() == Handle);
}

278
src/Avalonia.X11/Selections/SelectionDataProvider.cs

@ -0,0 +1,278 @@
using System;
using System.Buffers;
using System.Collections.Generic;
using System.IO;
using System.Text;
using System.Threading.Tasks;
using Avalonia.Input;
using Avalonia.Logging;
using Avalonia.X11.Selections.Clipboard;
using static Avalonia.X11.XLib;
namespace Avalonia.X11.Selections;
/// <summary>
/// Provides an X11 selection (clipboard/drag-and-drop) via a dedicated window.
/// </summary>
internal abstract class SelectionDataProvider : IDisposable
{
private readonly int _maximumPropertySize;
protected AvaloniaX11Platform Platform { get; }
protected IntPtr Selection { get; }
protected IntPtr Handle { get; private set; }
public IAsyncDataTransfer? DataTransfer { get; set; }
protected SelectionDataProvider(AvaloniaX11Platform platform, IntPtr selection)
{
Platform = platform;
Selection = selection;
var maxRequestSize = XExtendedMaxRequestSize(Platform.Display);
if (maxRequestSize == 0)
maxRequestSize = XMaxRequestSize(Platform.Display);
_maximumPropertySize = (int)Math.Min(0x100000, maxRequestSize - 0x100);
Handle = CreateEventWindow(platform, OnEvent);
}
protected IntPtr GetOwner()
=> XGetSelectionOwner(Platform.Display, Selection);
protected void SetOwner(IntPtr owner)
=> XSetSelectionOwner(Platform.Display, Selection, owner, 0);
protected virtual void OnEvent(ref XEvent ev)
{
if (ev.type == XEventName.SelectionRequest)
{
var sel = ev.SelectionRequestEvent;
var resp = new XEvent
{
SelectionEvent =
{
type = XEventName.SelectionNotify,
send_event = 1,
display = Platform.Display,
selection = sel.selection,
target = sel.target,
requestor = sel.requestor,
time = sel.time,
property = 0
}
};
if (sel.selection == Selection)
{
resp.SelectionEvent.property = WriteTargetToProperty(sel.target, sel.requestor, sel.property);
}
XSendEvent(Platform.Display, sel.requestor, false, new IntPtr((int)EventMask.NoEventMask), ref resp);
}
IntPtr WriteTargetToProperty(IntPtr target, IntPtr window, IntPtr property)
{
var atoms = Platform.Info.Atoms;
if (target == atoms.TARGETS)
{
var atomValues = ConvertDataTransfer(DataTransfer);
XChangeProperty(Platform.Display, window, property, atoms.ATOM, 32, PropertyMode.Replace,
atomValues, atomValues.Length);
return property;
}
if (target == atoms.SAVE_TARGETS)
{
return property;
}
if (DataFormatHelper.ToDataFormat(target, atoms) is { } dataFormat)
{
if (DataTransfer is null)
return 0;
// Our default bitmap format is image/png
if (dataFormat.Identifier is "image/png" && DataTransfer.Contains(DataFormat.Bitmap))
dataFormat = DataFormat.Bitmap;
if (!DataTransfer.Contains(dataFormat))
return 0;
if (TryGetDataAsBytes(DataTransfer, dataFormat, target) is not { } bytes)
return 0;
_ = SendDataToClientAsync(window, property, target, bytes);
return property;
}
if (target == atoms.MULTIPLE)
{
XGetWindowProperty(Platform.Display, window, property, 0, int.MaxValue, false,
atoms.ATOM_PAIR, out _, out var actualFormat, out var nitems, out _, out var prop);
if (nitems == 0)
return 0;
if (actualFormat == 32)
{
unsafe
{
var data = (IntPtr*)prop.ToPointer();
for (var c = 0; c < nitems.ToInt32(); c += 2)
{
var subTarget = data[c];
var subProp = data[c + 1];
var converted = WriteTargetToProperty(subTarget, window, subProp);
data[c + 1] = converted;
}
XChangeProperty(Platform.Display, window, property, atoms.ATOM_PAIR, 32, PropertyMode.Replace,
prop.ToPointer(), nitems.ToInt32());
}
}
XFree(prop);
return property;
}
return 0;
}
}
private byte[]? TryGetDataAsBytes(IAsyncDataTransfer dataTransfer, DataFormat format, IntPtr targetFormatAtom)
{
if (DataFormat.Text.Equals(format))
{
var text = dataTransfer.TryGetValueAsync(DataFormat.Text).GetAwaiter().GetResult();
return DataFormatHelper.TryGetStringEncoding(targetFormatAtom, Platform.Info.Atoms) is { } encoding ?
encoding.GetBytes(text ?? string.Empty) :
null;
}
if (DataFormat.Bitmap.Equals(format))
{
if (dataTransfer.TryGetValueAsync(DataFormat.Bitmap).GetAwaiter().GetResult() is not { } bitmap)
return null;
using var stream = new MemoryStream();
bitmap.Save(stream);
return stream.ToArray();
}
if (DataFormat.File.Equals(format))
{
if (dataTransfer.TryGetValuesAsync(DataFormat.File).GetAwaiter().GetResult() is not { } files)
return null;
return UriListHelper.FileUriListToUtf8Bytes(files);
}
if (format is DataFormat<string> stringFormat)
{
return dataTransfer.TryGetValueAsync(stringFormat).GetAwaiter().GetResult() is { } stringValue ?
Encoding.UTF8.GetBytes(stringValue) :
null;
}
if (format is DataFormat<byte[]> bytesFormat)
return dataTransfer.TryGetValueAsync(bytesFormat).GetAwaiter().GetResult();
Logger.TryGet(LogEventLevel.Warning, LogArea.X11Platform)
?.Log(this, "Unsupported data format {Format}", format);
return null;
}
private async Task SendIncrDataToClientAsync(IntPtr window, IntPtr property, IntPtr target, Stream data)
{
data.Position = 0;
using var events = new EventStreamWindow(Platform, window);
using var _ = data;
XSelectInput(Platform.Display, window, new IntPtr((int)XEventMask.PropertyChangeMask));
var size = new IntPtr(data.Length);
XChangeProperty(Platform.Display, window, property, Platform.Info.Atoms.INCR, 32, PropertyMode.Replace,
ref size, 1);
var buffer = ArrayPool<byte>.Shared.Rent((int)Math.Min(_maximumPropertySize, data.Length));
while (true)
{
var evt = await events.WaitForEventAsync(
x =>
x.type == XEventName.PropertyNotify &&
x.PropertyEvent.atom == property &&
x.PropertyEvent.state == 1,
TimeSpan.FromMinutes(1));
if (evt is null)
break;
var read = await data.ReadAsync(buffer, 0, buffer.Length);
if (read == 0)
break;
XChangeProperty(Platform.Display, window, property, target, 8, PropertyMode.Replace, buffer, read);
}
ArrayPool<byte>.Shared.Return(buffer);
// Finish the transfer
XChangeProperty(Platform.Display, window, property, target, 8, PropertyMode.Replace, 0, 0);
}
private Task SendDataToClientAsync(IntPtr window, IntPtr property, IntPtr target, byte[] bytes)
{
if (bytes.Length < _maximumPropertySize)
{
XChangeProperty(Platform.Display, window, property, target, 8, PropertyMode.Replace,
bytes, bytes.Length);
return Task.CompletedTask;
}
return SendIncrDataToClientAsync(window, property, target, new MemoryStream(bytes));
}
protected IntPtr[] ConvertDataTransfer(IAsyncDataTransfer? dataTransfer)
{
var atoms = Platform.Info.Atoms;
var atomValues = new List<IntPtr> { atoms.TARGETS, atoms.MULTIPLE };
if (dataTransfer is not null)
{
foreach (var format in dataTransfer.Formats)
{
foreach (var atom in DataFormatHelper.ToAtoms(format, atoms))
atomValues.Add(atom);
}
}
return atomValues.ToArray();
}
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
protected virtual void Dispose(bool disposing)
{
if (Handle == 0)
return;
XDestroyWindow(Platform.Display, Handle);
Handle = 0;
}
}
Loading…
Cancel
Save