Dealing with X11 Primary Clipboard under Avalonia

It all started with Avalonia and a discovery that its clipboard handling under Linux doesn’t include the primary buffer. Since I was building a new GUI for my password manager this was an issue for me. I wanted to be able to paste directly into the terminal using Shift+Insert instead of messing with the mouse. And honestly, especially for password prompts, having a different result depending on which paste operation you use is annoying at best. So, I went onto building my own X11 code to deal with it.

The first idea was to see if somebody else had written the necessary code already. Most useful source for this became mono repository. However, its clipboard support was intertwined with other X11 stuff. It was way too much code to deal with for a simple paste operation but I did make use of it for figuring out X11 structures.

What helped me the most was actually seeing how X11 clipboard handling was implemented in C. From that I managed to get my initialization code running:

DisplayPtr = NativeMethods.XOpenDisplay(null);
RootWindowPtr = NativeMethods.XDefaultRootWindow(DisplayPtr);
WindowPtr = NativeMethods.XCreateSimpleWindow(DisplayPtr, RootWindowPtr, -10, -10, 1, 1, 0, 0, 0);
TargetsAtom = NativeMethods.XInternAtom(DisplayPtr, "TARGETS", only_if_exists: false);
ClipboardAtom = NativeMethods.XInternAtom(DisplayPtr, "PRIMARY", only_if_exists: false);
Utf8StringAtom = NativeMethods.XInternAtom(DisplayPtr, "UTF8_STRING", only_if_exists: false);
MetaSelectionAtom = NativeMethods.XInternAtom(DisplayPtr, "META_SELECTION", only_if_exists: false);
EventThread = new Thread(EventLoop) {  // last to initialize so we can use it as detection for successful init
  IsBackground = true,
};
EventThread.Start();

This code creates a window (XCreateSimpleWindow) for an event loop (that we’ll handle in a separate thread) and also specifies a few X11 atoms for clipboard handling.

In order to set clipboard text, we need to tell X11 that we’re the owner of the clipboard and that it should use our event handler to answer any queries. I also opted to prepare UTF-8 string bytes so we don’t need to deal with them in the loop.

private byte[] BytesOut = [];

public void SetText(string text) {
  BytesOut = Encoding.UTF8.GetBytes(text);
  NativeMethods.XSetSelectionOwner(DisplayPtr, ClipboardAtom, WindowPtr, 0);
}

But the real code is actually in our event loop where we wait for SelectionRequest event:

private void Loop() {
  while (true) {
    NativeMethods.XEvent @event = new();
    NativeMethods.XNextEvent(DisplayPtr, ref @event);

    switch (@event.type) {
      case NativeMethods.XEventType.SelectionRequest: {
        var requestEvent = @event.xselectionrequest;
        if (NativeMethods.XGetSelectionOwner(DisplayPtr, requestEvent.selection) != WindowPtr) { continue; }
        if (requestEvent.selection != ClipboardAtom) { continue; }
        if (requestEvent.property == IntPtr.Zero) { continue; }

        // rest of selection handling code
        break;
    }
  }
}

There are two subrequests possible here. The first one for the other application asking for text formats (i.e., TARGETS atom query). Here we can give it UTF8_STRING atom that seems to be universally supported. We could have given it more formats but I honestly saw no point in messing with ANSI support. It’s 2024, for god’s sake.

if (requestEvent.target == TargetsAtom) {
  var formats = new[] { Utf8StringAtom };
  NativeMethods.XChangeProperty(requestEvent.display,
                                requestEvent.requestor,
                                requestEvent.property,
                                4,   // XA_ATOM
                                32,  // 32-bit data
                                0,   // Replace
                                formats,
                                formats.Length);

  var sendEvent = ... // create XSelectionEvent structure with type=SelectionNotify
  NativeMethods.XSendEvent(DisplayPtr,
                           requestEvent.requestor,
                           propagate: false,
                           eventMask: IntPtr.Zero,
                           ref sendEvent);
}

After we told the terminal what formats we support, we can expect a query for that data type next within the same SelectionRequest event type. Here we can finally use previously prepared our UTF-8 bytes. I opted to allocate a new buffer to avoid any issues. As in the previous case, all work is done by setting the property on the destination window (XChangeProperty) with XSendEvent serving to inform the window we’re done.

if (requestEvent.target == Utf8StringAtom) {
  var bufferPtr = IntPtr.Zero;
  int bufferLength;
  try {
    bufferPtr = Marshal.AllocHGlobal(BytesOut.Length);
    bufferLength = BytesOut.Length;
    Marshal.Copy(BytesOut, 0, bufferPtr, BytesOut.Length);
  }

  NativeMethods.XChangeProperty(DisplayPtr,
                                requestEvent.requestor,
                                requestEvent.property,
                                requestEvent.target,
                                8,  // 8-bit data
                                0,  // Replace
                                bufferPtr,
                                bufferLength);
  } finally {
    if (bufferPtr != IntPtr.Zero) { Marshal.FreeHGlobal(bufferPtr); }
  }

  var sendEvent = ... // create XSelectionEvent structure with type=SelectionNotify
  NativeMethods.XSendEvent(DisplayPtr,
                           requestEvent.requestor,
                           propagate: false,
                           eventMask: IntPtr.Zero,
                           ref sendEvent);
}

And that’s all you just need in order to support SetText. However, it seemed like a waste not to implement GetText method too.

The code for retrieving text is a bit more complicated since we cannot retrieve text directly. We must ask for it using our UTF8_STRING atom. However, we cannot read the clipboard text directly but only via our event loop. So, we need to wait for our AutoResetEvent to signal data is ready before returning.

private readonly AutoResetEvent BytesInLock = new(false);
private byte[] BytesIn = [];

public string GetText() {
  NativeMethods.XConvertSelection(DisplayPtr,
                                  ClipboardAtom,
                                  Utf8StringAtom,
                                  MetaSelectionAtom,
                                  WindowPtr,
                                  IntPtr.Zero);
  NativeMethods.XFlush(DisplayPtr);

  if (BytesInLock.WaitOne(100)) {  // 100 ms wait
    return Encoding.UTF8.GetString(BytesIn);
  } else {
    return string.Empty;
  }
}

In the event loop, we need to add an extra case for SelectionNotify event where we can handle reading the data and signaling our AutoResetEvent.

case NativeMethods.XEventType.SelectionNotify: {
  var selectionEvent = @event.xselection;
  if (selectionEvent.target != Utf8StringAtom) { continue; }  // we ignore anything not clipboard

  if (selectionEvent.property == 0) {  // nothing in clipboard
    BytesIn = [];
    BytesInLock.Set();
    continue;
  }

  var data = IntPtr.Zero;
  NativeMethods.XGetWindowProperty(DisplayPtr,
                                   selectionEvent.requestor,
                                   selectionEvent.property,
                                   long_offset: 0,
                                   long_length: int.MaxValue,
                                   delete: false,
                                   0,  // AnyPropertyType
                                   out var type,
                                   out var format,
                                   out var nitems,
                                   out var bytes_after,
                                   ref data);
  BytesIn = new byte[nitems.ToInt32()];
  Marshal.Copy(data, BytesIn, 0, BytesIn.Length);
  BytesInLock.Set();
  NativeMethods.XFree(data);
}
break;

With all this code in, you can now handle primary (aka, middle-click) clipboard just fine. And yes, the code is not fully complete, so you might want to check my X11Clipboard class, which not only provides support for primary but also for a normal clipboard too. E.g.:

X11Clipboard.Primary.SetText("A");
X11Clipboard.Primary.SetText("B");