diff --git a/Gui/SystemWindow/SystemWindow.cs b/Gui/SystemWindow/SystemWindow.cs
index 412986095..ff9c2ab41 100644
--- a/Gui/SystemWindow/SystemWindow.cs
+++ b/Gui/SystemWindow/SystemWindow.cs
@@ -532,9 +532,11 @@ public static void ResetSystemWindowProvider()
///
/// The override deliberately beats code that assigned the config value (several demos hard-code
/// theirs), because its whole purpose is running an unmodified demo on a chosen host. It
- /// understands the short names webgpu (the WinForms host) and mac (the AppKit host),
- /// and passes anything else through as a fully qualified type name so an out-of-tree provider can be
- /// named too. Neither short name is normally needed - the per-OS default in
+ /// understands the short names webgpu (the WinForms host), mac (the AppKit host) and
+ /// x11 (the Linux host), and passes anything else through as a fully qualified type name so
+ /// an out-of-tree provider can be named too. Note that webgpu names the WinForms host
+ /// specifically rather than "whichever host this OS has" - every host is a WebGPU host now, so the
+ /// name is historical. None of the three is normally needed: the per-OS default in
/// AggContext.Config.ProviderTypes already resolves to the right one.
///
///
@@ -561,6 +563,9 @@ private static string ResolveSystemWindowProviderTypeName()
case "mac":
return "MatterHackers.Agg.UI.WebGpuMacWindowProvider, agg_platform_mac";
+ case "x11":
+ return "MatterHackers.Agg.UI.WebGpuX11WindowProvider, agg_platform_linux";
+
case "bitmap":
case "d3d11":
throw new InvalidOperationException(
@@ -576,7 +581,7 @@ private static string ResolveSystemWindowProviderTypeName()
}
throw new InvalidOperationException(
- $"AGG_WINDOW_PROVIDER='{requested}' is not 'webgpu' or 'mac' and is not a 'Type, Assembly' name.");
+ $"AGG_WINDOW_PROVIDER='{requested}' is not 'webgpu', 'mac' or 'x11' and is not a 'Type, Assembly' name.");
}
}
diff --git a/PlatformLinux/PlatformLinux.csproj b/PlatformLinux/PlatformLinux.csproj
new file mode 100644
index 000000000..219d4c72c
--- /dev/null
+++ b/PlatformLinux/PlatformLinux.csproj
@@ -0,0 +1,37 @@
+
+
+
+
+ net10.0
+ MatterHackers Inc.
+ agg_platform_linux
+ agg_platform_linux
+ true
+
+
+ TRACE;DEBUG
+
+
+ TRACE;RELEASE
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/PlatformLinux/linux/LinuxClipboard.cs b/PlatformLinux/linux/LinuxClipboard.cs
new file mode 100644
index 000000000..05bd668f1
--- /dev/null
+++ b/PlatformLinux/linux/LinuxClipboard.cs
@@ -0,0 +1,273 @@
+/*
+Copyright (c) 2026, Lars Brubaker
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+1. Redistributions of source code must retain the above copyright notice, this
+ list of conditions and the following disclaimer.
+2. Redistributions in binary form must reproduce the above copyright notice,
+ this list of conditions and the following disclaimer in the documentation
+ and/or other materials provided with the distribution.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
+ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
+ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+The views and conclusions contained in the software and documentation are those
+of the authors and should not be interpreted as representing official policies,
+either expressed or implied, of the FreeBSD Project.
+*/
+
+using System;
+using System.Collections.Specialized;
+using MatterHackers.Agg.Image;
+using MatterHackers.Agg.Platform.Linux;
+
+namespace MatterHackers.Agg.UI
+{
+ ///
+ /// The Linux , backed by the X11 CLIPBOARD selection through
+ /// . The peer of MacClipboard and PlatformWin32's
+ /// WindowsFormsClipboard; an app installs it with
+ /// Clipboard.SetSystemClipboard(new LinuxClipboard()).
+ ///
+ ///
+ ///
+ /// Text and HTML round trip with other X11 clients. Images and file drop lists report "not present"
+ /// rather than pretending, exactly as on macOS - and on X11 a file drop is not even the same protocol,
+ /// it is XDND.
+ ///
+ ///
+ /// Two backings, one of which is a fallback. X11 has no clipboard storage: the owning client
+ /// is the clipboard. So while this process owns the selection the answer comes from the
+ /// strings held here - no round trip, and no way for the answer to differ from what was copied - and
+ /// when it does not, every read is a conversion request to whoever does. Those same strings are the
+ /// whole implementation when there is no X display at all, which is what a headless test run gets.
+ ///
+ ///
+ /// Threading. Xlib is single-threaded here, so only the thread that owns the display may speak
+ /// to the selection. A write from any other thread is not dropped: it is marshalled onto the
+ /// UI thread with and claimed there. A read cannot do
+ /// that - it has to answer now - so off-thread it answers from this process's own last copy, and says
+ /// so once on stderr.
+ ///
+ ///
+ /// Reads are re-entrant, but not to input. Reading another client's clipboard pumps the event
+ /// loop while it waits, so repaints and window management keep working - but key, button and motion
+ /// events are held back and replayed after the call unwinds. See 's remarks:
+ /// without that, a paste's own write-back silently eats anything typed during it.
+ ///
+ ///
+ public class LinuxClipboard : ISystemClipboard
+ {
+ /// Logged at most once per process - see .
+ private static bool warnedOffThreadRead;
+
+ ///
+ /// The last thing written here. Two jobs: the answer while this process owns the selection, and the
+ /// entire clipboard when there is no X11 to reach.
+ ///
+ private string text;
+
+ private string html;
+
+ ///
+ /// Whether a text flavor is available. Deliberately != null rather than
+ /// !string.IsNullOrEmpty, for parity with MacClipboard, whose
+ /// stringForType: != null distinguishes "the pasteboard holds an empty string" from "the
+ /// pasteboard holds no string at all". Folding those together would make copying an empty
+ /// selection behave differently on Linux than on the other two hosts.
+ ///
+ public bool ContainsText
+ {
+ get
+ {
+ X11Selection selection = SelectionForRead();
+ if (selection == null || selection.OwnsClipboard)
+ {
+ return this.text != null;
+ }
+
+ return selection.RemoteHasText();
+ }
+ }
+
+ /// Whether an HTML flavor is available. Same deliberate != null parity as
+ /// .
+ public bool ContainsHtml
+ {
+ get
+ {
+ X11Selection selection = SelectionForRead();
+ if (selection == null || selection.OwnsClipboard)
+ {
+ return this.html != null;
+ }
+
+ return selection.RemoteHasHtml();
+ }
+ }
+
+ /// Always false: images are not carried, matching the mac host.
+ public bool ContainsImage => false;
+
+ /// Always false: X11 file drops are a separate protocol (XDND), not a selection target.
+ public bool ContainsFileDropList => false;
+
+ ///
+ public string GetText()
+ {
+ X11Selection selection = SelectionForRead();
+ if (selection == null || selection.OwnsClipboard)
+ {
+ return this.text ?? string.Empty;
+ }
+
+ // The spelling is the owner's choice, not ours: UTF8_STRING if it has one, then the MIME name,
+ // then TEXT, then STRING. An old client with only STRING still holds text, and asking for
+ // UTF8_STRING alone would report its clipboard as empty.
+ return selection.RemoteText() ?? string.Empty;
+ }
+
+ ///
+ public string GetHtml()
+ {
+ X11Selection selection = SelectionForRead();
+ if (selection == null || selection.OwnsClipboard)
+ {
+ return this.html ?? string.Empty;
+ }
+
+ return selection.RemoteHtml() ?? string.Empty;
+ }
+
+ ///
+ public ImageBuffer GetImage() => null;
+
+ ///
+ public StringCollection GetFileDropList() => new StringCollection();
+
+ ///
+ public void SetText(string text)
+ {
+ // Writing plain text clears any HTML flavor, the way clearContents does on the mac: otherwise a
+ // later GetHtml would answer with HTML from an older, unrelated copy - and here it would also
+ // keep advertising a text/html target we can no longer honour.
+ this.text = text;
+ this.html = null;
+ this.PublishToSelection();
+ }
+
+ ///
+ public void SetTextAndHtml(string text, string html)
+ {
+ this.text = text;
+ this.html = html;
+ this.PublishToSelection();
+ }
+
+ ///
+ public void SetImage(ImageBuffer imageBuffer)
+ {
+ }
+
+ ///
+ /// The selection to read from, or null to answer from this process's own copy. Logs once when the
+ /// fallback is taken for a reason a developer would want to know about - a read off the display
+ /// thread, where the answer is this process's own last copy and not what another application may
+ /// have copied since.
+ ///
+ private static X11Selection SelectionForRead()
+ {
+ X11Selection selection = X11Selection.TryGet();
+ if (selection != null || !X11SystemWindow.HasDisplay)
+ {
+ // Either it worked, or this process is headless - in which case the in-process copy is not
+ // a fallback at all, it is the whole clipboard, and there is nothing to warn about.
+ return selection;
+ }
+
+ if (!warnedOffThreadRead)
+ {
+ warnedOffThreadRead = true;
+ Console.Error.WriteLine(
+ "LinuxClipboard: a clipboard read arrived off the thread that owns the X display, so it "
+ + "was answered from this process's own last copy rather than the X11 CLIPBOARD "
+ + "selection. Xlib here is single-threaded and a read cannot wait for the UI thread - "
+ + "read the clipboard from the UI thread to see what other applications have copied.");
+ }
+
+ return null;
+ }
+
+ ///
+ /// Puts the current strings on the X clipboard, from whichever thread called.
+ ///
+ ///
+ /// A write, unlike a read, has nothing to return and so can afford to wait: off the display thread
+ /// it is handed to rather than dropped. The captured values
+ /// are re-checked when that runs, so a write already superseded by a newer one does not resurrect
+ /// itself over the top of it.
+ ///
+ private void PublishToSelection()
+ {
+ X11Selection selection = X11Selection.TryGet();
+ if (selection != null)
+ {
+ Publish(selection, this.text, this.html);
+ return;
+ }
+
+ if (!X11SystemWindow.HasDisplay)
+ {
+ // Headless: the strings are the clipboard, and there is nothing to publish them to.
+ return;
+ }
+
+ string pendingText = this.text;
+ string pendingHtml = this.html;
+
+ UiThread.RunOnIdle(() =>
+ {
+ if (this.text != pendingText || this.html != pendingHtml)
+ {
+ // Superseded while this was queued. The newer write has its own turn coming.
+ return;
+ }
+
+ X11Selection deferred = X11Selection.TryGet();
+ if (deferred != null)
+ {
+ Publish(deferred, pendingText, pendingHtml);
+ }
+ });
+ }
+
+ ///
+ /// Claims the selection, or gives it up when there is nothing to offer. Releasing rather than
+ /// serving an empty string matters: "there is no text" and "the text is empty" are different
+ /// statements, distinguishes them on this host as it does on the mac,
+ /// and other clients can only see the difference if the claim is actually dropped.
+ ///
+ private static void Publish(X11Selection selection, string text, string html)
+ {
+ if (text == null && html == null)
+ {
+ selection.Release();
+ }
+ else
+ {
+ selection.Claim(text, html);
+ }
+ }
+ }
+}
diff --git a/PlatformLinux/linux/LinuxFileDialogProvider.cs b/PlatformLinux/linux/LinuxFileDialogProvider.cs
new file mode 100644
index 000000000..897dfe3fa
--- /dev/null
+++ b/PlatformLinux/linux/LinuxFileDialogProvider.cs
@@ -0,0 +1,882 @@
+/*
+Copyright (c) 2026, Lars Brubaker
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+1. Redistributions of source code must retain the above copyright notice, this
+ list of conditions and the following disclaimer.
+2. Redistributions in binary form must reproduce the above copyright notice,
+ this list of conditions and the following disclaimer in the documentation
+ and/or other materials provided with the distribution.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
+ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
+ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+The views and conclusions contained in the software and documentation are those
+of the authors and should not be interpreted as representing official policies,
+either expressed or implied, of the FreeBSD Project.
+*/
+
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.IO;
+using System.Threading.Tasks;
+using MatterHackers.Agg.UI;
+
+namespace MatterHackers.Agg.Platform
+{
+ ///
+ /// File dialogs on Linux, over whichever of zenity (GTK) or kdialog (KDE) is installed.
+ ///
+ ///
+ ///
+ /// There is no file dialog in X11 and no dialog API in this assembly's dependency set - PlatformLinux
+ /// takes no NuGet or native dependency of its own, so binding GTK or the xdg-desktop-portal D-Bus
+ /// interface directly is off the table. What every desktop does ship is a helper that puts up its own
+ /// native chooser and prints the chosen paths on stdout, and that is what this drives. zenity is tried
+ /// first because it is the one present on GNOME, Xfce, Cinnamon, MATE and most bare X sessions;
+ /// kdialog covers KDE, where zenity often is not installed.
+ ///
+ ///
+ /// This differs from mac and Windows in one visible way: the call does not block.
+ /// NSOpenPanel.runModal and WinForms' ShowDialog both spin a nested native modal loop, so
+ /// the caller sits inside the dialog call until the user answers and the provider can return the answer
+ /// as its return value. X11 has no such nested loop to borrow, and running one here by pumping
+ /// X11SystemWindow's event loop from inside a dialog call would re-enter the loop from an idle
+ /// action - the exact re-entrancy the window host guards against. So the helper runs on a thread-pool
+ /// thread and the result comes back through UiThread.RunOnIdle, which is the same place the
+ /// callback lands on the other two hosts. The return value therefore means the dialog was shown,
+ /// not the user picked something, and callers must use the callback for the answer. Every caller
+ /// in this repo already does - the mac host's own callers had to, because RunOnIdle keeps ticking under
+ /// runModal there too.
+ ///
+ ///
+ /// The consequence to know about: the agg window underneath stays interactive while the chooser is up,
+ /// where on mac and Windows it is blocked. Nothing here breaks from a second click, but a user can
+ /// start a second dialog on top of the first. A modal scrim (or an X11 grab) is the follow-up if that
+ /// turns out to matter in practice.
+ ///
+ ///
+ /// Cancel is silent, matching MacFileDialogProvider: the helper exits 1, no callback is invoked,
+ /// and the caller's params are left exactly as they were on the way in. Only
+ /// clears anything, and it does that up front rather than on cancel - again mirroring mac - so a params
+ /// object reused across calls cannot hand back last time's answer. A helper that fails rather
+ /// than being cancelled is not silent; see .
+ ///
+ ///
+ public class LinuxFileDialogProvider : IFileDialogProvider
+ {
+ /// Which helper this machine has. Probed once - PATH does not change under a running app.
+ private static readonly Lazy InstalledTool = new Lazy(ProbeForTool);
+
+ private enum DialogTool
+ {
+ /// Neither helper is installed; dialogs cannot be shown at all.
+ None,
+
+ Zenity,
+
+ KDialog,
+ }
+
+ public string LastDirectoryUsed { get; private set; }
+
+ /// Linux paths need no translation; this exists for platforms whose paths do.
+ public string ResolveFilePath(string path) => path;
+
+ public bool OpenFileDialog(OpenFileDialogParams openParams, Action callback)
+ {
+ // Reset first, exactly as the mac provider does, so a caller that keeps a params object around
+ // never reads last time's answer as this time's.
+ openParams.FileName = string.Empty;
+ openParams.FileNames = null;
+
+ return this.ShowDialog(
+ openParams.MultiSelect,
+ tool => tool == DialogTool.Zenity ? BuildZenityArguments(openParams) : BuildKdialogArguments(openParams),
+ paths =>
+ {
+ openParams.FileNames = paths;
+ openParams.FileName = paths[0];
+ this.LastDirectoryUsed = Path.GetDirectoryName(paths[0]);
+
+ callback?.Invoke(openParams);
+ });
+ }
+
+ public bool SaveFileDialog(SaveFileDialogParams saveParams, Action callback)
+ {
+ return this.ShowDialog(
+ multipleSelection: false,
+ tool => tool == DialogTool.Zenity ? BuildZenityArguments(saveParams) : BuildKdialogArguments(saveParams),
+ paths =>
+ {
+ saveParams.FileName = paths[0];
+ saveParams.FileNames = new[] { paths[0] };
+ this.LastDirectoryUsed = Path.GetDirectoryName(paths[0]);
+
+ callback?.Invoke(saveParams);
+ });
+ }
+
+ public bool SelectFolderDialog(SelectFolderDialogParams folderParams, Action callback)
+ {
+ return this.ShowDialog(
+ multipleSelection: false,
+ tool => tool == DialogTool.Zenity ? BuildZenityArguments(folderParams) : BuildKdialogArguments(folderParams),
+ paths =>
+ {
+ folderParams.FolderPath = paths[0];
+ this.LastDirectoryUsed = paths[0];
+
+ callback?.Invoke(folderParams);
+ });
+ }
+
+ ///
+ /// Opens a file manager on the file's folder with the file selected, falling back to just opening
+ /// the folder.
+ ///
+ ///
+ /// The same two-step MatterCAD's LinuxShellIntegration uses, and deliberately a copy of it:
+ /// that class lives in the application and this assembly is a library underneath it, so there is no
+ /// reference to share. There is no "reveal" command on Linux the way open -R is on macOS;
+ /// what there is is org.freedesktop.FileManager1.ShowItems, which Nautilus, Dolphin, Nemo,
+ /// Thunar and PCManFM all implement. Where nothing owns that bus name the call fails and
+ /// xdg-open on the containing folder is the honest degradation - right folder, file not
+ /// preselected.
+ ///
+ public void ShowFileInFolder(string fileName)
+ {
+ if (string.IsNullOrEmpty(fileName))
+ {
+ return;
+ }
+
+ var showItems = new ProcessStartInfo("dbus-send")
+ {
+ UseShellExecute = false,
+ };
+
+ showItems.ArgumentList.Add("--session");
+
+ // Without --print-reply dbus-send fires the call off and exits 0 whether or not anything is
+ // listening, which would make the exit code meaningless and the xdg-open fallback below dead
+ // code. With it, dbus-send waits for the reply and exits non-zero when the name is unowned or
+ // the method failed - which is exactly the "no file manager here" signal the fallback needs.
+ showItems.ArgumentList.Add("--print-reply");
+ showItems.ArgumentList.Add("--dest=org.freedesktop.FileManager1");
+ showItems.ArgumentList.Add("--type=method_call");
+ showItems.ArgumentList.Add("/org/freedesktop/FileManager1");
+ showItems.ArgumentList.Add("org.freedesktop.FileManager1.ShowItems");
+ showItems.ArgumentList.Add(BuildShowItemsArgument(fileName));
+ showItems.ArgumentList.Add("string:");
+
+ if (TryRunToCompletion(showItems))
+ {
+ return;
+ }
+
+ string directory = Path.GetDirectoryName(Path.GetFullPath(fileName));
+ if (string.IsNullOrEmpty(directory))
+ {
+ return;
+ }
+
+ var openFolder = new ProcessStartInfo("xdg-open")
+ {
+ UseShellExecute = false,
+ };
+
+ openFolder.ArgumentList.Add(directory);
+
+ TryStart(openFolder);
+ }
+
+ ///
+ /// Builds the array:string: argument naming for
+ /// dbus-send's org.freedesktop.FileManager1.ShowItems call.
+ ///
+ ///
+ /// Two escapings, both load-bearing. ShowItems takes file:// URIs, so the path is percent-encoded
+ /// ( does it) - without that a # in a name truncates the path at
+ /// the fragment. On top of that dbus-send splits an array: value on commas, so a file named
+ /// a,b.stl would arrive as two paths that do not exist and the reveal would silently do
+ /// nothing; a comma is legal unencoded in a URI path so AbsoluteUri leaves it, and this encodes it.
+ /// %2C is right on both sides - the file manager decodes it back to a comma. None of this is
+ /// shell quoting: the value is one argv entry and there is no shell in this path.
+ ///
+ internal static string BuildShowItemsArgument(string filePath)
+ {
+ string uri = new Uri(Path.GetFullPath(filePath)).AbsoluteUri;
+
+ return "array:string:" + uri.Replace(",", "%2C");
+ }
+
+ ///
+ /// Spawns the installed helper with 's argument list and hands the
+ /// chosen paths to on the UI thread.
+ ///
+ ///
+ /// True if a dialog was launched. False only when neither helper is installed - which is reported
+ /// once to stderr and never thrown, because a missing optional package must not take the
+ /// application down over a menu item.
+ ///
+ private bool ShowDialog(bool multipleSelection, Func> buildArguments, Action onAccepted)
+ {
+ DialogTool tool = InstalledTool.Value;
+ if (tool == DialogTool.None)
+ {
+ Console.Error.WriteLine("File dialogs on Linux need zenity or kdialog installed; neither was found on PATH.");
+ return false;
+ }
+
+ string executable = tool == DialogTool.Zenity ? "zenity" : "kdialog";
+ List arguments = buildArguments(tool);
+
+ // Off the UI thread deliberately - see the class remarks. Blocking here would stop the X11
+ // event loop, and with it painting and the RunOnIdle pump that delivers this very callback.
+ _ = Task.Run(async () =>
+ {
+ string[] paths = await RunDialogAsync(executable, arguments, multipleSelection).ConfigureAwait(false);
+
+ // Empty means cancelled, or a failure that RunDialogAsync has already reported. Either way
+ // there is no answer to deliver, and the mac provider is equally silent on a cancel.
+ if (paths.Length == 0)
+ {
+ return;
+ }
+
+ UiThread.RunOnIdle(() => onAccepted(paths));
+ });
+
+ return true;
+ }
+
+ ///
+ /// Runs the helper to completion and returns what the user chose - an empty array for both cancel
+ /// and failure, with failure additionally reported to .
+ ///
+ private static async Task RunDialogAsync(string executable, List arguments, bool multipleSelection)
+ {
+ try
+ {
+ var startInfo = new ProcessStartInfo(executable)
+ {
+ UseShellExecute = false,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ };
+
+ foreach (string argument in arguments)
+ {
+ startInfo.ArgumentList.Add(argument);
+ }
+
+ using (var process = Process.Start(startInfo))
+ {
+ if (process == null)
+ {
+ // Documented as possible when an existing process is reused; there is no helper to
+ // wait on and no answer coming, so the caller would otherwise wait forever.
+ Report($"{executable} could not be started.");
+ return Array.Empty();
+ }
+
+ // Both pipes are drained concurrently, and before the wait. GTK is chatty on stderr
+ // (dconf and theme warnings), and a full stderr pipe would wedge the helper forever
+ // while we sat waiting for it to exit.
+ Task standardOutput = process.StandardOutput.ReadToEndAsync();
+ Task standardError = process.StandardError.ReadToEndAsync();
+
+ await Task.WhenAll(standardOutput, standardError).ConfigureAwait(false);
+ await process.WaitForExitAsync().ConfigureAwait(false);
+
+ string failure = DescribeFailure(executable, process.ExitCode, standardError.Result);
+ if (failure != null)
+ {
+ Report(failure);
+ return Array.Empty();
+ }
+
+ return ParseDialogOutput(process.ExitCode, standardOutput.Result, multipleSelection);
+ }
+ }
+ catch (Exception exception)
+ {
+ // The helper vanished between the PATH probe and here, or could not be executed at all.
+ // This has to be reported and not merely traced: Debug.WriteLine compiles out of a Release
+ // build, and the symptom without it is a menu item that does nothing at all.
+ Report($"Failed to run {executable}: {exception.Message}");
+ return Array.Empty();
+ }
+ }
+
+ ///
+ /// Routes a dialog failure to the channel the crash reporter and the automation tests both watch.
+ ///
+ ///
+ /// Raised from the thread-pool thread the helper ran on. UiThread.ReportUnhandledException
+ /// swallows anything a subscriber throws, so this can never take down the dialog path itself.
+ ///
+ private static void Report(string message)
+ {
+ UiThread.ReportUnhandledException(new InvalidOperationException(message));
+ }
+
+ ///
+ /// Decides whether a finished helper failed, and with what message - or null for the two normal
+ /// outcomes, accept (exit 0) and cancel (exit 1).
+ ///
+ ///
+ ///
+ /// Any exit code outside {0, 1} is a failure by itself: zenity answers 255 for an option it does not
+ /// understand, which is what a filter or flag this provider builds wrongly would look like, and
+ /// silently treating that as "the user cancelled" is how such a bug survives to a release.
+ ///
+ ///
+ /// Exit 1 needs a second look, because zenity also exits 1 when it cannot open the display - a real
+ /// failure wearing the cancel code. What it cannot do is use stderr alone to tell them apart: GTK
+ /// prints a paragraph of dconf, DRI3 and theme warnings to stderr during a perfectly ordinary
+ /// cancel, and treating any stderr output as failure would file a crash report (Program.cs feeds
+ /// this channel straight to the crash reporter) every time a user backs out of an Open dialog. So
+ /// the GLib-formatted diagnostic lines are filtered out first, and only what is left - a plain
+ /// message, the shape a tool uses for its own errors - counts as evidence.
+ ///
+ ///
+ internal static string DescribeFailure(string executable, int exitCode, string standardError)
+ {
+ if (exitCode == 0)
+ {
+ return null;
+ }
+
+ string complaint = ToolComplaint(standardError);
+
+ if (exitCode == 1 && complaint == null)
+ {
+ // The ordinary cancel.
+ return null;
+ }
+
+ return complaint == null
+ ? $"{executable} exited with code {exitCode}."
+ : $"{executable} exited with code {exitCode}: {complaint}";
+ }
+
+ ///
+ /// Strips GLib's own diagnostic lines out of stderr and returns what a tool said in its own voice,
+ /// or null if it said nothing.
+ ///
+ ///
+ /// GLib formats every warning it emits as either (name:pid): DOMAIN-LEVEL **: time: text or
+ /// DOMAIN-Message: text, and Mesa's EGL loader prefixes its own with libEGL warning:.
+ /// A tool reporting its own failure - This option is not available... from zenity - has none
+ /// of that structure, which is the only durable way to tell the two apart without matching on
+ /// message text that changes with the locale.
+ ///
+ private static string ToolComplaint(string standardError)
+ {
+ if (string.IsNullOrWhiteSpace(standardError))
+ {
+ return null;
+ }
+
+ foreach (string line in standardError.Split('\n'))
+ {
+ string trimmed = line.Trim();
+
+ if (trimmed.Length == 0
+ || trimmed.StartsWith("(", StringComparison.Ordinal)
+
+ // GLib drops the domain when a message has none, and then leads with the "** " marker
+ // instead: "** (zenity:123): WARNING **: ...", "** Message: ...". Both forms lose the
+ // hyphen the domain-ed checks below key on, so without this a cancel on any desktop
+ // lacking at-spi ("Couldn't connect to accessibility bus") files a crash report.
+ || trimmed.StartsWith("** ", StringComparison.Ordinal)
+ || trimmed.StartsWith("libEGL warning:", StringComparison.Ordinal)
+ || trimmed.Contains("-WARNING **:", StringComparison.Ordinal)
+ || trimmed.Contains("-CRITICAL **:", StringComparison.Ordinal)
+ || trimmed.Contains("-Message:", StringComparison.Ordinal))
+ {
+ continue;
+ }
+
+ return trimmed;
+ }
+
+ return null;
+ }
+
+ ///
+ /// Turns a helper's exit code and stdout into the chosen paths.
+ ///
+ ///
+ ///
+ /// Both helpers agree on the shape: exit 0 and the chosen path on stdout, with a trailing newline.
+ ///
+ ///
+ /// Splitting on newlines is correct only for a multi-select, where the arguments asked for a
+ /// newline separator. A newline is perfectly legal in a Unix filename - nothing but / and NUL
+ /// is illegal - so newline is not a safe separator, only the least bad one on offer: zenity's
+ /// default is | and kdialog's is a space, and both of those turn up in real filenames
+ /// constantly where a newline essentially never does. For a single selection there is no separator
+ /// problem to have, so nothing is split and the whole of stdout is the path, minus the one trailing
+ /// newline the helper added - which is what makes a single file named with a newline work.
+ ///
+ ///
+ internal static string[] ParseDialogOutput(int exitCode, string standardOutput, bool multipleSelection)
+ {
+ if (exitCode != 0 || string.IsNullOrEmpty(standardOutput))
+ {
+ return Array.Empty();
+ }
+
+ if (!multipleSelection)
+ {
+ string only = TrimOneTrailingNewline(standardOutput);
+
+ return only.Length == 0 ? Array.Empty() : new[] { only };
+ }
+
+ var paths = new List();
+ foreach (string line in standardOutput.Split('\n'))
+ {
+ // Only the carriage return of a CRLF comes off. Spaces do not: a trailing space is a legal
+ // and unremarkable part of a filename, and trimming it yields a path that does not exist.
+ string path = line.Trim('\r');
+ if (path.Length > 0)
+ {
+ paths.Add(path);
+ }
+ }
+
+ return paths.ToArray();
+ }
+
+ /// Removes the single line terminator a helper prints after the path, and nothing else.
+ private static string TrimOneTrailingNewline(string output)
+ {
+ if (output.EndsWith("\n", StringComparison.Ordinal))
+ {
+ output = output.Substring(0, output.Length - 1);
+ }
+
+ if (output.EndsWith("\r", StringComparison.Ordinal))
+ {
+ output = output.Substring(0, output.Length - 1);
+ }
+
+ return output;
+ }
+
+ internal static List BuildZenityArguments(OpenFileDialogParams openParams)
+ {
+ var arguments = new List { "--file-selection" };
+
+ AddZenityTitle(arguments, openParams.Title);
+
+ if (openParams.MultiSelect)
+ {
+ arguments.Add("--multiple");
+ arguments.Add("--separator=\n");
+ }
+
+ AddZenityFilename(arguments, DirectoryArgument(openParams.InitialDirectory));
+ arguments.AddRange(ZenityFilters(openParams.Filter));
+
+ return arguments;
+ }
+
+ internal static List BuildZenityArguments(SaveFileDialogParams saveParams)
+ {
+ var arguments = new List { "--file-selection", "--save" };
+
+ // Deprecated as of zenity 4 (where it warns and does nothing) but still what makes zenity 3
+ // confirm before clobbering a file, and zenity 3 is what most current LTS distributions ship.
+ arguments.Add("--confirm-overwrite");
+
+ AddZenityTitle(arguments, saveParams.Title);
+ AddZenityFilename(arguments, SuggestedSavePath(saveParams));
+ arguments.AddRange(ZenityFilters(saveParams.Filter));
+
+ return arguments;
+ }
+
+ internal static List BuildZenityArguments(SelectFolderDialogParams folderParams)
+ {
+ var arguments = new List { "--file-selection", "--directory" };
+
+ // Folder params carry a Description where the file ones carry only a Title, and callers fill
+ // the Description far more often - it is the one required constructor argument. The mac panel
+ // has a message line to put it on; a zenity chooser has only its title bar.
+ AddZenityTitle(arguments, string.IsNullOrEmpty(folderParams.Title) ? folderParams.Description : folderParams.Title);
+ AddZenityFilename(arguments, DirectoryArgument(folderParams.FolderPath));
+
+ return arguments;
+ }
+
+ internal static List BuildKdialogArguments(OpenFileDialogParams openParams)
+ {
+ var arguments = new List();
+
+ AddKdialogTitle(arguments, openParams.Title);
+
+ if (openParams.MultiSelect)
+ {
+ arguments.Add("--multiple");
+ arguments.Add("--separate-output");
+ }
+
+ // kdialog takes the start directory and the filter as positionals after the command, so the
+ // command has to be last and the start directory has to be present whenever a filter is.
+ arguments.Add("--getopenfilename");
+ AddKdialogPositionals(arguments, StartDirectoryArgument(openParams.InitialDirectory), openParams.Filter);
+
+ return arguments;
+ }
+
+ internal static List BuildKdialogArguments(SaveFileDialogParams saveParams)
+ {
+ var arguments = new List();
+
+ AddKdialogTitle(arguments, saveParams.Title);
+
+ // No overwrite flag: kdialog's save chooser confirms on its own.
+ arguments.Add("--getsavefilename");
+ AddKdialogPositionals(arguments, StartDirectoryArgument(SuggestedSavePath(saveParams)), saveParams.Filter);
+
+ return arguments;
+ }
+
+ internal static List BuildKdialogArguments(SelectFolderDialogParams folderParams)
+ {
+ var arguments = new List();
+
+ AddKdialogTitle(arguments, string.IsNullOrEmpty(folderParams.Title) ? folderParams.Description : folderParams.Title);
+
+ arguments.Add("--getexistingdirectory");
+
+ // Through the same guard as the other two: an unset FolderPath used to append a bare null here,
+ // which ProcessStartInfo.ArgumentList rejects with an ArgumentNullException out of a menu click.
+ AddKdialogPositionals(arguments, StartDirectoryArgument(DirectoryArgument(folderParams.FolderPath)), filter: null);
+
+ return arguments;
+ }
+
+ ///
+ /// Translates a string into zenity's --file-filter
+ /// arguments - one per group, in the NAME | PATTERN PATTERN form zenity parses.
+ ///
+ internal static IEnumerable ZenityFilters(string filter)
+ {
+ foreach (var group in ParseFilter(filter))
+ {
+ yield return "--file-filter=" + group.Description + " | " + string.Join(" ", group.Patterns);
+ }
+ }
+
+ ///
+ /// Translates a string into kdialog's single filter argument.
+ ///
+ ///
+ /// kdialog inverts agg's ordering: it wants PATTERN PATTERN|Description, one group per line,
+ /// where agg's string is Description|PATTERN;PATTERN. Returns null for no filter, which is
+ /// the signal not to pass a filter positional at all.
+ ///
+ internal static string KdialogFilter(string filter)
+ {
+ var groups = new List();
+ foreach (var group in ParseFilter(filter))
+ {
+ groups.Add(string.Join(" ", group.Patterns) + "|" + group.Description);
+ }
+
+ return groups.Count == 0 ? null : string.Join("\n", groups);
+ }
+
+ ///
+ /// Splits agg's "Meshes|*.stl;*.amf|All Files|*.*" filter into description/pattern groups.
+ ///
+ ///
+ /// The format is positional pairs with no escaping, so a trailing unpaired element (a description
+ /// with no patterns) is simply dropped rather than treated as an error - the alternative is
+ /// throwing out of a menu click over a typo in a filter string.
+ ///
+ private static IEnumerable<(string Description, string[] Patterns)> ParseFilter(string filter)
+ {
+ if (string.IsNullOrWhiteSpace(filter))
+ {
+ yield break;
+ }
+
+ string[] fields = filter.Split('|');
+
+ // Step by two and stop before an unpaired tail.
+ for (int i = 0; i + 1 < fields.Length; i += 2)
+ {
+ string description = fields[i].Trim();
+ var patterns = new List();
+
+ foreach (string pattern in fields[i + 1].Split(';'))
+ {
+ string trimmed = pattern.Trim();
+ if (trimmed.Length > 0)
+ {
+ patterns.Add(trimmed);
+ }
+ }
+
+ if (patterns.Count > 0)
+ {
+ yield return (description, patterns.ToArray());
+ }
+ }
+ }
+
+ private static void AddZenityTitle(List arguments, string title)
+ {
+ if (!string.IsNullOrEmpty(title))
+ {
+ arguments.Add("--title=" + title);
+ }
+ }
+
+ private static void AddZenityFilename(List arguments, string path)
+ {
+ if (!string.IsNullOrEmpty(path))
+ {
+ arguments.Add("--filename=" + path);
+ }
+ }
+
+ private static void AddKdialogTitle(List arguments, string title)
+ {
+ if (!string.IsNullOrEmpty(title))
+ {
+ arguments.Add("--title");
+ arguments.Add(title);
+ }
+ }
+
+ private static void AddKdialogPositionals(List arguments, string startDirectory, string filter)
+ {
+ string kdialogFilter = KdialogFilter(filter);
+
+ if (string.IsNullOrEmpty(startDirectory) && kdialogFilter == null)
+ {
+ return;
+ }
+
+ // Everything after "--" is a positional, whatever it starts with. Without it a start directory
+ // beginning with "-" - which a directory legitimately may - is read as an unknown option and
+ // kdialog exits instead of opening.
+ arguments.Add("--");
+
+ // The filter is the second positional, so it needs a start directory ahead of it even when the
+ // caller gave none. "." is kdialog's own default.
+ arguments.Add(string.IsNullOrEmpty(startDirectory) ? "." : startDirectory);
+
+ if (kdialogFilter != null)
+ {
+ arguments.Add(kdialogFilter);
+ }
+ }
+
+ ///
+ /// Normalizes a directory into the trailing-slash form both helpers read as "start here" rather
+ /// than "preselect a file with this name".
+ ///
+ private static string DirectoryArgument(string directory)
+ {
+ if (string.IsNullOrEmpty(directory))
+ {
+ return null;
+ }
+
+ return directory.EndsWith("/", StringComparison.Ordinal) ? directory : directory + "/";
+ }
+
+ ///
+ /// The path a save dialog should open on: the caller's directory with the caller's suggested file
+ /// name inside it, either half optional.
+ ///
+ private static string SuggestedSavePath(SaveFileDialogParams saveParams)
+ {
+ string directory = DirectoryArgument(saveParams.InitialDirectory);
+ string fileName = string.IsNullOrEmpty(saveParams.FileName) ? null : Path.GetFileName(saveParams.FileName);
+
+ if (fileName == null)
+ {
+ return directory;
+ }
+
+ return directory == null ? fileName : directory + fileName;
+ }
+
+ ///
+ /// kdialog's start-directory positional wants a path, not the trailing-slash directory form zenity
+ /// wants, and it is happy with a file path (it starts in that file's folder).
+ ///
+ private static string StartDirectoryArgument(string path)
+ {
+ if (string.IsNullOrEmpty(path) || path == "/")
+ {
+ return path;
+ }
+
+ return path.TrimEnd('/');
+ }
+
+ private static DialogTool ProbeForTool()
+ {
+ if (FindOnPath("zenity") != null)
+ {
+ return DialogTool.Zenity;
+ }
+
+ if (FindOnPath("kdialog") != null)
+ {
+ return DialogTool.KDialog;
+ }
+
+ return DialogTool.None;
+ }
+
+ ///
+ /// Finds on PATH, or null. This is what which does, without
+ /// spawning a shell to ask.
+ ///
+ /// The bare command name to look for.
+ ///
+ /// The colon-separated list to search, defaulting to the process's own PATH. Passed explicitly only
+ /// by the tests, which would otherwise have to mutate PATH for the whole process to cover it.
+ ///
+ internal static string FindOnPath(string executable, string searchPath = null)
+ {
+ searchPath ??= Environment.GetEnvironmentVariable("PATH");
+ if (string.IsNullOrEmpty(searchPath))
+ {
+ return null;
+ }
+
+ foreach (string directory in searchPath.Split(Path.PathSeparator))
+ {
+ if (string.IsNullOrEmpty(directory))
+ {
+ continue;
+ }
+
+ try
+ {
+ string candidate = Path.Combine(directory, executable);
+
+ // Existence is not enough. A same-named data file, or a script whose execute bit was
+ // lost to an unzip, would be "found" here and then fail at Process.Start - which is a
+ // launch failure reported to the user rather than the quiet fallback to the other
+ // helper that the situation actually calls for.
+ if (File.Exists(candidate) && IsExecutable(candidate))
+ {
+ return candidate;
+ }
+ }
+ catch (ArgumentException)
+ {
+ // A PATH entry with invalid path characters in it. Skip and keep looking.
+ }
+ }
+
+ return null;
+ }
+
+ /// Whether any of the three execute bits is set on .
+ private static bool IsExecutable(string path)
+ {
+ try
+ {
+ UnixFileMode mode = File.GetUnixFileMode(path);
+
+ return (mode & (UnixFileMode.UserExecute | UnixFileMode.GroupExecute | UnixFileMode.OtherExecute)) != 0;
+ }
+ catch (Exception)
+ {
+ // Unreadable, gone between the two calls, or a host with no Unix modes at all (this
+ // assembly targets plain net10.0 and compiles anywhere). None of those is an executable.
+ return false;
+ }
+ }
+
+ ///
+ /// Runs a helper and reports whether it succeeded. Used for the D-Bus reveal, whose failure is the
+ /// signal to fall back.
+ ///
+ private static bool TryRunToCompletion(ProcessStartInfo startInfo)
+ {
+ try
+ {
+ startInfo.RedirectStandardOutput = true;
+ startInfo.RedirectStandardError = true;
+
+ using (var process = Process.Start(startInfo))
+ {
+ if (process == null)
+ {
+ return false;
+ }
+
+ // Start draining both pipes before waiting. --print-reply makes dbus-send write the
+ // reply to stdout, and a wait on a process whose output nobody is reading deadlocks the
+ // moment that output fills the pipe buffer. These are deliberately not awaited: this
+ // call is synchronous by design (see below) and the reads only need to be *running*.
+ _ = process.StandardOutput.ReadToEndAsync();
+ _ = process.StandardError.ReadToEndAsync();
+
+ // This can run on the UI thread from a context menu, so a wedged bus must not hang the
+ // app. Two seconds is far more than a local method call needs.
+ if (!process.WaitForExit(2000))
+ {
+ process.Kill();
+ return false;
+ }
+
+ return process.ExitCode == 0;
+ }
+ }
+ catch (Exception)
+ {
+ // dbus-send missing entirely, or no session bus. Both mean "fall back".
+ return false;
+ }
+ }
+
+ ///
+ /// Fire-and-forget launch. A missing helper must not take the application down over a menu item.
+ ///
+ private static void TryStart(ProcessStartInfo startInfo)
+ {
+ try
+ {
+ // Disposed immediately: the handle is all that is being released, the child keeps running,
+ // and holding one Process object per reveal leaks a file descriptor for the app's lifetime.
+ using (Process.Start(startInfo))
+ {
+ }
+ }
+ catch (Exception exception)
+ {
+ Debug.WriteLine($"Failed to start {startInfo.FileName}: {exception.Message}");
+ }
+ }
+ }
+}
diff --git a/PlatformLinux/linux/LinuxInformationProvider.cs b/PlatformLinux/linux/LinuxInformationProvider.cs
new file mode 100644
index 000000000..0bc971ca8
--- /dev/null
+++ b/PlatformLinux/linux/LinuxInformationProvider.cs
@@ -0,0 +1,200 @@
+/*
+Copyright (c) 2026, Lars Brubaker
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+1. Redistributions of source code must retain the above copyright notice, this
+ list of conditions and the following disclaimer.
+2. Redistributions in binary form must reproduce the above copyright notice,
+ this list of conditions and the following disclaimer in the documentation
+ and/or other materials provided with the distribution.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
+ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
+ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+*/
+
+using System;
+using System.Globalization;
+using System.IO;
+using MatterHackers.Agg.Platform.Linux;
+
+namespace MatterHackers.Agg.Platform
+{
+ ///
+ /// The Linux/X11 . Exists for the same reason
+ /// MacInformationProvider does: the Windows one is built on
+ /// System.Windows.Forms.Screen and Microsoft.VisualBasic.Devices.ComputerInfo, neither of
+ /// which will even load off Windows.
+ ///
+ public class LinuxInformationProvider : IOsInformationProvider
+ {
+ ///
+ /// What reports when there is no X server to ask. A headless test run and
+ /// a CI container both land here, and every caller of DesktopSize is sizing or centring a window -
+ /// so a plausible desktop keeps that arithmetic sane, where a 0x0 one turns into a zero-size window
+ /// or a division by zero far away from here.
+ ///
+ private static readonly Point2D HeadlessDesktopSize = new Point2D(1920, 1080);
+
+ /// The DPI X11 and every toolkit on it treat as unscaled.
+ private const double BaselineDpi = 96.0;
+
+ public LinuxInformationProvider()
+ {
+ // One connection for both reads, closed before the constructor returns. Holding a Display open
+ // for the life of the provider would mean a second connection to the server alongside the
+ // window host's, and a file descriptor that nothing ever closes.
+ IntPtr display = TryOpenDisplay();
+ try
+ {
+ this.DesktopSize = ReadDesktopSize(display);
+ this.DisplayScale = ReadDisplayScale(display);
+ }
+ finally
+ {
+ if (display != IntPtr.Zero)
+ {
+ Xlib.XCloseDisplay(display);
+ }
+ }
+ }
+
+ public OSType OperatingSystem => OSType.X11;
+
+ ///
+ /// The screen size in device pixels, to match the space every other size in agg is
+ /// expressed in. This is the whole screen, not a work area: X11 itself has no concept of one, and
+ /// the _NET_WORKAREA that window managers publish is a convention, is often absent, and is
+ /// meaningless on a multi-head setup. Read once at construction, like the mac provider's - a display
+ /// change mid-run is not something the toolkit reacts to today.
+ ///
+ public Point2D DesktopSize { get; }
+
+ ///
+ /// The user's display scaling. X11 has no per-display scale factor of its own, so this is
+ /// reconstructed from what the desktop environment wrote down: Xft.dpi over 96 first, then
+ /// GDK_SCALE, then 1. Read once at construction, like .
+ ///
+ public double DisplayScale { get; }
+
+ ///
+ /// Total physical RAM in bytes, from /proc/meminfo's MemTotal line - the only portable
+ /// source on Linux (sysconf would need a P/Invoke to get the same number less legibly).
+ ///
+ public long PhysicalMemory
+ {
+ get
+ {
+ try
+ {
+ foreach (string line in File.ReadLines("/proc/meminfo"))
+ {
+ if (!line.StartsWith("MemTotal:", StringComparison.Ordinal))
+ {
+ continue;
+ }
+
+ // The line is "MemTotal: 16311476 kB" - the unit is always kB, and has been
+ // since the file existed, but the whitespace run between the fields is not fixed.
+ string[] fields = line.Split((char[])null, StringSplitOptions.RemoveEmptyEntries);
+ if (fields.Length >= 2
+ && long.TryParse(fields[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out long kilobytes))
+ {
+ return kilobytes * 1024;
+ }
+
+ break;
+ }
+ }
+ catch (IOException)
+ {
+ // /proc is not mounted, which is possible inside a minimal container. Zero is what the
+ // mac provider reports when its own query fails.
+ }
+ catch (UnauthorizedAccessException)
+ {
+ }
+
+ return 0;
+ }
+ }
+
+ ///
+ /// Opens the default display, or returns zero when there is none. A missing libX11 is treated the
+ /// same as a missing server: a machine with no X libraries installed can still legitimately run the
+ /// non-UI parts of this stack, and this provider is constructed from wherever AggContext is
+ /// first touched - which under a test runner is a thread pool worker with no display at all.
+ ///
+ private static IntPtr TryOpenDisplay()
+ {
+ try
+ {
+ return Xlib.XOpenDisplay(null);
+ }
+ catch (DllNotFoundException)
+ {
+ return IntPtr.Zero;
+ }
+ catch (EntryPointNotFoundException)
+ {
+ return IntPtr.Zero;
+ }
+ }
+
+ private static Point2D ReadDesktopSize(IntPtr display)
+ {
+ if (display == IntPtr.Zero)
+ {
+ return HeadlessDesktopSize;
+ }
+
+ int screen = Xlib.XDefaultScreen(display);
+ int width = Xlib.XDisplayWidth(display, screen);
+ int height = Xlib.XDisplayHeight(display, screen);
+
+ // A server that answers at all always has a positive screen size; the guard is here so a broken
+ // answer degrades to the headless default rather than to a zero-size desktop.
+ if (width <= 0 || height <= 0)
+ {
+ return HeadlessDesktopSize;
+ }
+
+ return new Point2D(width, height);
+ }
+
+ private static double ReadDisplayScale(IntPtr display)
+ {
+ // Xft.dpi is what every desktop environment writes when the user picks a scaling factor, and it
+ // is the only one of these that can express a fractional scale.
+ if (display != IntPtr.Zero
+ && Xlib.TryReadXftDpi(display, out double dpi)
+ && dpi > 0)
+ {
+ return dpi / BaselineDpi;
+ }
+
+ // GDK_SCALE is GTK's integer-only override, and is the one thing that is still set when an app
+ // is launched from a scaled session with no resource database (a bare WM, or a login that never
+ // ran xrdb). It is integral by definition, so it is parsed as an int and not a double.
+ string gdkScale = Environment.GetEnvironmentVariable("GDK_SCALE");
+ if (!string.IsNullOrWhiteSpace(gdkScale)
+ && int.TryParse(gdkScale.Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out int scale)
+ && scale > 0)
+ {
+ return scale;
+ }
+
+ return 1.0;
+ }
+ }
+}
diff --git a/PlatformLinux/linux/WebGpuX11WindowProvider.cs b/PlatformLinux/linux/WebGpuX11WindowProvider.cs
new file mode 100644
index 000000000..2f23cad88
--- /dev/null
+++ b/PlatformLinux/linux/WebGpuX11WindowProvider.cs
@@ -0,0 +1,83 @@
+/*
+Copyright (c) 2026, Lars Brubaker
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+1. Redistributions of source code must retain the above copyright notice, this
+ list of conditions and the following disclaimer.
+2. Redistributions in binary form must reproduce the above copyright notice,
+ this list of conditions and the following disclaimer in the documentation
+ and/or other materials provided with the distribution.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
+ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
+ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+*/
+
+using System.Collections.Generic;
+using System.Linq;
+
+namespace MatterHackers.Agg.UI
+{
+ ///
+ /// Hands out s. Resolved by name as
+ /// "MatterHackers.Agg.UI.WebGpuX11WindowProvider, agg_platform_linux", which is what
+ /// AggContext.Config.ProviderTypes.SystemWindowProvider defaults to on Linux and what
+ /// AGG_WINDOW_PROVIDER=x11 selects.
+ ///
+ /// One native window per agg window, which is the arrangement a demo wants. An application shell uses
+ /// SingleWindowProvider instead and sets .
+ ///
+ ///
+ public class WebGpuX11WindowProvider : ISystemWindowProvider
+ {
+ private readonly List openWindows = new List();
+
+ public IReadOnlyList OpenWindows => this.openWindows;
+
+ public SystemWindow TopWindow => this.openWindows.LastOrDefault();
+
+ /// Creates or reconnects a platform window for the given .
+ public void ShowSystemWindow(SystemWindow systemWindow)
+ {
+ IPlatformWindow platformWindow;
+
+ if (systemWindow.PlatformWindow == null)
+ {
+ platformWindow = new X11SystemWindow
+ {
+ Caption = systemWindow.Title,
+ MinimumSize = systemWindow.MinimumSize,
+ };
+ }
+ else
+ {
+ platformWindow = systemWindow.PlatformWindow;
+ }
+
+ if (platformWindow is X11SystemWindow x11Window)
+ {
+ x11Window.WindowProvider = this;
+ }
+
+ this.openWindows.Add(systemWindow);
+
+ platformWindow.ShowSystemWindow(systemWindow);
+ }
+
+ public void CloseSystemWindow(SystemWindow systemWindow)
+ {
+ systemWindow.PlatformWindow?.CloseSystemWindow(systemWindow);
+ this.openWindows.Remove(systemWindow);
+ }
+ }
+}
diff --git a/PlatformLinux/linux/X11Constants.cs b/PlatformLinux/linux/X11Constants.cs
new file mode 100644
index 000000000..2d8190e75
--- /dev/null
+++ b/PlatformLinux/linux/X11Constants.cs
@@ -0,0 +1,436 @@
+/*
+Copyright (c) 2026, Lars Brubaker
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+1. Redistributions of source code must retain the above copyright notice, this
+ list of conditions and the following disclaimer.
+2. Redistributions in binary form must reproduce the above copyright notice,
+ this list of conditions and the following disclaimer in the documentation
+ and/or other materials provided with the distribution.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
+ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
+ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+*/
+
+namespace MatterHackers.Agg.Platform.Linux
+{
+ ///
+ /// The X11 protocol values PlatformLinux needs, transcribed from X11/X.h,
+ /// X11/Xutil.h, X11/keysymdef.h and X11/cursorfont.h. They are plain constants
+ /// rather than a binding for the same reason PlatformMac's AppKitConstants are: none of this is
+ /// discoverable at runtime, because a C enum carries no metadata across a P/Invoke.
+ ///
+ internal static class X11
+ {
+ // ---- Universal "nothing" values -------------------------------------------------------------
+ // X.h spells all three of these `None`/`CurrentTime`; they are separated here only so a call site
+ // says which kind of nothing it means.
+
+ /// X.h's None: the null XID. Never a real window, atom, cursor or pixmap.
+ public const ulong None = 0;
+
+ /// X.h's CurrentTime: "whatever the server's clock says when it reads this".
+ public const ulong CurrentTime = 0;
+
+ /// Inherit the parent's visual/depth in XCreateWindow.
+ public const int CopyFromParent = 0;
+
+ /// X.h's True. Xlib's Bool is an int, not a C99 _Bool.
+ public const int True = 1;
+
+ /// X.h's False.
+ public const int False = 0;
+
+ /// Match any property type in XGetWindowProperty.
+ public const ulong AnyPropertyType = 0;
+
+ ///
+ /// X.h's Success: what XGetWindowProperty returns when it worked. It is zero,
+ /// so the reflex "non-zero means success" has this exactly backwards.
+ ///
+ public const int Success = 0;
+
+ // ---- Predefined atoms (Xatom.h) -------------------------------------------------------------
+ // The first few atom ids are fixed by the protocol itself rather than interned, which is why they can
+ // be written as constants at all. Only the handful that property reads and writes here name.
+
+ /// Xatom.h's XA_ATOM - the type of a property holding atoms, e.g. _NET_WM_STATE.
+ public const ulong XA_ATOM = 4;
+
+ /// Xatom.h's XA_CARDINAL - the type of a property holding unsigned numbers,
+ /// e.g. _NET_FRAME_EXTENTS.
+ public const ulong XA_CARDINAL = 6;
+
+ /// Xatom.h's XA_STRING - Latin-1 text, which is all WM_NAME can carry;
+ /// anything outside it needs _NET_WM_NAME as UTF8_STRING instead.
+ public const ulong XA_STRING = 31;
+
+ // ---- Event types (X.h) ----------------------------------------------------------------------
+ // The value in XEvent.type. 0 and 1 are not events: the protocol reserves them for error and reply
+ // replies, which never reach an application through the event queue.
+
+ public const int KeyPress = 2;
+ public const int KeyRelease = 3;
+ public const int ButtonPress = 4;
+ public const int ButtonRelease = 5;
+ public const int MotionNotify = 6;
+ public const int EnterNotify = 7;
+ public const int LeaveNotify = 8;
+ public const int FocusIn = 9;
+ public const int FocusOut = 10;
+ public const int KeymapNotify = 11;
+ public const int Expose = 12;
+ public const int GraphicsExpose = 13;
+ public const int NoExpose = 14;
+ public const int VisibilityNotify = 15;
+ public const int CreateNotify = 16;
+ public const int DestroyNotify = 17;
+ public const int UnmapNotify = 18;
+ public const int MapNotify = 19;
+ public const int MapRequest = 20;
+ public const int ReparentNotify = 21;
+ public const int ConfigureNotify = 22;
+ public const int ConfigureRequest = 23;
+ public const int GravityNotify = 24;
+ public const int ResizeRequest = 25;
+ public const int CirculateNotify = 26;
+ public const int CirculateRequest = 27;
+ public const int PropertyNotify = 28;
+ public const int SelectionClear = 29;
+ public const int SelectionRequest = 30;
+ public const int SelectionNotify = 31;
+ public const int ColormapNotify = 32;
+ public const int ClientMessage = 33;
+ public const int MappingNotify = 34;
+ public const int GenericEvent = 35;
+
+ // ---- Event masks (X.h) ----------------------------------------------------------------------
+ // What XSelectInput is told to deliver. A `long` because that is what XSelectInput takes, and on
+ // LP64 that is 64 bits even though only the low 25 are defined.
+
+ public const long NoEventMask = 0L;
+ public const long KeyPressMask = 1L << 0;
+ public const long KeyReleaseMask = 1L << 1;
+ public const long ButtonPressMask = 1L << 2;
+ public const long ButtonReleaseMask = 1L << 3;
+ public const long EnterWindowMask = 1L << 4;
+ public const long LeaveWindowMask = 1L << 5;
+ public const long PointerMotionMask = 1L << 6;
+
+ /// Asks the server to compress motion into one event plus a "there is more" hint. Not used
+ /// here: a 3D viewport wants every sample it can get, and compression is what makes a drag stutter.
+ public const long PointerMotionHintMask = 1L << 7;
+
+ public const long Button1MotionMask = 1L << 8;
+ public const long Button2MotionMask = 1L << 9;
+ public const long Button3MotionMask = 1L << 10;
+ public const long Button4MotionMask = 1L << 11;
+ public const long Button5MotionMask = 1L << 12;
+ public const long ButtonMotionMask = 1L << 13;
+ public const long KeymapStateMask = 1L << 14;
+ public const long ExposureMask = 1L << 15;
+ public const long VisibilityChangeMask = 1L << 16;
+ public const long StructureNotifyMask = 1L << 17;
+ public const long ResizeRedirectMask = 1L << 18;
+ public const long SubstructureNotifyMask = 1L << 19;
+ public const long SubstructureRedirectMask = 1L << 20;
+ public const long FocusChangeMask = 1L << 21;
+ public const long PropertyChangeMask = 1L << 22;
+ public const long ColormapChangeMask = 1L << 23;
+ public const long OwnerGrabButtonMask = 1L << 24;
+
+ // ---- Pointer buttons (X.h) ------------------------------------------------------------------
+ // X11 has no separate wheel event: a detent is a ButtonPress/ButtonRelease pair on a synthetic
+ // button, which is why a wheel-only device still reports "buttons".
+
+ public const uint Button1 = 1; // left
+ public const uint Button2 = 2; // middle
+ public const uint Button3 = 3; // right
+ public const uint Button4 = 4; // wheel up
+ public const uint Button5 = 5; // wheel down
+ public const uint Button6 = 6; // wheel/tilt left
+ public const uint Button7 = 7; // wheel/tilt right
+
+ // ---- Key/button state modifier masks (X.h) --------------------------------------------------
+ // The `state` field of a key, button, motion or crossing event. It is the state *before* the event,
+ // so a ShiftMask on a KeyPress of Shift itself is absent, and present on the matching KeyRelease.
+
+ public const uint ShiftMask = 1 << 0;
+ public const uint LockMask = 1 << 1; // Caps Lock
+ public const uint ControlMask = 1 << 2;
+ public const uint Mod1Mask = 1 << 3; // conventionally Alt
+ public const uint Mod2Mask = 1 << 4; // conventionally Num Lock
+ public const uint Mod3Mask = 1 << 5;
+ public const uint Mod4Mask = 1 << 6; // conventionally Super / the Windows key
+ public const uint Mod5Mask = 1 << 7; // conventionally AltGr
+
+ /// Every modifier bit, and no button bit. What separates the two halves of a state word:
+ /// the low eight bits are modifiers, bits 8 and up are the buttons currently held.
+ public const uint AllModifierMask = ShiftMask | LockMask | ControlMask | Mod1Mask | Mod2Mask | Mod3Mask | Mod4Mask | Mod5Mask;
+
+ public const uint Button1Mask = 1 << 8;
+ public const uint Button2Mask = 1 << 9;
+ public const uint Button3Mask = 1 << 10;
+ public const uint Button4Mask = 1 << 11;
+ public const uint Button5Mask = 1 << 12;
+
+ // ---- Crossing and focus event modes (X.h) ---------------------------------------------------
+ // The `mode` field of an EnterNotify, LeaveNotify, FocusIn or FocusOut. Only NotifyNormal is the
+ // pointer (or the focus) actually having moved: a grab and its release each manufacture a crossing
+ // pair "as if the pointer warped", so a host that believes every LeaveNotify fires its pointer-gone
+ // sentinel every time it grabs for a drag - which is the X11 spelling of the cursor-rect artifact
+ // the mac host's IsRealPointerExit exists for.
+
+ public const int NotifyNormal = 0;
+ public const int NotifyGrab = 1;
+ public const int NotifyUngrab = 2;
+ public const int NotifyWhileGrabbed = 3;
+
+ // ---- Crossing and focus event details (X.h) -------------------------------------------------
+ // The `detail` field, which says where the other end of the transition sat in the window hierarchy.
+ // The first five describe a focus that moved between real windows. The last three do not, and that
+ // is what makes them worth naming: Pointer and PointerRoot are what a focus-follows-mouse desktop
+ // sends as the pointer crosses a window while the focus is PointerRoot - they mean "the keyboard
+ // goes wherever the pointer is", not "you have lost it" - and DetailNone is the focus becoming None.
+ // Acting on those releases the held modifiers in the middle of a drag whose pointer merely passed
+ // over another window, which is the latched-modifier bug the focus handlers exist to prevent,
+ // arriving by the opposite route.
+
+ public const int NotifyAncestor = 0;
+ public const int NotifyVirtual = 1;
+ public const int NotifyInferior = 2;
+ public const int NotifyNonlinear = 3;
+ public const int NotifyNonlinearVirtual = 4;
+ public const int NotifyPointer = 5;
+ public const int NotifyPointerRoot = 6;
+ public const int NotifyDetailNone = 7;
+
+ // ---- Keysyms (keysymdef.h) ------------------------------------------------------------------
+ // A keysym is the *symbol* the key produces under the active layout, which is what agg's Keys maps
+ // onto - the raw keycode is a hardware position and differs per keyboard.
+ //
+ // Latin-1 needs no table: keysyms 0x0020-0x00FF are exactly their ISO 8859-1 code points, so
+ // XK_space is 0x20, XK_0-XK_9 are 0x30-0x39, XK_A-XK_Z are 0x41-0x5A and XK_a-XK_z are 0x61-0x7A.
+ // The anchors below exist so a range check can be written without a magic number.
+
+ public const ulong XK_space = 0x0020;
+ public const ulong XK_0 = 0x0030;
+ public const ulong XK_9 = 0x0039;
+ public const ulong XK_A = 0x0041;
+ public const ulong XK_Z = 0x005A;
+ public const ulong XK_a = 0x0061;
+ public const ulong XK_z = 0x007A;
+
+ // Function and editing keys. Most live in the 0xFF00 "keyboard function" page, but not all of them:
+ // the ISO keysyms sit one page below in 0xFE00, and XK_ISO_Left_Tab in particular is not an obscure
+ // corner - it is what an ordinary Shift+Tab produces. What the two pages do share is that neither
+ // can collide with the Latin-1 range above.
+ public const ulong XK_ISO_Left_Tab = 0xFE20;
+
+ public const ulong XK_BackSpace = 0xFF08;
+ public const ulong XK_Tab = 0xFF09;
+ public const ulong XK_Return = 0xFF0D;
+ public const ulong XK_Pause = 0xFF13;
+ public const ulong XK_Scroll_Lock = 0xFF14;
+ public const ulong XK_Escape = 0xFF1B;
+ public const ulong XK_Home = 0xFF50;
+ public const ulong XK_Left = 0xFF51;
+ public const ulong XK_Up = 0xFF52;
+ public const ulong XK_Right = 0xFF53;
+ public const ulong XK_Down = 0xFF54;
+ public const ulong XK_Page_Up = 0xFF55; // XK_Prior in older headers
+ public const ulong XK_Page_Down = 0xFF56; // XK_Next in older headers
+ public const ulong XK_End = 0xFF57;
+ public const ulong XK_Begin = 0xFF58;
+ public const ulong XK_Print = 0xFF61;
+ public const ulong XK_Insert = 0xFF63;
+ public const ulong XK_Menu = 0xFF67;
+ public const ulong XK_Num_Lock = 0xFF7F;
+ public const ulong XK_Delete = 0xFFFF;
+
+ // Keypad. Reported as the *_KP_ symbols only when Num Lock is off or the layout says so; with Num
+ // Lock on the digits arrive as XK_KP_0..XK_KP_9 instead of the navigation names.
+ public const ulong XK_KP_Space = 0xFF80;
+ public const ulong XK_KP_Tab = 0xFF89;
+ public const ulong XK_KP_Enter = 0xFF8D;
+ public const ulong XK_KP_Home = 0xFF95;
+ public const ulong XK_KP_Left = 0xFF96;
+ public const ulong XK_KP_Up = 0xFF97;
+ public const ulong XK_KP_Right = 0xFF98;
+ public const ulong XK_KP_Down = 0xFF99;
+ public const ulong XK_KP_Page_Up = 0xFF9A;
+ public const ulong XK_KP_Page_Down = 0xFF9B;
+ public const ulong XK_KP_End = 0xFF9C;
+ public const ulong XK_KP_Begin = 0xFF9D;
+ public const ulong XK_KP_Insert = 0xFF9E;
+ public const ulong XK_KP_Delete = 0xFF9F;
+ public const ulong XK_KP_Multiply = 0xFFAA;
+ public const ulong XK_KP_Add = 0xFFAB;
+ public const ulong XK_KP_Separator = 0xFFAC;
+ public const ulong XK_KP_Subtract = 0xFFAD;
+ public const ulong XK_KP_Decimal = 0xFFAE;
+ public const ulong XK_KP_Divide = 0xFFAF;
+ public const ulong XK_KP_0 = 0xFFB0;
+ public const ulong XK_KP_1 = 0xFFB1;
+ public const ulong XK_KP_2 = 0xFFB2;
+ public const ulong XK_KP_3 = 0xFFB3;
+ public const ulong XK_KP_4 = 0xFFB4;
+ public const ulong XK_KP_5 = 0xFFB5;
+ public const ulong XK_KP_6 = 0xFFB6;
+ public const ulong XK_KP_7 = 0xFFB7;
+ public const ulong XK_KP_8 = 0xFFB8;
+ public const ulong XK_KP_9 = 0xFFB9;
+
+ /// Out of numeric order with the operators above because keysymdef.h puts it here, past the
+ /// digits and immediately before F1 - it was added to the keypad block long after the rest.
+ public const ulong XK_KP_Equal = 0xFFBD;
+
+ // F1..F12 are contiguous from 0xFFBE, so a loop can walk them.
+ public const ulong XK_F1 = 0xFFBE;
+ public const ulong XK_F2 = 0xFFBF;
+ public const ulong XK_F3 = 0xFFC0;
+ public const ulong XK_F4 = 0xFFC1;
+ public const ulong XK_F5 = 0xFFC2;
+ public const ulong XK_F6 = 0xFFC3;
+ public const ulong XK_F7 = 0xFFC4;
+ public const ulong XK_F8 = 0xFFC5;
+ public const ulong XK_F9 = 0xFFC6;
+ public const ulong XK_F10 = 0xFFC7;
+ public const ulong XK_F11 = 0xFFC8;
+ public const ulong XK_F12 = 0xFFC9;
+
+ // Modifier keys themselves. A bare modifier still produces a KeyPress/KeyRelease pair on X11 (unlike
+ // AppKit's separate FlagsChanged), so these are how Keyboard's down-state is kept honest.
+ public const ulong XK_Shift_L = 0xFFE1;
+ public const ulong XK_Shift_R = 0xFFE2;
+ public const ulong XK_Control_L = 0xFFE3;
+ public const ulong XK_Control_R = 0xFFE4;
+ public const ulong XK_Caps_Lock = 0xFFE5;
+ public const ulong XK_Meta_L = 0xFFE7;
+ public const ulong XK_Meta_R = 0xFFE8;
+ public const ulong XK_Alt_L = 0xFFE9;
+ public const ulong XK_Alt_R = 0xFFEA;
+ public const ulong XK_Super_L = 0xFFEB;
+ public const ulong XK_Super_R = 0xFFEC;
+
+ // ---- Font cursor shapes (cursorfont.h) ------------------------------------------------------
+ // XC_ ids index the "cursor" font. They are always even, because each glyph is a source/mask pair.
+
+ public const uint XC_arrow = 2;
+ public const uint XC_bottom_left_corner = 12;
+ public const uint XC_bottom_right_corner = 14;
+ public const uint XC_crosshair = 34;
+ public const uint XC_fleur = 52;
+ public const uint XC_hand2 = 60;
+ public const uint XC_question_arrow = 92;
+ public const uint XC_sb_h_double_arrow = 108;
+ public const uint XC_sb_v_double_arrow = 116;
+ public const uint XC_top_left_corner = 134;
+ public const uint XC_top_right_corner = 136;
+ public const uint XC_watch = 150;
+ public const uint XC_xterm = 152;
+
+ // ---- XCreateWindow attribute mask bits (X.h) ------------------------------------------------
+ // Which fields of XSetWindowAttributes the server should read. A field left out of the mask keeps
+ // its default no matter what the struct holds, which makes a forgotten mask bit silent.
+
+ public const ulong CWBackPixmap = 1UL << 0;
+ public const ulong CWBackPixel = 1UL << 1;
+ public const ulong CWBorderPixmap = 1UL << 2;
+ public const ulong CWBorderPixel = 1UL << 3;
+ public const ulong CWBitGravity = 1UL << 4;
+ public const ulong CWWinGravity = 1UL << 5;
+ public const ulong CWBackingStore = 1UL << 6;
+ public const ulong CWBackingPlanes = 1UL << 7;
+ public const ulong CWBackingPixel = 1UL << 8;
+ public const ulong CWOverrideRedirect = 1UL << 9;
+ public const ulong CWSaveUnder = 1UL << 10;
+ public const ulong CWEventMask = 1UL << 11;
+ public const ulong CWDontPropagate = 1UL << 12;
+ public const ulong CWColormap = 1UL << 13;
+ public const ulong CWCursor = 1UL << 14;
+
+ // ---- Window classes (X.h) -------------------------------------------------------------------
+
+ public const uint InputOutput = 1;
+ public const uint InputOnly = 2;
+
+ // ---- XChangeProperty modes (X.h) ------------------------------------------------------------
+
+ public const int PropModeReplace = 0;
+ public const int PropModePrepend = 1;
+ public const int PropModeAppend = 2;
+
+ // ---- PropertyNotify state (X.h) -------------------------------------------------------------
+ // Which half of a property's life the event is reporting. An INCR reader cares only about
+ // PropertyNewValue: the deletes it sees are the echoes of its own XDeleteProperty acknowledgements,
+ // and acting on those would read every chunk twice.
+
+ public const int PropertyNewValue = 0;
+ public const int PropertyDelete = 1;
+
+ // ---- Grab modes and results (X.h) -----------------------------------------------------------
+
+ ///
+ /// The grabbed device is frozen after every event until the client calls XAllowEvents to let
+ /// the next one through. That per-event hand-back is a deadlock waiting to happen in a
+ /// single-threaded pump, so a pointer grab here wants .
+ ///
+ public const int GrabModeSync = 0;
+
+ /// The device keeps delivering events for the whole grab, with no hand-back. What a drag
+ /// capture needs.
+ public const int GrabModeAsync = 1;
+
+ /// XGrabPointer's success return; every other value is a refusal.
+ public const int GrabSuccess = 0;
+
+ // ---- XSetInputFocus revert-to (X.h) ---------------------------------------------------------
+
+ public const int RevertToNone = 0;
+ public const int RevertToPointerRoot = 1;
+ public const int RevertToParent = 2;
+
+ // ---- EWMH (_NET_WM_STATE client messages) ---------------------------------------------------
+ // Not X11 at all: the freedesktop.org window-manager conventions, which is how a client asks to be
+ // maximized. There is no Xlib call for it, and which mechanism applies is decided by map state:
+ // before the window is mapped the _NET_WM_STATE property itself is the request, and afterwards the
+ // manager owns that property and the request has to be a ClientMessage to the root window, where
+ // the manager is the one listening. The values below belong to that second, post-map form.
+
+ public const long NetWmStateRemove = 0;
+ public const long NetWmStateAdd = 1;
+ public const long NetWmStateToggle = 2;
+
+ /// The source indication every EWMH message carries: 1 means "a normal application
+ /// asked", which is what a window manager honours. 0 is the legacy "unknown" and some managers
+ /// ignore it outright.
+ public const long NetWmSourceApplication = 1;
+
+ // ---- XSizeHints flags (Xutil.h) -------------------------------------------------------------
+ // The window manager reads these; without the matching flag bit the corresponding field is ignored,
+ // which is how a minimum size silently fails to be honoured.
+
+ public const long USPosition = 1L << 0;
+ public const long USSize = 1L << 1;
+ public const long PPosition = 1L << 2;
+ public const long PSize = 1L << 3;
+ public const long PMinSize = 1L << 4;
+ public const long PMaxSize = 1L << 5;
+ public const long PResizeInc = 1L << 6;
+ public const long PAspect = 1L << 7;
+ public const long PBaseSize = 1L << 8;
+ public const long PWinGravity = 1L << 9;
+ }
+}
diff --git a/PlatformLinux/linux/X11Selection.cs b/PlatformLinux/linux/X11Selection.cs
new file mode 100644
index 000000000..da49e82c7
--- /dev/null
+++ b/PlatformLinux/linux/X11Selection.cs
@@ -0,0 +1,1376 @@
+/*
+Copyright (c) 2026, Lars Brubaker
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+1. Redistributions of source code must retain the above copyright notice, this
+ list of conditions and the following disclaimer.
+2. Redistributions in binary form must reproduce the above copyright notice,
+ this list of conditions and the following disclaimer in the documentation
+ and/or other materials provided with the distribution.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
+ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
+ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+The views and conclusions contained in the software and documentation are those
+of the authors and should not be interpreted as representing official policies,
+either expressed or implied, of the FreeBSD Project.
+*/
+
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.IO;
+using System.Runtime.InteropServices;
+using System.Text;
+using MatterHackers.Agg.UI;
+
+namespace MatterHackers.Agg.Platform.Linux
+{
+ /// The atoms one speaks, interned once per display.
+ ///
+ /// A struct rather than fields on so the encoding and target-choosing logic
+ /// can be pure functions of "these atom ids" and be tested without an X server: an atom is only ever an
+ /// opaque id compared for equality, so a test can invent its own set and the code cannot tell.
+ ///
+ internal struct X11SelectionAtoms
+ {
+ /// The CLIPBOARD selection - the one Ctrl+C writes. Not PRIMARY, which is the
+ /// select-to-copy middle-click one and a different user gesture entirely.
+ public ulong Clipboard;
+
+ /// The meta-target every owner must answer: "what can you convert to?".
+ public ulong Targets;
+
+ public ulong Utf8String;
+
+ /// Xatom.h's XA_STRING: Latin-1, and only Latin-1.
+ public ulong String;
+
+ /// ICCCM's TEXT - "text in whatever encoding you like, tell me which".
+ public ulong Text;
+
+ /// The MIME spelling of UTF-8 text, which is what GTK and Qt ask for first.
+ public ulong TextPlainUtf8;
+
+ public ulong TextHtml;
+
+ /// The type that means "this property is not the data, it is a transfer about to
+ /// happen" - see .
+ public ulong Incr;
+
+ /// The private property on our own window that conversions are delivered into.
+ public ulong Property;
+ }
+
+ ///
+ /// The CLIPBOARD selection, which on X11 is not storage but a conversation: there is no clipboard
+ /// daemon in the protocol, so the owning client is the clipboard and must answer
+ /// SelectionRequest events for as long as it holds the claim. This owns the claim, the hidden
+ /// window it is made from, and both sides of the conversation.
+ ///
+ ///
+ ///
+ /// The hidden window. A selection owner is a window, and a conversion result is delivered as a
+ /// property on a window, so both need one. It is deliberately not one of the application's real
+ /// windows: a real window comes and goes with the UI, and a clipboard that stops working because a
+ /// dialog closed is worse than no clipboard. A 1x1 unmapped InputOnly window costs nothing, is
+ /// never seen, and lives as long as the display does.
+ ///
+ ///
+ /// Timestamps. Every claim here uses CurrentTime rather than the timestamp of the key
+ /// press that caused it, because does not keep a last-event time. ICCCM
+ /// asks for the real event time so that two clients racing for the selection resolve in the order the
+ /// user acted; with CurrentTime the server substitutes its own clock, so the loser of such a
+ /// race is whoever the scheduler ran second rather than whoever the user asked second. That is a real
+ /// difference and a rare one - it needs two clipboard writes inside a scheduling quantum - and closing
+ /// it means threading an event time out of the input path, which is its own change.
+ ///
+ ///
+ /// Re-entrancy, and why input is held back. A paste is a round trip through the X server and
+ /// this host has one thread, so runs a nested pump. That pump keeps
+ /// dispatching the events a stalled window must not miss - Expose, ConfigureNotify, the WM protocols -
+ /// but it does not dispatch input: key, button, motion and crossing events are queued by
+ /// and replayed, in order, from the outer
+ /// X11SystemWindow.PumpEvents once the whole clipboard call has unwound. Dispatching them
+ /// inline would break the callers this exists for. InternalTextEditWidget.PasteFromClipboard
+ /// snapshots its text, calls GetText, and writes the result back: a keystroke delivered in the
+ /// middle of that is silently overwritten by the write-back. A ButtonRelease is worse - it would run
+ /// SyncPointerGrab and the mouse-up path underneath an outer OnMouseDown that has not
+ /// returned. Deferral costs a keystroke up to a second of latency against a wedged clipboard owner,
+ /// which is a pause; the alternative is lost input, which is a bug.
+ ///
+ ///
+ /// Threading. Xlib here is single-threaded (see 's remarks), so everything on
+ /// this type belongs to the thread that owns the display. is what keeps a
+ /// call from another thread off this type, and what marshals an off-thread write back onto it.
+ ///
+ ///
+ /// How a conversion request ended, which is not the same question as what it returned.
+ internal enum X11ConversionOutcome
+ {
+ /// The owner produced the target.
+ Answered,
+
+ /// The owner answered, and the answer was "I cannot do that". Fast, and final for that
+ /// target only - the owner is alive and worth asking about others.
+ Refused,
+
+ ///
+ /// Nobody answered inside the timeout. The owner is wedged or gone, and every further target costs
+ /// another full timeout to be told the same nothing - which is why this is worth distinguishing.
+ ///
+ TimedOut,
+ }
+
+ internal sealed unsafe class X11Selection
+ {
+ ///
+ /// How long a paste waits for the owner to answer before giving up. Long enough for a remote X
+ /// connection or a busy owner, short enough that a dead owner - a client that claimed the selection
+ /// and then wedged, which the protocol gives us no way to detect - is a pause and not a hang.
+ ///
+ private const int ConversionTimeoutMilliseconds = 1000;
+
+ ///
+ /// How long an INCR transfer may go without progress before it is abandoned. Per chunk,
+ /// not per transfer: a large paste is legitimately many chunks, and one budget for the whole thing
+ /// would fail an 80MB transfer that is arriving perfectly steadily for no reason but its size.
+ ///
+ private const int IncrChunkTimeoutMilliseconds = 1000;
+
+ /// How long the abort-time drain will keep acknowledging chunks, in total.
+ private const int IncrDrainTimeoutMilliseconds = 500;
+
+ /// And how many, so a sender stuck in a loop cannot hold the drain open.
+ private const int IncrDrainChunkLimit = 4096;
+
+ ///
+ /// How long a TARGETS answer is trusted. There is no "the clipboard changed" event for a selection
+ /// somebody else owns - SelectionClear only fires on the owner losing it - so a short expiry is the
+ /// only invalidation available for a foreign clipboard. Long enough that the
+ /// ContainsText-then-GetText pair a context menu does costs one round trip instead of
+ /// two, short enough that a copy in another window shows up on the next menu.
+ ///
+ private const int TargetsCacheMilliseconds = 250;
+
+ ///
+ /// Bytes of slack left under the connection's maximum request size for XChangeProperty's own
+ /// header and fixed fields. Generous: the header is 24 bytes and the cost of over-reserving is
+ /// nothing.
+ ///
+ private const int ChangePropertyOverheadBytes = 64;
+
+ ///
+ /// The most input events held back across one clipboard round trip. A second of frantic mouse
+ /// motion is a few hundred, so this is far past any real burst; it exists so that a pathological
+ /// wait cannot grow the queue without bound.
+ ///
+ private const int DeferredInputLimit = 4096;
+
+ ///
+ /// Input events that arrived while a conversion was outstanding, waiting to be replayed in order.
+ /// Static because there is one display and one queue: see the class remarks on re-entrancy.
+ ///
+ private static readonly List DeferredInput = new List();
+
+ /// A monotonic clock for the TARGETS cache. One for the process; nothing here is per-call
+ /// timing, only age.
+ private static readonly Stopwatch CacheClock = Stopwatch.StartNew();
+
+ private static X11Selection instance;
+
+ private static bool dispatchingDeferredInput;
+
+ private static bool warnedDeferredInputOverflow;
+
+ private X11SelectionAtoms atoms;
+
+ private ulong window;
+
+ /// Whether we currently hold CLIPBOARD, as far as we have been told.
+ private bool ownsClipboard;
+
+ /// What we last put on the clipboard, and what
+ /// serves. Null html means the HTML flavor is simply not offered.
+ private string ownedText;
+
+ private string ownedHtml;
+
+ ///
+ /// Guards against re-entering itself. The nested pump dispatches to
+ /// widget code, and widget code can ask for the clipboard; without this, a paste inside a paste
+ /// would consume the outer request's answer.
+ ///
+ private bool converting;
+
+ ///
+ /// An INCR transfer was given up on with the sender still mid-stream. See
+ /// for what that costs the next conversion.
+ ///
+ private bool incrTransferAbandoned;
+
+ private ulong[] cachedTargets;
+
+ private bool cachedTargetsValid;
+
+ ///
+ /// Whether the cached TARGETS answer is missing because the owner never replied, as opposed to
+ /// replying that it cannot do TARGETS. The two failures call for opposite responses - see
+ /// .
+ ///
+ private bool cachedTargetsTimedOut;
+
+ private long cachedTargetsAtMilliseconds;
+
+ private X11Selection()
+ {
+ }
+
+ /// The interned atoms, for tests and for 's target checks.
+ internal X11SelectionAtoms Atoms => this.atoms;
+
+ /// Whether this process is the current CLIPBOARD owner.
+ internal bool OwnsClipboard => this.ownsClipboard;
+
+ /// The text we are serving, or null when we are not the owner.
+ internal string OwnedText => this.ownsClipboard ? this.ownedText : null;
+
+ /// The HTML we are serving, or null when we are not the owner or offered none.
+ internal string OwnedHtml => this.ownsClipboard ? this.ownedHtml : null;
+
+ ///
+ /// The selection for this process, or null when X11 cannot be spoken from here - no window has
+ /// opened a display yet, the process is headless, or the caller is not on the thread that owns the
+ /// connection. A null answer is the caller's cue to fall back to in-process behaviour rather than
+ /// an error.
+ ///
+ internal static X11Selection TryGet()
+ {
+ if (!X11SystemWindow.OnDisplayThread)
+ {
+ return null;
+ }
+
+ IntPtr display = X11SystemWindow.SharedDisplay;
+
+ X11Selection selection = instance;
+ if (selection == null)
+ {
+ selection = new X11Selection();
+ if (!selection.Initialize(display))
+ {
+ return null;
+ }
+
+ instance = selection;
+ }
+
+ return selection;
+ }
+
+ ///
+ /// Handles the selection events, which belong to the hidden window and so would be dropped by
+ /// 's per-window routing.
+ ///
+ /// True when the event was ours and must not be routed on.
+ internal static bool TryHandleEvent(ref XEvent nextEvent)
+ {
+ X11Selection selection = instance;
+ if (selection == null || selection.window == X11.None)
+ {
+ return false;
+ }
+
+ switch (nextEvent.Type)
+ {
+ case X11.SelectionRequest:
+ if (nextEvent.As().Owner != selection.window)
+ {
+ return false;
+ }
+
+ try
+ {
+ selection.HandleSelectionRequest(ref nextEvent.As());
+ }
+ catch (Exception ex)
+ {
+ // A requestor left without an answer waits out its own timeout, which is bad; a
+ // throw escaping into the display-wide dispatch would take the event loop down,
+ // which is worse.
+ Console.Error.WriteLine($"X11Selection SelectionRequest handler threw {ex}");
+ }
+
+ return true;
+
+ case X11.SelectionClear:
+ if (nextEvent.As().Window != selection.window)
+ {
+ return false;
+ }
+
+ // The window is ours, so the event is ours to swallow either way - but only a CLIPBOARD
+ // clear drops the clipboard. The same hidden window is the natural owner for any other
+ // selection this host grows later (PRIMARY, or a drag), and treating one of those as a
+ // clipboard loss would silently empty the clipboard when nothing had touched it.
+ if (nextEvent.As().Selection == selection.atoms.Clipboard)
+ {
+ selection.HandleSelectionClear();
+ }
+
+ return true;
+
+ case X11.SelectionNotify:
+ // Only ever reached by an answer to a conversion we already gave up waiting for: the
+ // nested pump in ConvertSelection takes the ones it asked for before dispatching.
+ // Swallowed rather than routed, because no window wants it.
+ return nextEvent.As().Requestor == selection.window;
+
+ case X11.PropertyNotify:
+ // Same: INCR chunks are taken by ReadIncrementally's own pump. Anything reaching here
+ // is a leftover, and the hidden window is not a window any widget owns.
+ return nextEvent.As().Window == selection.window;
+
+ default:
+ return false;
+ }
+ }
+
+ ///
+ /// Replays the input held back across a clipboard round trip, in the order it arrived. Called from
+ /// X11SystemWindow.PumpEvents before it takes anything new off the queue, which is
+ /// what keeps a deferred keystroke ahead of one typed after the paste finished.
+ ///
+ ///
+ /// Deliberately not called from 's own unwind. The point of the
+ /// deferral is that the widget code which asked for the clipboard - a paste that snapshots, reads
+ /// and writes back - has finished, and inside that call it has not.
+ ///
+ internal static void DispatchDeferredInput()
+ {
+ if (DeferredInput.Count == 0 || dispatchingDeferredInput)
+ {
+ return;
+ }
+
+ // A conversion still in flight means the stack that deferred these has not unwound.
+ if (instance != null && instance.converting)
+ {
+ return;
+ }
+
+ dispatchingDeferredInput = true;
+ try
+ {
+ // Taken as a snapshot: a replayed keystroke can start a paste of its own, and the events
+ // that defers are newer than everything here - so they belong after this batch, not
+ // interleaved with it.
+ XEvent[] replay = DeferredInput.ToArray();
+ DeferredInput.Clear();
+
+ for (int i = 0; i < replay.Length; i++)
+ {
+ X11SystemWindow.DispatchEvent(ref replay[i]);
+ }
+ }
+ finally
+ {
+ dispatchingDeferredInput = false;
+ }
+ }
+
+ // -------------------------------------------------------------------------------------------
+ // Pure helpers. No display, no window - see X11SelectionAtoms' remarks for why these can be
+ // tested without an X server, and LinuxClipboardTests for the tests that do.
+ // -------------------------------------------------------------------------------------------
+
+ ///
+ /// Whether an event arriving mid-conversion must be held back rather than dispatched. See the class
+ /// remarks: these are the events that re-enter widget input handling, and the two failures that
+ /// causes - a keystroke overwritten by a paste's write-back, and a mouse-up run underneath an
+ /// unfinished mouse-down.
+ ///
+ internal static bool IsDeferredDuringConversion(int eventType)
+ {
+ switch (eventType)
+ {
+ case X11.KeyPress:
+ case X11.KeyRelease:
+ case X11.ButtonPress:
+ case X11.ButtonRelease:
+ case X11.MotionNotify:
+ case X11.EnterNotify:
+ case X11.LeaveNotify:
+ return true;
+
+ default:
+ // Expose, ConfigureNotify, ClientMessage (WM_DELETE_WINDOW), FocusIn/FocusOut and the
+ // rest keep flowing. They are what a window stalled on a clipboard owner must not miss,
+ // and none of them is an input event a widget can lose the way the set above can.
+ return false;
+ }
+ }
+
+ ///
+ /// What we advertise for TARGETS. Order is not protocol, but it is the order a requestor
+ /// that walks the list sees, so the richest text form comes first.
+ ///
+ internal static ulong[] BuildTargetList(in X11SelectionAtoms atoms, bool hasHtml)
+ {
+ var targets = new List(6)
+ {
+ atoms.Targets,
+ atoms.Utf8String,
+ atoms.TextPlainUtf8,
+ atoms.Text,
+ atoms.String,
+ };
+
+ if (hasHtml)
+ {
+ targets.Add(atoms.TextHtml);
+ }
+
+ return targets.ToArray();
+ }
+
+ ///
+ /// The text targets to ask a foreign owner for, best first. UTF-8 before Latin-1 for the obvious
+ /// reason; TEXT ahead of STRING because it lets the owner pick an encoding and say
+ /// which, and reads whatever it says.
+ ///
+ internal static ulong[] TextTargetPreference(in X11SelectionAtoms atoms)
+ => new[] { atoms.Utf8String, atoms.TextPlainUtf8, atoms.Text, atoms.String };
+
+ ///
+ /// Picks the best text target an owner actually offers, or None when it offers no text at
+ /// all. An owner that has only STRING - an old client, or a minimal one - is still a
+ /// clipboard with text on it, and refusing to look past UTF8_STRING would report it as empty.
+ ///
+ internal static ulong ChooseTextTarget(ulong[] offered, in X11SelectionAtoms atoms)
+ {
+ if (offered == null)
+ {
+ return X11.None;
+ }
+
+ foreach (ulong preferred in TextTargetPreference(atoms))
+ {
+ if (Array.IndexOf(offered, preferred) >= 0)
+ {
+ return preferred;
+ }
+ }
+
+ return X11.None;
+ }
+
+ ///
+ /// Encodes what we own for one requested target.
+ ///
+ ///
+ /// The atom to stamp the property with, which is not always the target: TEXT means "your
+ /// choice of encoding, and say which", so it is answered as UTF8_STRING.
+ ///
+ /// The bytes, or null when the target is one we do not offer and must refuse.
+ internal static byte[] EncodeForTarget(
+ ulong target,
+ string text,
+ string html,
+ in X11SelectionAtoms atoms,
+ out ulong dataType)
+ {
+ if (target == atoms.Utf8String || target == atoms.TextPlainUtf8 || target == atoms.Text)
+ {
+ dataType = atoms.Utf8String;
+ return Encoding.UTF8.GetBytes(text ?? string.Empty);
+ }
+
+ if (target == atoms.String)
+ {
+ // XA_STRING is Latin-1 by definition. Encoding.Latin1 substitutes '?' for anything outside
+ // it, which is the lossy answer the target asked for - a requestor that wanted the accents
+ // should have asked for UTF8_STRING, and every modern one does.
+ dataType = atoms.String;
+ return Encoding.Latin1.GetBytes(text ?? string.Empty);
+ }
+
+ if (target == atoms.TextHtml && html != null)
+ {
+ dataType = atoms.TextHtml;
+ return Encoding.UTF8.GetBytes(html);
+ }
+
+ dataType = X11.None;
+ return null;
+ }
+
+ ///
+ /// Decodes a text property by the type the owner stamped on it. Anything that is not explicitly
+ /// Latin-1 is read as UTF-8, which covers UTF8_STRING and the MIME types, and is the least wrong
+ /// guess for the compound-text encodings this does not implement.
+ ///
+ internal static string DecodeText(byte[] data, ulong type, in X11SelectionAtoms atoms)
+ {
+ if (data == null)
+ {
+ return null;
+ }
+
+ return type == atoms.String
+ ? Encoding.Latin1.GetString(data)
+ : Encoding.UTF8.GetString(data);
+ }
+
+ /// Joins the chunks of an INCR transfer back into the value that was sent.
+ internal static byte[] AssembleChunks(IReadOnlyList chunks)
+ {
+ int total = 0;
+ for (int i = 0; i < chunks.Count; i++)
+ {
+ total += chunks[i].Length;
+ }
+
+ var assembled = new byte[total];
+ int offset = 0;
+ for (int i = 0; i < chunks.Count; i++)
+ {
+ Buffer.BlockCopy(chunks[i], 0, assembled, offset, chunks[i].Length);
+ offset += chunks[i].Length;
+ }
+
+ return assembled;
+ }
+
+ ///
+ /// Reads a format-32 property's items out of the bytes XGetWindowProperty returned. Each
+ /// item is eight bytes and not four: a "format 32" property is unpacked into C longs, and on
+ /// LP64 that is 64 bits even though the wire carried 32.
+ ///
+ internal static ulong[] ParseAtomList(byte[] data)
+ {
+ if (data == null)
+ {
+ return Array.Empty();
+ }
+
+ var atomList = new ulong[data.Length / sizeof(ulong)];
+ for (int i = 0; i < atomList.Length; i++)
+ {
+ atomList[i] = BitConverter.ToUInt64(data, i * sizeof(ulong));
+ }
+
+ return atomList;
+ }
+
+ // -------------------------------------------------------------------------------------------
+ // Owning the selection
+ // -------------------------------------------------------------------------------------------
+
+ ///
+ /// Claims CLIPBOARD and starts serving (and , when
+ /// it is not null) to anyone who asks.
+ ///
+ internal void Claim(string text, string html)
+ {
+ this.ownedText = text;
+ this.ownedHtml = html;
+
+ // Whatever a foreign owner was offering a moment ago is now irrelevant: we are the owner.
+ this.cachedTargetsValid = false;
+
+ IntPtr display = X11SystemWindow.SharedDisplay;
+ if (display == IntPtr.Zero)
+ {
+ return;
+ }
+
+ Xlib.XSetSelectionOwner(display, this.atoms.Clipboard, this.window, X11.CurrentTime);
+
+ // The server is the authority on who owns a selection, and a claim can fail - a window that
+ // has been destroyed, or a race with another client. Believing an unverified claim would make
+ // every later read answer from our own copy while the real clipboard said something else.
+ this.ownsClipboard = Xlib.XGetSelectionOwner(display, this.atoms.Clipboard) == this.window;
+ Xlib.XFlush(display);
+ }
+
+ ///
+ /// Gives up the claim, so the clipboard genuinely holds nothing of ours rather than holding an
+ /// empty string. What SetText(null) means: "there is no text", which is a different
+ /// statement from "the text is empty" and has to reach other clients as one.
+ ///
+ internal void Release()
+ {
+ this.ownedText = null;
+ this.ownedHtml = null;
+ this.cachedTargetsValid = false;
+
+ IntPtr display = X11SystemWindow.SharedDisplay;
+
+ // Only ever released when we hold it. XSetSelectionOwner(..., None) succeeds whoever the owner
+ // is, so doing this unconditionally would empty another application's clipboard.
+ if (display != IntPtr.Zero && this.ownsClipboard)
+ {
+ Xlib.XSetSelectionOwner(display, this.atoms.Clipboard, X11.None, X11.CurrentTime);
+ Xlib.XFlush(display);
+ }
+
+ this.ownsClipboard = false;
+ }
+
+ ///
+ /// What the current owner says it can convert to, or null when there is no owner, it does not
+ /// answer, or it does not implement TARGETS. Cached for
+ /// - see that constant for why an expiry is the only
+ /// invalidation a foreign clipboard offers.
+ ///
+ internal ulong[] RemoteTargets()
+ {
+ long now = CacheClock.ElapsedMilliseconds;
+ if (this.cachedTargetsValid && now - this.cachedTargetsAtMilliseconds < TargetsCacheMilliseconds)
+ {
+ return this.cachedTargets;
+ }
+
+ // The reply's type is XA_ATOM for every well-behaved owner, and a few answer with TARGETS
+ // itself - the same list under a different name - so only the format is worth checking.
+ byte[] data = this.ConvertSelection(this.atoms.Targets, out _, out int format, out X11ConversionOutcome outcome);
+
+ this.cachedTargets = data != null && format == 32 ? ParseAtomList(data) : null;
+ this.cachedTargetsTimedOut = outcome == X11ConversionOutcome.TimedOut;
+
+ // The failure is cached too, and on purpose: against an owner that has wedged, every query
+ // costs a full timeout, and a context menu asking twice would stall for two seconds.
+ this.cachedTargetsAtMilliseconds = CacheClock.ElapsedMilliseconds;
+ this.cachedTargetsValid = true;
+
+ return this.cachedTargets;
+ }
+
+ /// Whether the foreign owner offers text in any spelling we can read.
+ internal bool RemoteHasText()
+ {
+ ulong[] offered = this.RemoteTargets();
+ if (offered != null)
+ {
+ return ChooseTextTarget(offered, this.atoms) != X11.None;
+ }
+
+ if (this.cachedTargetsTimedOut)
+ {
+ // The owner is not answering. There may well be text on its clipboard, but nothing here can
+ // reach it, and saying yes would enable a Paste that then pastes nothing.
+ return false;
+ }
+
+ // It answered, it just does not implement the TARGETS meta-target - which ICCCM requires and a
+ // few minimal clients skip. If somebody owns the clipboard, say so: RemoteText will ask for the
+ // spellings directly, and against a live owner that costs a refusal, not a stall.
+ IntPtr display = X11SystemWindow.SharedDisplay;
+ return display != IntPtr.Zero
+ && Xlib.XGetSelectionOwner(display, this.atoms.Clipboard) != X11.None;
+ }
+
+ ///
+ /// The foreign owner's text, in the best spelling it offers, or null when it has none.
+ ///
+ internal string RemoteText()
+ {
+ ulong[] offered = this.RemoteTargets();
+ if (offered != null)
+ {
+ ulong target = ChooseTextTarget(offered, this.atoms);
+ return target == X11.None ? null : this.RemoteTextForTarget(target);
+ }
+
+ if (this.cachedTargetsTimedOut)
+ {
+ // Nobody answered TARGETS, so nobody will answer four more conversions either - and each
+ // one costs a full timeout. Trying them anyway is how a single wedged clipboard owner turns
+ // one second of frozen UI into five.
+ return null;
+ }
+
+ // A live owner that skips TARGETS. Ask for the spellings in preference order and take the first
+ // that answers; a refusal from a live owner comes straight back.
+ foreach (ulong target in TextTargetPreference(this.atoms))
+ {
+ string got = this.RemoteTextForTarget(target);
+ if (got != null)
+ {
+ return got;
+ }
+ }
+
+ return null;
+ }
+
+ /// Whether the foreign owner offers text/html.
+ ///
+ /// No TARGETS list means no, deliberately - and this is the one place that does not mirror
+ /// the timed-out-versus-refused split makes. That split exists because
+ /// there is a cheap stand-in for "has text": somebody owns the clipboard, so ask for the spellings
+ /// only when the caller actually wants the text. There is no equivalent stand-in for html - owning
+ /// the clipboard says nothing about whether html is among what is offered - so the only way to
+ /// answer this for a live owner that skips TARGETS is to convert text/html in full, which
+ /// against a large document is an INCR transfer of the whole thing to answer a bool, and then a
+ /// second one when is called for real.
+ ///
+ /// The cost of being wrong here is small and one-directional: does probe
+ /// in that case, so html from a TARGETS-less owner is still readable by anyone who asks for it
+ /// directly. Only the capability query understates, and it understates rather than promising html
+ /// that may not be there.
+ ///
+ ///
+ internal bool RemoteHasHtml()
+ {
+ ulong[] offered = this.RemoteTargets();
+ return offered != null && Array.IndexOf(offered, this.atoms.TextHtml) >= 0;
+ }
+
+ /// The foreign owner's HTML, or null when it has none.
+ internal string RemoteHtml()
+ {
+ ulong[] offered = this.RemoteTargets();
+ if (offered == null)
+ {
+ // Same split as RemoteText: a wedged owner is not asked again, a live one that skips
+ // TARGETS is asked directly.
+ return this.cachedTargetsTimedOut ? null : this.RemoteTextForTarget(this.atoms.TextHtml);
+ }
+
+ if (Array.IndexOf(offered, this.atoms.TextHtml) < 0)
+ {
+ // It said what it has, and html is not in it. Asking anyway would cost a round trip to be
+ // told no.
+ return null;
+ }
+
+ return this.RemoteTextForTarget(this.atoms.TextHtml);
+ }
+
+ ///
+ /// Converts CLIPBOARD to one text target and decodes it, or null when the owner refuses, does not
+ /// answer in time, or answers with something that is not a byte property.
+ ///
+ private string RemoteTextForTarget(ulong target)
+ {
+ byte[] data = this.ConvertSelection(target, out ulong type, out int format, out _);
+ if (data == null || format != 8)
+ {
+ return null;
+ }
+
+ return DecodeText(data, type, this.atoms);
+ }
+
+ ///
+ /// Creates the hidden window and interns the atoms. False when the display went away underneath,
+ /// which leaves unset so the next call can try again.
+ ///
+ private bool Initialize(IntPtr display)
+ {
+ this.atoms = new X11SelectionAtoms
+ {
+ Clipboard = Xlib.XInternAtom(display, "CLIPBOARD", 0),
+ Targets = Xlib.XInternAtom(display, "TARGETS", 0),
+ Utf8String = Xlib.XInternAtom(display, "UTF8_STRING", 0),
+ String = X11.XA_STRING,
+ Text = Xlib.XInternAtom(display, "TEXT", 0),
+ TextPlainUtf8 = Xlib.XInternAtom(display, "text/plain;charset=utf-8", 0),
+ TextHtml = Xlib.XInternAtom(display, "text/html", 0),
+ Incr = Xlib.XInternAtom(display, "INCR", 0),
+ Property = Xlib.XInternAtom(display, "AGG_SELECTION", 0),
+ };
+
+ var attributes = new XSetWindowAttributes
+ {
+ // Never managed, never mapped, and never seen - but override-redirect anyway, so no window
+ // manager can take an interest in it.
+ OverrideRedirect = X11.True,
+
+ // PropertyChangeMask is not optional: the receiving half of an INCR transfer is driven
+ // entirely by PropertyNotify on this window, so without it a large paste waits forever.
+ EventMask = X11.PropertyChangeMask,
+ };
+
+ // InputOnly: no pixels, no depth, no visual, and nothing to draw. Depth and visual are
+ // CopyFromParent (0 and NULL), which is the only combination InputOnly accepts.
+ this.window = Xlib.XCreateWindow(
+ display,
+ X11SystemWindow.SharedRootWindow,
+ -10,
+ -10,
+ 1,
+ 1,
+ 0,
+ X11.CopyFromParent,
+ X11.InputOnly,
+ IntPtr.Zero,
+ X11.CWOverrideRedirect | X11.CWEventMask,
+ &attributes);
+
+ return this.window != X11.None;
+ }
+
+ ///
+ /// Answers one request for our clipboard. Every path ends in a SelectionNotify, including refusal:
+ /// a requestor that is never answered blocks on its own timeout, and on most toolkits that is the
+ /// paste menu freezing for a second.
+ ///
+ private void HandleSelectionRequest(ref XSelectionRequestEvent request)
+ {
+ IntPtr display = X11SystemWindow.SharedDisplay;
+
+ // A property of None is an obsolete client from before ICCCM; the convention is to answer on a
+ // property named by the target.
+ ulong property = request.Property == X11.None ? request.Target : request.Property;
+
+ bool answered = display != IntPtr.Zero
+ && this.ownsClipboard
+ && request.Selection == this.atoms.Clipboard
+ && this.WriteRequestedTarget(display, ref request, property);
+
+ var reply = default(XEvent);
+ ref XSelectionEvent notify = ref reply.As();
+ notify.Type = X11.SelectionNotify;
+ notify.Display = display;
+ notify.Requestor = request.Requestor;
+ notify.Selection = request.Selection;
+ notify.Target = request.Target;
+
+ // The property field is the whole answer: it names where the data is, or it is None, which is
+ // how a refusal is spelled. There is no other refusal.
+ notify.Property = answered ? property : X11.None;
+ notify.Time = request.Time;
+
+ if (display != IntPtr.Zero)
+ {
+ // propagate False and an empty mask: ICCCM says a SelectionNotify goes to the requestor
+ // whatever it has selected for, which is what an empty mask means for a sent event.
+ Xlib.XSendEvent(display, request.Requestor, X11.False, X11.NoEventMask, ref reply);
+ Xlib.XFlush(display);
+ }
+ }
+
+ /// Writes the requested target onto the requestor's property. False means "refuse".
+ private bool WriteRequestedTarget(IntPtr display, ref XSelectionRequestEvent request, ulong property)
+ {
+ if (request.Target == this.atoms.Targets)
+ {
+ ulong[] targets = BuildTargetList(this.atoms, this.ownedHtml != null);
+ fixed (ulong* targetData = targets)
+ {
+ Xlib.XChangeProperty(
+ display,
+ request.Requestor,
+ property,
+ X11.XA_ATOM,
+ 32,
+ X11.PropModeReplace,
+ (byte*)targetData,
+ targets.Length);
+ }
+
+ return true;
+ }
+
+ byte[] data = EncodeForTarget(request.Target, this.ownedText, this.ownedHtml, this.atoms, out ulong dataType);
+ if (data == null)
+ {
+ return false;
+ }
+
+ if (data.Length > MaxPropertyBytes(display))
+ {
+ // The sending half of INCR is not implemented: past this size the honest answer is a
+ // refusal rather than a truncated paste. The ceiling is the connection's maximum request
+ // size, which BIG-REQUESTS puts in the megabytes on every server in use - far past any
+ // clipboard text a user produces by hand. The receiving half *is* implemented
+ // (ReadIncrementally), because other applications routinely send that way.
+ Console.Error.WriteLine(
+ $"X11Selection: refusing a {data.Length} byte clipboard conversion; INCR sending is not implemented.");
+ return false;
+ }
+
+ // A zero-length value is legal and meaningful - an empty string was copied - but `fixed` on an
+ // empty array yields a null pointer, so it needs a byte to point at that nothing reads.
+ byte[] pinnable = data.Length > 0 ? data : new byte[1];
+ fixed (byte* payload = pinnable)
+ {
+ Xlib.XChangeProperty(
+ display,
+ request.Requestor,
+ property,
+ dataType,
+ 8,
+ X11.PropModeReplace,
+ payload,
+ data.Length);
+ }
+
+ return true;
+ }
+
+ ///
+ /// Somebody else took the clipboard. Everything we were serving is dropped with the claim, so a
+ /// later read goes back out to the new owner instead of answering from a copy that is now history.
+ ///
+ private void HandleSelectionClear()
+ {
+ this.ownsClipboard = false;
+ this.ownedText = null;
+ this.ownedHtml = null;
+
+ // Whoever took it advertises its own targets, and this is the one moment a foreign clipboard
+ // change is actually announced to us - so it is the one moment the cache can be invalidated
+ // for the right reason rather than by expiry.
+ this.cachedTargetsValid = false;
+ }
+
+ /// The most property data one XChangeProperty can carry on this connection.
+ private static long MaxPropertyBytes(IntPtr display)
+ {
+ long units = Xlib.XExtendedMaxRequestSize(display);
+ if (units == 0)
+ {
+ units = Xlib.XMaxRequestSize(display);
+ }
+
+ return Math.Max(0, (units * 4) - ChangePropertyOverheadBytes);
+ }
+
+ // -------------------------------------------------------------------------------------------
+ // Reading somebody else's selection
+ // -------------------------------------------------------------------------------------------
+
+ ///
+ /// Asks the owner to convert CLIPBOARD to and waits for the answer,
+ /// pumping the event loop while it waits. Null on refusal, timeout, or no owner;
+ /// is how a caller tells those apart.
+ ///
+ private byte[] ConvertSelection(ulong target, out ulong type, out int format, out X11ConversionOutcome outcome)
+ {
+ type = X11.None;
+ format = 0;
+ outcome = X11ConversionOutcome.Refused;
+
+ IntPtr display = X11SystemWindow.SharedDisplay;
+ if (display == IntPtr.Zero || this.window == X11.None || this.converting)
+ {
+ return null;
+ }
+
+ this.converting = true;
+ try
+ {
+ this.PurgeAbandonedTransfer(display);
+
+ // Clear the landing property first: a leftover from a conversion that timed out would
+ // otherwise be read as this one's answer.
+ Xlib.XDeleteProperty(display, this.window, this.atoms.Property);
+
+ Xlib.XConvertSelection(
+ display,
+ this.atoms.Clipboard,
+ target,
+ this.atoms.Property,
+ this.window,
+ X11.CurrentTime);
+ Xlib.XFlush(display);
+
+ var clock = Stopwatch.StartNew();
+ if (!this.PumpForSelectionNotify(display, target, clock, out XSelectionEvent notify))
+ {
+ outcome = X11ConversionOutcome.TimedOut;
+ return null;
+ }
+
+ if (notify.Property == X11.None)
+ {
+ // The owner cannot produce this target. Not an error - it is how "I have no HTML"
+ // is said.
+ return null;
+ }
+
+ // Reading with delete=true is also the INCR handshake: deleting the property is the signal
+ // that starts the transfer, so it has to happen whether or not this turns out to be one.
+ if (!this.TryReadProperty(display, delete: true, out byte[] data, out type, out format))
+ {
+ return null;
+ }
+
+ if (type == this.atoms.Incr)
+ {
+ byte[] whole = this.ReadIncrementally(display, out type, out format);
+ outcome = whole == null ? X11ConversionOutcome.TimedOut : X11ConversionOutcome.Answered;
+ return whole;
+ }
+
+ outcome = X11ConversionOutcome.Answered;
+ return data;
+ }
+ finally
+ {
+ this.converting = false;
+ }
+ }
+
+ ///
+ /// Takes the chunks of an INCR transfer. The owner sends a value too large for one request as a
+ /// series of properties, one at a time, each announced by a PropertyNotify and acknowledged by our
+ /// deleting it; a zero-length property ends the sequence.
+ ///
+ ///
+ /// The timeout is per chunk and is restarted by every chunk that arrives, so the budget measures
+ /// progress and not size. One budget for the whole transfer would abandon a large paste
+ /// that was arriving perfectly steadily, purely for being large.
+ ///
+ private byte[] ReadIncrementally(IntPtr display, out ulong type, out int format)
+ {
+ type = X11.None;
+ format = 0;
+
+ var chunks = new List();
+ var chunkClock = Stopwatch.StartNew();
+
+ while (true)
+ {
+ if (!this.PumpForPropertyNotify(display, chunkClock, IncrChunkTimeoutMilliseconds))
+ {
+ this.AbandonIncrTransfer(display, chunks.Count, "the sender stopped mid-transfer");
+ return null;
+ }
+
+ if (!this.TryReadProperty(display, delete: true, out byte[] chunk, out ulong chunkType, out int chunkFormat))
+ {
+ this.AbandonIncrTransfer(display, chunks.Count, "a chunk could not be read");
+ return null;
+ }
+
+ if (chunk.Length == 0)
+ {
+ // The terminator. Its type says nothing, so the type from the chunks is what stands.
+ return AssembleChunks(chunks);
+ }
+
+ type = chunkType;
+ format = chunkFormat;
+ chunks.Add(chunk);
+
+ // Progress. The next chunk gets a full budget of its own.
+ chunkClock.Restart();
+ }
+ }
+
+ ///
+ /// Ends a transfer we have given up on, as tidily as the protocol allows.
+ ///
+ ///
+ ///
+ /// The problem is that our XDeleteProperty is not only a tidy-up, it is the acknowledgement
+ /// the sender waits on - so simply walking away leaves a sender that will push one more chunk onto
+ /// our window the next time the property disappears, and that chunk would be read as the answer to
+ /// whatever conversion comes next.
+ ///
+ ///
+ /// So the abort first drains: it keeps acknowledging, bounded by
+ /// and , in the hope of
+ /// reaching the sender's zero-length terminator - which ends the transfer properly and leaves
+ /// nothing behind. A sender that was merely slow finishes here. Only if the drain also gives up is
+ /// the transfer marked abandoned, and then pays the cost at
+ /// the start of the next conversion.
+ ///
+ ///
+ private void AbandonIncrTransfer(IntPtr display, int chunksTaken, string why)
+ {
+ var overall = Stopwatch.StartNew();
+ var chunkClock = Stopwatch.StartNew();
+
+ for (int drained = 0; drained < IncrDrainChunkLimit; drained++)
+ {
+ if (overall.ElapsedMilliseconds >= IncrDrainTimeoutMilliseconds)
+ {
+ break;
+ }
+
+ int remaining = (int)Math.Max(1, IncrDrainTimeoutMilliseconds - overall.ElapsedMilliseconds);
+ if (!this.PumpForPropertyNotify(display, chunkClock, remaining))
+ {
+ break;
+ }
+
+ if (!this.TryReadProperty(display, delete: true, out byte[] chunk, out _, out _))
+ {
+ break;
+ }
+
+ if (chunk.Length == 0)
+ {
+ // The sender finished after all. Nothing is left on the window and nothing is owed.
+ return;
+ }
+
+ chunkClock.Restart();
+ }
+
+ this.incrTransferAbandoned = true;
+ Console.Error.WriteLine(
+ $"X11Selection: abandoned an INCR clipboard transfer after {chunksTaken} chunks ({why}); "
+ + "the next paste will clear the leftover first.");
+ }
+
+ ///
+ /// Clears the wreckage of an abandoned INCR transfer before a new conversion is started.
+ ///
+ ///
+ /// Deleting the property is also the acknowledgement a still-running sender is waiting for, so the
+ /// delete is done twice with an XSync between: the first lets the straggler chunk be
+ /// written, the sync makes that round trip happen now rather than interleaved with the
+ /// conversion about to start, and the second removes it. A sender that keeps going past that is
+ /// indistinguishable from a hostile one; the residual risk is one stale chunk landing on the
+ /// property before the new owner's reply overwrites it (PropModeReplace), which is why the
+ /// answer is only ever read after its own SelectionNotify has arrived.
+ ///
+ private void PurgeAbandonedTransfer(IntPtr display)
+ {
+ if (!this.incrTransferAbandoned)
+ {
+ return;
+ }
+
+ Xlib.XDeleteProperty(display, this.window, this.atoms.Property);
+ Xlib.XSync(display, X11.False);
+ Xlib.XDeleteProperty(display, this.window, this.atoms.Property);
+ Xlib.XSync(display, X11.False);
+
+ this.incrTransferAbandoned = false;
+ }
+
+ ///
+ /// Runs a nested event pump until the SelectionNotify we asked for arrives or the clock runs out.
+ /// Non-input events are dispatched; input is held back - see the class remarks on re-entrancy.
+ ///
+ private bool PumpForSelectionNotify(IntPtr display, ulong target, Stopwatch clock, out XSelectionEvent notify)
+ {
+ notify = default;
+
+ while (clock.ElapsedMilliseconds < ConversionTimeoutMilliseconds)
+ {
+ while (Xlib.XPending(display) > 0)
+ {
+ Xlib.XNextEvent(display, out XEvent nextEvent);
+
+ if (nextEvent.Type == X11.SelectionNotify
+ && nextEvent.As().Requestor == this.window
+ && nextEvent.As().Selection == this.atoms.Clipboard
+ && nextEvent.As().Target == target)
+ {
+ notify = nextEvent.As();
+ return true;
+ }
+
+ DispatchOrDefer(ref nextEvent);
+ }
+
+ X11SystemWindow.WaitForEvents(1);
+ }
+
+ return false;
+ }
+
+ ///
+ /// Runs the same nested pump until the next chunk of an INCR transfer is announced. Only
+ /// PropertyNewValue counts: the deletes are the echoes of our own acknowledgements.
+ ///
+ private bool PumpForPropertyNotify(IntPtr display, Stopwatch clock, int timeoutMilliseconds)
+ {
+ while (clock.ElapsedMilliseconds < timeoutMilliseconds)
+ {
+ while (Xlib.XPending(display) > 0)
+ {
+ Xlib.XNextEvent(display, out XEvent nextEvent);
+
+ if (nextEvent.Type == X11.PropertyNotify
+ && nextEvent.As().Window == this.window
+ && nextEvent.As().Atom == this.atoms.Property
+ && nextEvent.As().State == X11.PropertyNewValue)
+ {
+ return true;
+ }
+
+ DispatchOrDefer(ref nextEvent);
+ }
+
+ X11SystemWindow.WaitForEvents(1);
+ }
+
+ return false;
+ }
+
+ ///
+ /// One event that is not the answer we are waiting for: dispatched if the application can safely
+ /// see it now, queued for replay if it is input. See .
+ ///
+ private static void DispatchOrDefer(ref XEvent nextEvent)
+ {
+ if (!IsDeferredDuringConversion(nextEvent.Type))
+ {
+ X11SystemWindow.DispatchEvent(ref nextEvent);
+ return;
+ }
+
+ if (DeferredInput.Count < DeferredInputLimit)
+ {
+ DeferredInput.Add(nextEvent);
+ return;
+ }
+
+ if (!warnedDeferredInputOverflow)
+ {
+ warnedDeferredInputOverflow = true;
+ Console.Error.WriteLine(
+ $"X11Selection: more than {DeferredInputLimit} input events arrived during one clipboard "
+ + "round trip; the excess is being dropped.");
+ }
+ }
+
+ ///
+ /// Reads the landing property whole: a zero-length probe to learn the size, then as many reads as
+ /// it takes to exhaust bytesAfter, then the delete.
+ ///
+ ///
+ /// The loop matters because XGetWindowProperty is free to return less than was asked for,
+ /// and a single read that trusts the first bytesAfter silently truncates when it does. The
+ /// delete is deliberately last rather than folded into the reads: on an INCR chunk the delete is
+ /// the acknowledgement that releases the next chunk, so deleting before the current one is fully
+ /// read would throw away the tail.
+ ///
+ private bool TryReadProperty(IntPtr display, bool delete, out byte[] data, out ulong type, out int format)
+ {
+ data = null;
+ type = X11.None;
+ format = 0;
+
+ int status = Xlib.XGetWindowProperty(
+ display,
+ this.window,
+ this.atoms.Property,
+ 0,
+ 0,
+ X11.False,
+ X11.AnyPropertyType,
+ out ulong actualType,
+ out int actualFormat,
+ out ulong itemCount,
+ out ulong bytesAfter,
+ out IntPtr prop);
+
+ // Freed even though nothing was asked for: XGetWindowProperty allocates on every success, and
+ // the zero-length read is the one everybody forgets.
+ if (prop != IntPtr.Zero)
+ {
+ Xlib.XFree(prop);
+ }
+
+ if (status != X11.Success)
+ {
+ return false;
+ }
+
+ type = actualType;
+ format = actualFormat;
+
+ using var assembled = new MemoryStream();
+ long offsetIn32BitUnits = 0;
+
+ while (bytesAfter > 0)
+ {
+ // The offset and the length are both in 32-bit units, whatever the property's own format is.
+ long remaining = (long)((bytesAfter + 3) / 4);
+
+ status = Xlib.XGetWindowProperty(
+ display,
+ this.window,
+ this.atoms.Property,
+ offsetIn32BitUnits,
+ remaining,
+ X11.False,
+ X11.AnyPropertyType,
+ out actualType,
+ out actualFormat,
+ out itemCount,
+ out bytesAfter,
+ out prop);
+
+ if (status != X11.Success)
+ {
+ if (prop != IntPtr.Zero)
+ {
+ Xlib.XFree(prop);
+ }
+
+ return false;
+ }
+
+ type = actualType;
+ format = actualFormat;
+
+ // A format-32 item arrives as a C long, so it is eight bytes wide here and four on the wire.
+ int itemBytes = actualFormat switch
+ {
+ 8 => 1,
+ 16 => 2,
+ 32 => sizeof(ulong),
+ _ => 0,
+ };
+
+ int chunkBytes = (int)itemCount * itemBytes;
+ if (chunkBytes > 0 && prop != IntPtr.Zero)
+ {
+ var buffer = new byte[chunkBytes];
+ Marshal.Copy(prop, buffer, 0, chunkBytes);
+ assembled.Write(buffer, 0, chunkBytes);
+ }
+
+ if (prop != IntPtr.Zero)
+ {
+ Xlib.XFree(prop);
+ }
+
+ // How far along the property this read left us, in the 32-bit units the offset counts. A
+ // read that returned nothing while claiming more is left cannot be made progress on, and
+ // looping on it would spin forever.
+ long consumed = actualFormat == 32
+ ? (long)itemCount
+ : ((long)itemCount * itemBytes) / 4;
+
+ if (consumed <= 0)
+ {
+ break;
+ }
+
+ offsetIn32BitUnits += consumed;
+ }
+
+ if (delete)
+ {
+ Xlib.XDeleteProperty(display, this.window, this.atoms.Property);
+ }
+
+ data = assembled.ToArray();
+ return true;
+ }
+ }
+}
diff --git a/PlatformLinux/linux/X11SystemWindow.cs b/PlatformLinux/linux/X11SystemWindow.cs
new file mode 100644
index 000000000..23ccf9cb0
--- /dev/null
+++ b/PlatformLinux/linux/X11SystemWindow.cs
@@ -0,0 +1,3536 @@
+/*
+Copyright (c) 2026, Lars Brubaker
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+1. Redistributions of source code must retain the above copyright notice, this
+ list of conditions and the following disclaimer.
+2. Redistributions in binary form must reproduce the above copyright notice,
+ this list of conditions and the following disclaimer in the documentation
+ and/or other materials provided with the distribution.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
+ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
+ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+*/
+
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Text;
+using System.Threading;
+using MatterHackers.Agg.Platform.Linux;
+using MatterHackers.RenderGl;
+using MatterHackers.RenderGl.OpenGl;
+using MatterHackers.VectorMath;
+
+namespace MatterHackers.Agg.UI
+{
+ ///
+ /// The Linux/X11 window host: a plain InputOutput X11 window, a for
+ /// the Vulkan swapchain over it, a over its GL facade for widget paint, and
+ /// one present per frame. The structural counterpart of MacSystemWindow on macOS and of
+ /// WinformsSystemWindow + WebGpuSystemWindow on Windows, with X11 reached through raw
+ /// P/Invoke into libX11.so.6 - no GTK, no SDL, no GLFW.
+ ///
+ ///
+ /// Coordinates and DPI: agg pixels = X pixels. Unlike AppKit (points x backingScaleFactor) and
+ /// unlike a DPI-aware Win32 window, X11 has no logical coordinate system at all. A window's size, an
+ /// event's position and the drawable are all the same pixels, so the conversion this seam has to perform
+ /// is the identity: is the X11 width, and the swapchain is that size.
+ /// What X11 does have is a user scaling preference, which desktop environments record as the
+ /// Xft.dpi X resource; that is read here purely to be reported through
+ /// - it never scales a coordinate. As on macOS,
+ /// GuiWidget.DeviceScale is deliberately not touched: it is a user text-size preference, not a
+ /// DPI factor.
+ ///
+ ///
+ ///
+ /// Y flip. X11's origin is top-left with Y increasing downwards, which is Win32's convention and
+ /// not agg's. Mouse Y therefore has to be flipped on the way in, exactly as WinformsEventSink
+ /// does; that lives with the rest of the input translation (step 3b) and is called out here so the
+ /// absence of a flip in MacSystemWindow is not copied by mistake.
+ ///
+ ///
+ ///
+ /// The loop is ours, and it is also the idle timer. drains the X queue,
+ /// drains the RunOnIdle queue, paints whatever asked to be repainted, and then - having nothing to do -
+ /// sleeps in poll(2) on the X connection for at most . On macOS
+ /// that idle drain has to be a real NSTimer, because AppKit runs nested tracking loops (window
+ /// drag, live resize, menu tracking) in which the host's own pump is frozen and queued layout would never
+ /// run - a window with no idle pump comes up blank. X11 has no such nested native loop: a resize is a
+ /// stream of ConfigureNotify events delivered to this same queue and a menu is drawn by the
+ /// application, so this loop is never frozen and it can be the idle timer itself. The <=4ms poll timeout
+ /// is what makes it one.
+ ///
+ ///
+ ///
+ /// One thread, not "the main thread". Xlib is thread-safe only after XInitThreads, which is
+ /// deliberately not called (see ); in exchange every Xlib call must come from the one
+ /// thread that pumps the connection. That is a weaker rule than AppKit's, which is why
+ /// MainThreadDispatcher.MainThreadRequired is false off macOS and why nothing here is wrapped in
+ /// MainThreadDispatcher.Invoke. still calls
+ /// MainThreadDispatcher.DrainPending, which costs nothing when unhosted and keeps test hosting
+ /// working.
+ ///
+ ///
+ public class X11SystemWindow : IPlatformWindow
+ {
+ ///
+ /// How many pump iterations spins waiting for a capture whose
+ /// read-back suspended. Bounded so a window that never repaints cannot hang the caller; the native
+ /// read-back path completes inline and never reaches the loop.
+ ///
+ private const int ScreenshotPumpSpins = 200;
+
+ ///
+ /// How long is willing to sleep with nothing to do. Doubles as the idle
+ /// tick: see the class remarks for why X11 needs no separate timer. Short enough that input latency
+ /// stays well under a frame, long enough that an idle window is not a CPU spin.
+ ///
+ private const int IdlePumpMilliseconds = 4;
+
+ ///
+ /// How long after a size change another one still counts as part of the same resize burst.
+ ///
+ ///
+ /// A window manager delivers a drag of a window edge as a stream of ConfigureNotify events at
+ /// roughly pointer-report rate - 5 to 16ms apart on everything measured - so 50ms is several times
+ /// the gap this has to bridge while staying far below the pause between two deliberate resizes. The
+ /// original 250ms did not discriminate: it is long enough that the configures a window emits while
+ /// it is being mapped and settled all fall inside one window, which is exactly the case that must
+ /// not paint synchronously. Widening it further trades the same way, which is why the show
+ /// gate in carries that case instead of the timer.
+ ///
+ private const int ResizeBurstMilliseconds = 50;
+
+ /// The DPI X11 and every toolkit on it treat as unscaled.
+ private const double BaselineDpi = 96.0;
+
+ ///
+ /// How long after a press another press on the same button at the same spot still counts as part of
+ /// the same click. 500ms is Win32's GetDoubleClickTime default and X11's own convention -
+ /// there is no server-side setting to ask, so every toolkit hard-codes something near it.
+ ///
+ private const ulong DoubleClickMilliseconds = 500;
+
+ ///
+ /// How far the pointer may drift between two presses and still be the same click, in pixels. A hand
+ /// resting on a mouse moves it a pixel or two between clicks of a real double click; more than this
+ /// and the user meant two clicks in two places.
+ ///
+ private const int DoubleClickSlopPixels = 4;
+
+ ///
+ /// The wheel units one detent is worth. Win32's v120 convention, which every agg consumer was
+ /// written against (MatterCAD's trackball zooms by WheelDelta / 120 steps). X11 has no
+ /// magnitude to carry: a detent is a button press, so it is exactly one notch and nothing else.
+ ///
+ private const int WheelDeltaPerDetent = 120;
+
+ /// The complete set of down-state keys can report, so
+ /// one loop can set and clear all of them.
+ private static readonly Keys[] ModifierStateKeys = { Keys.ShiftKey, Keys.ControlKey, Keys.Menu };
+
+ /// Holding nothing - the starting value for .
+ private static readonly IReadOnlySet NoModifierKeys = new HashSet();
+
+ private static readonly object StaticInitLock = new object();
+
+ /// Every constructed window that has not closed yet, in creation order.
+ private static readonly List LiveWindows = new List();
+
+ /// Font-cursor shape id to the cursor made from it. Cursors are per-display and immutable,
+ /// so one per shape for the life of the process is all that is ever needed.
+ private static readonly Dictionary ResolvedCursors = new Dictionary();
+
+ // --- Unattended smoke runs -------------------------------------------------------------------
+ // Read once, from the environment, because the point is to drive an *unmodified* demo: no demo has
+ // to know it is being smoke tested, and with the variables unset none of this does anything. Kept
+ // byte for byte compatible with the WinForms and mac hosts' versions so one AGG_SMOKE_* invocation
+ // drives any of the three.
+ private static readonly int SmokeFrameTarget = ParseSmokeFrames();
+ private static readonly string SmokeScreenshotPath = Environment.GetEnvironmentVariable("AGG_SMOKE_SCREENSHOT");
+
+ private static System.Threading.Timer smokeExitWatchdog;
+
+ ///
+ /// The one connection to the X server every window in this process shares, opened lazily by the
+ /// first window. One display rather than one per window because the event queue is per connection:
+ /// two connections would need two pumps, and the second window's events would be invisible to the
+ /// loop the first window is running.
+ ///
+ private static IntPtr display;
+
+ private static int screenNumber;
+ private static ulong rootWindow;
+ private static bool displayBootstrapped;
+
+ /// The managed thread that opened , and so the only one allowed to
+ /// use it. -1 until has run.
+ private static int displayThreadId = -1;
+
+ // The interned atoms. All are per-display, so they are interned once alongside it.
+ private static ulong wmProtocolsAtom;
+ private static ulong wmDeleteWindowAtom;
+ private static ulong netWmNameAtom;
+ private static ulong utf8StringAtom;
+ private static ulong netWmStateAtom;
+ private static ulong netWmStateMaximizedHorzAtom;
+ private static ulong netWmStateMaximizedVertAtom;
+ private static ulong netFrameExtentsAtom;
+
+ ///
+ /// The installed protocol-error handler, held in a static field for exactly one reason: nothing on
+ /// the native side roots a managed delegate, and a collected one turns the next BadWindow into a
+ /// jump to freed memory. Same for .
+ ///
+ private static Xlib.XErrorHandler protocolErrorHandler;
+
+ private static Xlib.XIOErrorHandler ioErrorHandler;
+
+ ///
+ /// Whether a window is currently running . This, rather than a
+ /// "first window" latch, is what decides whether a window being shown owns the loop: a latch has to
+ /// be reset between runs and gets the answer wrong for any window shown before the application's
+ /// main one. Same reasoning as MacSystemWindow's.
+ ///
+ private static volatile bool runLoopActive;
+
+ private static bool processingOnIdle;
+
+ private ulong window;
+
+ /// The cursor currently defined on the window, so re-asserting the same one is free.
+ private ulong currentCursor;
+
+ private X11WebGpuLayer webGpuLayer;
+ private SystemWindow aggSystemWindow;
+
+ ///
+ /// The user's display scaling, reported to the application and never applied to a coordinate. See
+ /// the class remarks for why those are two different things on X11.
+ ///
+ private double displayScale = 1;
+
+ private uint pixelWidth = 1;
+ private uint pixelHeight = 1;
+
+ private string caption = string.Empty;
+ private Vector2 minimumSize;
+
+ private bool needsRedraw = true;
+ private bool viewPortHasBeenSet;
+ private bool isInsidePaint;
+ private bool hasClosed;
+
+ /// Set while an X11-initiated close is running, so the agg close does not re-enter it.
+ private bool platformAlreadyClosing;
+
+ ///
+ /// True once has finished mapping and settling this window. Until
+ /// then the configures arriving are the show sequence's own, not a user resizing anything - see
+ /// .
+ ///
+ private bool showCompleted;
+
+ ///
+ /// When the last size change arrived, on 's monotonic clock. See
+ /// and .
+ ///
+ private long lastResizeTimestamp = long.MinValue;
+
+ /// Which buttons this window owns for the duration of a drag; see .
+ private readonly OutOfViewMouseCapture mouseCapture = new OutOfViewMouseCapture();
+
+ /// Turns a stream of button presses into single, double and triple clicks.
+ private readonly ClickCounter clickCounter = new ClickCounter();
+
+ /// What was last told; see .
+ private Keys overrideModifierKeys = Keys.None;
+
+ private bool modifiersOverridden;
+
+ ///
+ /// The modifier keys this window put into 's down state, so focus loss can
+ /// release exactly those and nothing else. See .
+ ///
+ private IReadOnlySet appliedModifierKeys = NoModifierKeys;
+
+ ///
+ /// The modifier half of the last input event's state word, corrected for the event itself
+ /// when that event was a modifier key. What answers from.
+ ///
+ private uint lastModifierState;
+
+ /// Whether is currently held for a drag.
+ private bool pointerGrabbed;
+
+ private int drawCount;
+ private bool smokeRunFinished;
+
+ ///
+ /// A screenshot asked for but not taken yet. The read-back has to happen at the end of a frame,
+ /// so a request made at any other time waits here for one.
+ ///
+ private string pendingScreenshotPath;
+
+ /// Signalled by the paint that performs a queued capture, so the requester can return only
+ /// once the file is on disk.
+ private ManualResetEventSlim screenshotComplete;
+
+ public X11SystemWindow()
+ {
+ BootstrapDisplay();
+
+ lock (StaticInitLock)
+ {
+ LiveWindows.Add(this);
+ }
+ }
+
+ ///
+ /// How many frames a smoke run draws before it screenshots and closes itself
+ /// (AGG_SMOKE_FRAMES), or 0 when the window should behave normally.
+ ///
+ public static int SmokeFrames => SmokeFrameTarget;
+
+ ///
+ /// The shared connection, or when no window has opened one yet. What
+ /// hangs the clipboard off, and what tells it there is no X11 to talk to
+ /// - a headless test process, where the clipboard falls back to in-process behaviour.
+ ///
+ internal static IntPtr SharedDisplay => display;
+
+ /// The root window of the default screen, for a child that belongs to no window of ours.
+ internal static ulong SharedRootWindow => rootWindow;
+
+ ///
+ /// Whether the calling thread is the one that owns the X connection. False when there is no
+ /// connection at all, so it doubles as "is there an X11 to talk to from here".
+ ///
+ internal static bool OnDisplayThread
+ => display != IntPtr.Zero && Environment.CurrentManagedThreadId == displayThreadId;
+
+ ///
+ /// Whether a connection is open at all, from whatever thread. What separates "this process is
+ /// headless, so the in-process clipboard is the whole story" from "there is a real X clipboard and
+ /// this caller merely has to get onto the right thread to reach it" - two cases
+ /// alone cannot tell apart.
+ ///
+ internal static bool HasDisplay => display != IntPtr.Zero;
+
+ ///
+ /// Whether every agg window in the process shares this one native window, dialogs included.
+ ///
+ ///
+ /// What an application shell like MatterCAD runs on. wraps
+ /// everything shown after the first window in a WindowWidget, draws it inside the window
+ /// already on screen, and then hands that wrapper to this same .
+ /// Without this flag the second call reads as "the window you are already showing asked to be
+ /// raised" and the dialog is never drawn. The WinForms and mac hosts carry the identical flag for
+ /// the identical reason; a provider that gives every window its own native window (agg's own
+ /// ) leaves it alone.
+ ///
+ public static bool SingleWindowMode { get; set; }
+
+ ///
+ /// The single consumption point of , which is seeded from
+ /// RootSystemWindow.DefaultUseGpu by the FORCE_SOFTWARE_RENDERING command-line flag.
+ ///
+ public static bool ShouldUseSoftwareAdapter(SystemWindow systemWindow) => systemWindow?.UseGpu == false;
+
+ /// The SystemWindow this platform window is currently showing.
+ public SystemWindow AggSystemWindow => this.aggSystemWindow;
+
+ /// The wgpu host that owns the device and swapchain.
+ public X11WebGpuLayer WebGpuLayer => this.webGpuLayer;
+
+ /// The provider that created this window, set by the provider itself.
+ public ISystemWindowProvider WindowProvider { get; set; }
+
+ ///
+ /// The X11 window XID, widened into an so this type's public surface matches
+ /// the other hosts' WindowHandle. Diagnostics and tests. An XID is a 32-bit server-side id
+ /// carried in an unsigned long, so nothing is lost.
+ ///
+ public IntPtr WindowHandle => (IntPtr)this.window;
+
+ /// The shared Display*, or zero before the first window opened it. Diagnostics.
+ public IntPtr DisplayHandle => display;
+
+ /// What the renderer has to complain about, or null when it is happy.
+ public string RenderErrorReport => this.webGpuLayer?.LastError;
+
+ /// Which backend, and how many frames actually reached the screen.
+ public string RenderStatusReport
+ {
+ get
+ {
+ var layer = this.webGpuLayer;
+ if (layer?.Device == null)
+ {
+ return "webgpu not initialized";
+ }
+
+ return $"{layer.BackendType} {layer.Device.AdapterName}, presented {layer.Surface?.PresentedFrameCount ?? 0}";
+ }
+ }
+
+ ///
+ /// The window title. Written to both WM_NAME and _NET_WM_NAME: the first is ICCCM and
+ /// is Latin-1 only, the second is the EWMH UTF-8 replacement every modern window manager prefers.
+ /// Setting only one gets a title that is either mojibake or missing depending on which manager the
+ /// user runs.
+ ///
+ public string Caption
+ {
+ get => this.caption;
+
+ set
+ {
+ this.caption = value ?? string.Empty;
+ if (this.window != X11.None)
+ {
+ this.ApplyCaption();
+ }
+ }
+ }
+
+ ///
+ /// The height of the window manager's title bar, in agg pixels (= X pixels), or zero when there is
+ /// none to measure.
+ ///
+ ///
+ /// From _NET_FRAME_EXTENTS, which is the only way to ask: the decorations belong to the
+ /// window manager, live on a frame window this client does not own, and are not part of this
+ /// window's geometry at all. A manager that does not publish the property - and a bare X server with
+ /// no manager running, which is what an Xvfb smoke run is - has no title bar, so zero is the honest
+ /// answer rather than a fallback.
+ ///
+ public int TitleBarHeight => this.ReadFrameExtents(out _, out int top) ? top : 0;
+
+ ///
+ /// The window's top-left corner in desktop space: device pixels with the origin at the top-left of
+ /// the screen, which is X11's own convention and needs no conversion.
+ ///
+ ///
+ /// The position is the frame's, not this window's. Under a reparenting window manager -
+ /// which is nearly all of them - this window is a child of a frame window that carries the
+ /// decorations, so XTranslateCoordinates reports a point inset by the frame's border and
+ /// title bar. Subtracting _NET_FRAME_EXTENTS undoes that, which is what makes the getter the
+ /// inverse of the setter: ICCCM says a XMoveWindow on a reparented top-level is a request to
+ /// place the frame, not the client.
+ ///
+ public Point2D DesktopPosition
+ {
+ get
+ {
+ if (this.window == X11.None || display == IntPtr.Zero)
+ {
+ return new Point2D(0, 0);
+ }
+
+ Xlib.XTranslateCoordinates(display, this.window, rootWindow, 0, 0, out int x, out int y, out _);
+
+ if (this.ReadFrameExtents(out int left, out int top))
+ {
+ x -= left;
+ y -= top;
+ }
+
+ return new Point2D(x, y);
+ }
+
+ set
+ {
+ if (this.window == X11.None || display == IntPtr.Zero)
+ {
+ return;
+ }
+
+ Xlib.XMoveWindow(display, this.window, value.x, value.y);
+ Xlib.XFlush(display);
+ }
+ }
+
+ ///
+ /// The smallest the window may be made, in agg pixels. Published to the window manager as the
+ /// PMinSize half of WM_NORMAL_HINTS; a manager is free to ignore it, and a bare X
+ /// server with no manager always does.
+ ///
+ public Vector2 MinimumSize
+ {
+ get => this.minimumSize;
+
+ set
+ {
+ this.minimumSize = value;
+ if (this.window != X11.None)
+ {
+ this.ApplySizeHints(null);
+ }
+ }
+ }
+
+ /// The modifier keys held right now.
+ ///
+ /// Reports whatever was last told, once it has been told anything - a
+ /// simulated Ctrl-click has no real key held, so reading the real keyboard would report None and
+ /// every modifier-sensitive interaction in an automated run would behave as an unmodified one.
+ ///
+ /// Otherwise it reports the modifier half of the last input event's state word rather than
+ /// asking the server. That is a deliberate difference from the mac host, which polls
+ /// +[NSEvent modifierFlags] here: the X11 equivalent is XQueryPointer, and every Xlib
+ /// call has to come from the thread that owns the display (see ), while this
+ /// property is read from wherever a widget happens to be. The remembered word costs nothing, is
+ /// thread safe, and is stale only for the moment between a modifier being pressed and the next event
+ /// - and a bare modifier press is itself an event, so that window is empty in practice.
+ /// , which does run on the pump thread, is where the live state is
+ /// read instead.
+ ///
+ public Keys ModifierKeys => this.modifiersOverridden
+ ? this.overrideModifierKeys
+ : TranslateModifiers(this.lastModifierState);
+
+ ///
+ /// Declares which modifier keys a synthetic input event is holding, so
+ /// reports them instead of the (empty) real keyboard state.
+ ///
+ ///
+ /// Found by name and by reflection from AggInputMethods.TrySetModifierKeys, which is why it
+ /// is internal and not on - the name and the visibility are both part
+ /// of the contract and cannot be changed without silently dropping the modifiers off every synthetic
+ /// click in the automation suite. Both other hosts have the same method for the same caller. Once
+ /// called, the real keyboard is never read again: an automated run has no user at the keyboard, so
+ /// there is nothing to fall back to.
+ ///
+ internal void SetModifierKeys(Keys modifiers)
+ {
+ this.overrideModifierKeys = modifiers;
+ this.modifiersOverridden = true;
+ }
+
+ /// Raises the window above its siblings, without taking focus from another application.
+ public void BringToFront()
+ {
+ if (this.window != X11.None && display != IntPtr.Zero)
+ {
+ Xlib.XRaiseWindow(display, this.window);
+ Xlib.XFlush(display);
+ }
+ }
+
+ /// Raises the window and gives it the keyboard.
+ ///
+ /// RevertToParent so that when this window goes away the focus falls back to whatever
+ /// contains it rather than to None - focus on None makes the keyboard dead for every client
+ /// until something claims it.
+ ///
+ public void Activate()
+ {
+ if (this.window != X11.None && display != IntPtr.Zero)
+ {
+ Xlib.XRaiseWindow(display, this.window);
+ Xlib.XSetInputFocus(display, this.window, X11.RevertToParent, X11.CurrentTime);
+ Xlib.XFlush(display);
+ }
+ }
+
+ ///
+ /// Schedules a repaint. X11 has an Expose event but no WM_PAINT-style "please redraw" the client can
+ /// post to itself, so this is a flag the pumped loop reads; the rectangle is ignored because the
+ /// whole frame is redrawn either way.
+ ///
+ public void Invalidate(RectangleDouble rectToInvalidate)
+ {
+ this.needsRedraw = true;
+ }
+
+ ///
+ /// Asks for this window to close, the same way the window manager's close button does: by sending
+ /// itself a WM_DELETE_WINDOW client message.
+ ///
+ ///
+ /// Routing through the protocol rather than calling the teardown directly is what keeps one close
+ /// path: whether the user pressed the frame's X or the application called this, the same
+ /// runs and the same OnShouldClose is asked. The
+ /// message is only delivered by the pump, though, so a close with no loop running would otherwise
+ /// never happen - hence the inline fallback.
+ ///
+ public void Close()
+ {
+ if (this.hasClosed || this.window == X11.None || display == IntPtr.Zero)
+ {
+ return;
+ }
+
+ if (runLoopActive)
+ {
+ this.SendCloseRequestToSelf();
+ return;
+ }
+
+ this.HandleCloseRequest();
+ }
+
+ ///
+ /// Points the window's cursor at one of the standard "cursor" font shapes.
+ ///
+ ///
+ /// X11 needs nothing like AppKit's cursor rects: XDefineCursor is a property of the window
+ /// itself, so the server shows this cursor for as long as the pointer is over it and nothing else
+ /// can put it back. The eight pan directions and the "no move" cursors have no cursorfont
+ /// equivalent, so they fall back to the arrow rather than being faked with something misleading.
+ ///
+ public void SetCursor(Cursors cursorToSet)
+ {
+ uint shape = cursorToSet switch
+ {
+ Cursors.IBeam => X11.XC_xterm,
+ Cursors.Hand => X11.XC_hand2,
+ Cursors.Cross => X11.XC_crosshair,
+ Cursors.Help => X11.XC_question_arrow,
+ Cursors.WaitCursor => X11.XC_watch,
+ Cursors.SizeAll => X11.XC_fleur,
+
+ // A split bar is dragged along one axis, which is the same gesture - and in every toolkit
+ // the same cursor - as a window edge on that axis.
+ Cursors.SizeWE or Cursors.VSplit => X11.XC_sb_h_double_arrow,
+ Cursors.SizeNS or Cursors.HSplit => X11.XC_sb_v_double_arrow,
+
+ // cursorfont has no free-floating diagonal arrows, only the four named window corners. The
+ // bottom pair point the right way for the one place agg asks: a window-widget corner grip.
+ Cursors.SizeNWSE => X11.XC_bottom_right_corner,
+ Cursors.SizeNESW => X11.XC_bottom_left_corner,
+
+ _ => X11.XC_arrow,
+ };
+
+ if (this.window == X11.None || display == IntPtr.Zero)
+ {
+ return;
+ }
+
+ ulong cursor = ResolveCursor(shape);
+ if (cursor == X11.None || cursor == this.currentCursor)
+ {
+ return;
+ }
+
+ this.currentCursor = cursor;
+ Xlib.XDefineCursor(display, this.window, cursor);
+ Xlib.XFlush(display);
+ }
+
+ public Graphics2D NewGraphics2D()
+ {
+ if (this.webGpuLayer?.Gl == null)
+ {
+ // Without this the caller gets a bare NullReferenceException out of Graphics2DGpu and no
+ // hint at all that the real problem is a window painting before its wgpu device exists.
+ throw new InvalidOperationException(
+ "The WebGPU device is not initialized, so this window cannot make a Graphics2D. "
+ + "InitializeWebGpu runs from ShowSystemWindow; reaching a paint before that happened "
+ + "means the window was never shown or its initialization threw.");
+ }
+
+ if (!this.viewPortHasBeenSet)
+ {
+ this.SetAndClearViewPort();
+ }
+
+ Graphics2D graphics2D = new Graphics2DGpu(
+ this.webGpuLayer.Gl,
+ (int)this.pixelWidth,
+ (int)this.pixelHeight,
+ GuiWidget.DeviceScale);
+ graphics2D.PushTransform();
+
+ return graphics2D;
+ }
+
+ ///
+ /// Connects a to this platform window, creates the X11 window and its
+ /// wgpu device, maps it, and - unless a window is already running the loop - runs the event loop
+ /// until that window closes. The blocking shape is deliberate: it is what Application.Run
+ /// does on Windows, and every agg demo's Main depends on ShowAsSystemWindow not
+ /// returning until the app is done.
+ ///
+ public void ShowSystemWindow(SystemWindow systemWindow)
+ {
+ if (systemWindow.PlatformWindow == this)
+ {
+ // In single window mode the provider points a window at this one before showing it, so
+ // "already mine" means "start drawing this instead", not "raise what is already up".
+ if (SingleWindowMode && this.window != X11.None)
+ {
+ this.SetActiveAggWindow(systemWindow);
+ return;
+ }
+
+ this.BringToFront();
+ return;
+ }
+
+ this.aggSystemWindow = systemWindow;
+ systemWindow.PlatformWindow = this;
+ systemWindow.AnchorAll();
+
+ this.CreateNativeWindow(systemWindow);
+
+ this.webGpuLayer.UseSoftwareAdapter = ShouldUseSoftwareAdapter(systemWindow);
+ this.webGpuLayer.InitializeWebGpu();
+
+ // Also seeds SystemWindow.DisplayScale, since aggSystemWindow is already attached. On an
+ // unscaled desktop that matches the default and says nothing; on a scaled one it queues a single
+ // DisplayScaleChanged for the first idle tick, which happens before anything is on screen.
+ this.SyncSizeFromWindow();
+
+ // Mapping is a request, not a state change: the window is not on screen until the server (and,
+ // when there is one, the window manager) has processed it and sent back the MapNotify and the
+ // first ConfigureNotify. Pumping the queue here lets those be handled - and the initial geometry
+ // picked up - before the first frame is drawn into a swapchain sized from a guess.
+ for (int settle = 0; settle < 10; settle++)
+ {
+ PumpEvents();
+ Thread.Sleep(10);
+ }
+
+ this.needsRedraw = true;
+
+ // From here on a configure is somebody resizing the window, not this method sizing it - which is
+ // what lets a resize burst paint itself. See ShouldPaintSynchronouslyForResize.
+ this.showCompleted = true;
+
+ // Whoever finds no loop running owns it. A window shown from inside the loop (a dialog, a second
+ // window) finds one and returns, which is the non-blocking Show every platform gives it.
+ if (!runLoopActive)
+ {
+ RunEventLoop();
+ }
+ }
+
+ ///
+ /// Tears this platform window down in response to the agg window closing. Called by the provider
+ /// from .
+ ///
+ public void CloseSystemWindow(SystemWindow systemWindow)
+ {
+ // X11 is already closing us (the user hit the frame's close button); letting the agg close drive
+ // a second close would re-enter the teardown.
+ if (this.platformAlreadyClosing)
+ {
+ return;
+ }
+
+ // In single window mode a dialog lives inside this window, so closing one is only a matter of
+ // going back to drawing whatever the provider now has on top. Only the shell - the window the
+ // provider is left holding - takes the native window down with it.
+ if (SingleWindowMode
+ && this.window != X11.None
+ && this.WindowProvider?.TopWindow != null
+ && this.WindowProvider.TopWindow != systemWindow)
+ {
+ this.SetActiveAggWindow(this.WindowProvider.TopWindow);
+ return;
+ }
+
+ this.DestroyNativeWindow();
+ }
+
+ ///
+ /// Makes the window this native window draws and routes input to.
+ /// Creates nothing native - only single window mode reaches it, see .
+ ///
+ private void SetActiveAggWindow(SystemWindow systemWindow)
+ {
+ if (systemWindow == null || this.hasClosed)
+ {
+ return;
+ }
+
+ if (this.aggSystemWindow != systemWindow)
+ {
+ this.aggSystemWindow = systemWindow;
+ systemWindow.PlatformWindow = this;
+ systemWindow.AnchorAll();
+
+ // SetBoundsFromPlatform rather than LocalBounds so a minimum sized for another display cannot
+ // lay the window out larger than the drawable it is about to be drawn into.
+ systemWindow.SetBoundsFromPlatform(this.pixelWidth, this.pixelHeight);
+ systemWindow.SetDisplayScale(this.displayScale);
+ systemWindow.SetDisplayUsableSize(this.MeasureUsableScreenSize());
+ }
+
+ systemWindow.Invalidate();
+ this.needsRedraw = true;
+ }
+
+ ///
+ /// Reads the frame back through wgpu. No System.Drawing anywhere on the path: the pixels go
+ /// through agg's own ImageBuffer/ImageIO.
+ ///
+ ///
+ /// The read-back can only happen at the end of a frame - that is the only moment a swapchain
+ /// texture exists and is still readable - so the request is queued and a paint forced, and this
+ /// call does not return until that paint has written the file. Callers (failure diagnostics, the
+ /// automation harness) treat CaptureScreenshot as "the PNG exists when I get control back",
+ /// which is the contract every other IPlatformWindow gives them.
+ ///
+ /// Where to write the PNG.
+ public void CaptureScreenshot(string path)
+ {
+ if (this.webGpuLayer == null || this.webGpuLayer.IsDisposed)
+ {
+ return;
+ }
+
+ if (!UiThread.IsUiThread)
+ {
+ // Every Xlib call has to come from the thread that owns the display, so the request is
+ // marshalled the only way this host has: through the idle queue the event loop drains.
+ using (var done = new ManualResetEventSlim(false))
+ {
+ UiThread.RunOnIdle(() =>
+ {
+ try
+ {
+ this.CaptureScreenshot(path);
+ }
+ finally
+ {
+ done.Set();
+ }
+ });
+
+ done.Wait(TimeSpan.FromSeconds(10));
+ }
+
+ return;
+ }
+
+ if (this.isInsidePaint)
+ {
+ // The smoke-run path asks from inside the paint, just before the present that would consume
+ // the request. Forcing another paint from here would re-enter the frame; queuing is enough,
+ // because this frame is about to reach PresentOrCapture anyway.
+ this.pendingScreenshotPath = path;
+ return;
+ }
+
+ this.pendingScreenshotPath = path;
+ this.screenshotComplete = new ManualResetEventSlim(false);
+
+ try
+ {
+ this.PaintFrame();
+
+ // The native read-back completes inside the paint (wgpu's buffer map is polled to
+ // completion there), so this is normally already set. It is only not set if the await in
+ // CaptureThenPresent genuinely suspended, in which case its continuation is queued to the
+ // idle pump - hence pumping rather than blocking, which would deadlock.
+ for (int spin = 0; spin < ScreenshotPumpSpins && !this.screenshotComplete.IsSet; spin++)
+ {
+ PumpEvents();
+ InvokeIdleActions();
+ }
+ }
+ finally
+ {
+ this.pendingScreenshotPath = null;
+ this.screenshotComplete.Dispose();
+ this.screenshotComplete = null;
+ }
+ }
+
+ // -----------------------------------------------------------------------------------------
+ // Display bootstrap
+ // -----------------------------------------------------------------------------------------
+
+ ///
+ /// Opens the shared connection and makes the process safe to run X11 on. Idempotent.
+ ///
+ ///
+ /// The order matters. The locale is set first, because XOpenDisplay captures the
+ /// locale in effect when it runs and XLookupString answers in that locale's encoding - a
+ /// process that never calls setlocale starts in the "C" locale, where the encoding is ASCII
+ /// and every non-ASCII key silently produces nothing. The error handlers are installed before
+ /// anything can fail, because both Xlib defaults end in exit.
+ ///
+ private static void BootstrapDisplay()
+ {
+ lock (StaticInitLock)
+ {
+ if (displayBootstrapped)
+ {
+ return;
+ }
+
+ // "" means "take the locale from the environment", which is what makes XLookupString produce
+ // UTF-8 on any modern desktop. XSupportsLocale then says whether Xlib has an X locale
+ // database entry for it; if it has not, its behaviour in that locale is undefined, so the
+ // only safe move is to stay in the portable "C" one.
+ Xlib.SetLocale(Xlib.LC_ALL, string.Empty);
+ if (Xlib.XSupportsLocale() == 0)
+ {
+ Console.Error.WriteLine(
+ "X11SystemWindow: Xlib does not support this locale; falling back to \"C\". Non-ASCII keys may not type.");
+ Xlib.SetLocale(Xlib.LC_ALL, "C");
+ }
+ else
+ {
+ // "" here means "take XMODIFIERS from the environment", which is where a running input
+ // method advertises itself.
+ //
+ // Advertised to, and then not used: no XIM is opened and no XIC is created, so the input
+ // method is not actually connected to and key translation goes through XLookupString
+ // alone. That is enough for every direct key on every Latin layout, and is not enough for
+ // the three things an input context owns - dead keys and Compose sequences (typing
+ // ' then e for e-acute), CJK candidate selection, and on-the-spot preedit. Those need
+ // XOpenIM/XCreateIC and a Xutf8LookupString on the KeyPress path, which is a follow-up.
+ // The call stays because it costs nothing and has to happen here, before XOpenDisplay,
+ // for that follow-up to have anything to open.
+ Xlib.XSetLocaleModifiers(string.Empty);
+ }
+
+ InstallErrorHandlers();
+
+ display = Xlib.XOpenDisplay(null);
+ if (display == IntPtr.Zero)
+ {
+ throw new InvalidOperationException(
+ "XOpenDisplay returned NULL: there is no X server to talk to. "
+ + $"DISPLAY is '{Environment.GetEnvironmentVariable("DISPLAY") ?? "(unset)"}'.");
+ }
+
+ screenNumber = Xlib.XDefaultScreen(display);
+ rootWindow = Xlib.XRootWindow(display, screenNumber);
+
+ // Xlib is single-threaded here (see Xlib's remarks), and this is the thread that owns the
+ // connection: whoever opened it is whoever goes on to run the pump. Recorded so a caller
+ // that can arrive from anywhere - the clipboard - can tell whether it is allowed to speak
+ // X11 at all, rather than corrupting the connection to find out.
+ displayThreadId = Environment.CurrentManagedThreadId;
+
+ wmProtocolsAtom = Xlib.XInternAtom(display, "WM_PROTOCOLS", 0);
+ wmDeleteWindowAtom = Xlib.XInternAtom(display, "WM_DELETE_WINDOW", 0);
+ netWmNameAtom = Xlib.XInternAtom(display, "_NET_WM_NAME", 0);
+ utf8StringAtom = Xlib.XInternAtom(display, "UTF8_STRING", 0);
+ netWmStateAtom = Xlib.XInternAtom(display, "_NET_WM_STATE", 0);
+ netWmStateMaximizedHorzAtom = Xlib.XInternAtom(display, "_NET_WM_STATE_MAXIMIZED_HORZ", 0);
+ netWmStateMaximizedVertAtom = Xlib.XInternAtom(display, "_NET_WM_STATE_MAXIMIZED_VERT", 0);
+ netFrameExtentsAtom = Xlib.XInternAtom(display, "_NET_FRAME_EXTENTS", 0);
+
+ displayBootstrapped = true;
+ }
+ }
+
+ ///
+ /// Replaces both of Xlib's fatal default handlers. Xlib does not return protocol errors from the
+ /// call that caused them - requests are asynchronous, so by the time the server objects the call has
+ /// long since returned - and its defaults print and then exit. An application that installs
+ /// nothing therefore dies on the first BadWindow with no managed stack and no chance to report.
+ ///
+ private static unsafe void InstallErrorHandlers()
+ {
+ // Rooted in static fields: nothing on the native side keeps a managed delegate alive, and a
+ // collected one turns the next error into a jump into freed memory.
+ protocolErrorHandler = (handlerDisplay, error) =>
+ {
+ // Never throws. This runs on a native frame with no managed caller above it, so an exception
+ // crossing back tears the process down with no diagnostic - the same rule the mac host's
+ // [UnmanagedCallersOnly] IMPs follow.
+ try
+ {
+ Console.Error.WriteLine(
+ $"X11 protocol error: code={error->ErrorCode} request={error->RequestCode}.{error->MinorCode} "
+ + $"resource=0x{error->ResourceId:x} serial={error->Serial}");
+ }
+ catch
+ {
+ }
+
+ // Xlib ignores the value; zero is the convention.
+ return 0;
+ };
+
+ ioErrorHandler = handlerDisplay =>
+ {
+ // The connection is gone: the server died, or the session ended under us. Every Xlib call
+ // from here on is undefined behaviour, which rules out an orderly teardown - there is no
+ // way to destroy a window on a display that no longer exists. Xlib also requires this
+ // handler NOT to return; if it does, Xlib calls exit() itself with no message at all.
+ //
+ // So the process ends here, and it ends by Environment.Exit rather than FailFast: this is a
+ // lost connection, not a corrupted process, and a crash dump plus a Watson report would say
+ // nothing that the line below does not. Clearing the latch first means that if a finalizer
+ // or an AppDomain handler does get a turn, it does not find a loop that believes it is
+ // still running.
+ try
+ {
+ runLoopActive = false;
+ Console.Error.WriteLine("X11 I/O error: the connection to the X server was lost. Exiting.");
+ }
+ catch
+ {
+ }
+
+ Environment.Exit(1);
+
+ return 0;
+ };
+
+ Xlib.XSetErrorHandler(protocolErrorHandler);
+ Xlib.XSetIOErrorHandler(ioErrorHandler);
+ }
+
+ ///
+ /// Drains the RunOnIdle queue. Guarded because an idle action can run a nested loop (a modal
+ /// dialog, or 's pump) and re-enter this.
+ ///
+ private static void InvokeIdleActions()
+ {
+ lock (StaticInitLock)
+ {
+ if (processingOnIdle)
+ {
+ return;
+ }
+
+ processingOnIdle = true;
+ }
+
+ try
+ {
+ UiThread.InvokePendingActions();
+ }
+ finally
+ {
+ lock (StaticInitLock)
+ {
+ processingOnIdle = false;
+ }
+ }
+ }
+
+ // -----------------------------------------------------------------------------------------
+ // Native window construction
+ // -----------------------------------------------------------------------------------------
+
+ private unsafe void CreateNativeWindow(SystemWindow systemWindow)
+ {
+ this.displayScale = ReadDisplayScale();
+
+ // No division by a scale factor here, unlike the mac host: on X11 the window's size in pixels IS
+ // the agg size. See the class remarks.
+ uint width = (uint)Math.Max(1, systemWindow.Width);
+ uint height = (uint)Math.Max(1, systemWindow.Height);
+
+ var attributes = default(XSetWindowAttributes);
+ attributes.BackgroundPixel = Xlib.XBlackPixel(display, screenNumber);
+ attributes.EventMask =
+ X11.ExposureMask
+ | X11.StructureNotifyMask
+ | X11.KeyPressMask
+ | X11.KeyReleaseMask
+ | X11.ButtonPressMask
+ | X11.ButtonReleaseMask
+ | X11.PointerMotionMask
+ | X11.EnterWindowMask
+ | X11.LeaveWindowMask
+ | X11.FocusChangeMask;
+
+ // Deliberately no PropertyChangeMask. The only property this host reads is _NET_FRAME_EXTENTS,
+ // which the window manager publishes onto this window - but TitleBarHeight and DesktopPosition
+ // read it on demand with XGetWindowProperty, so there is nothing for a PropertyNotify to do
+ // except cost a round trip per property any client on the desktop happens to change.
+
+ this.window = Xlib.XCreateWindow(
+ display,
+ rootWindow,
+ 0,
+ 0,
+ width,
+ height,
+ 0,
+ X11.CopyFromParent,
+ X11.InputOutput,
+
+ // CopyFromParent for the visual as well as the depth: the root's visual is the server's
+ // default, which is the one wgpu's Vulkan surface expects and the only one guaranteed to
+ // have a colormap already.
+ Xlib.XDefaultVisual(display, screenNumber),
+ X11.CWEventMask | X11.CWBackPixel,
+ &attributes);
+
+ if (this.window == X11.None)
+ {
+ throw new InvalidOperationException("XCreateWindow returned None - the X server refused to create the window.");
+ }
+
+ // Without this the window manager has no way to ask, so its close button kills the connection
+ // outright instead - which reaches this process as an I/O error and no Closed handler at all.
+ ulong[] protocols = { wmDeleteWindowAtom };
+ Xlib.XSetWMProtocols(display, this.window, protocols, protocols.Length);
+
+ if (string.IsNullOrEmpty(this.caption))
+ {
+ this.caption = systemWindow.Title ?? string.Empty;
+ }
+
+ this.ApplyCaption();
+ this.ApplySizeHints(systemWindow);
+
+ // Before the map, not after: see RequestMaximizeBeforeMap. A window manager reads the state
+ // property when it adopts the window, and after that it owns it.
+ if (systemWindow.Maximized)
+ {
+ this.RequestMaximizeBeforeMap();
+ }
+
+ Xlib.XMapWindow(display, this.window);
+
+ // XSync rather than XFlush: the geometry read straight afterwards has to be the one the server
+ // settled on, and a flush only pushes the requests out without waiting for them.
+ Xlib.XSync(display, 0);
+
+ this.MeasureWindow();
+
+ this.webGpuLayer = new X11WebGpuLayer(display, this.window, this.pixelWidth, this.pixelHeight);
+ }
+
+ ///
+ /// Publishes the title under both conventions. See for why one is not enough.
+ ///
+ private unsafe void ApplyCaption()
+ {
+ Xlib.XStoreName(display, this.window, this.caption);
+
+ byte[] utf8 = Encoding.UTF8.GetBytes(this.caption);
+ fixed (byte* bytes = utf8)
+ {
+ Xlib.XChangeProperty(
+ display,
+ this.window,
+ netWmNameAtom,
+ utf8StringAtom,
+ 8,
+ X11.PropModeReplace,
+ bytes,
+
+ // A zero-length property is legal and is how an empty title is spelled; the fixed
+ // pointer of an empty array is null, which XChangeProperty accepts for a zero count.
+ utf8.Length);
+ }
+ }
+
+ ///
+ /// Publishes WM_NORMAL_HINTS: the minimum size always, and the initial geometry when the
+ /// application asked for a specific one.
+ ///
+ ///
+ /// The window being created, or null when this is a later minimum-size change - in which case there
+ /// is no initial position left to state.
+ ///
+ private unsafe void ApplySizeHints(SystemWindow systemWindow)
+ {
+ var hints = default(XSizeHints);
+
+ if (this.minimumSize != Vector2.Zero)
+ {
+ hints.Flags |= X11.PMinSize;
+ hints.MinWidth = (int)Math.Max(1, this.minimumSize.X);
+ hints.MinHeight = (int)Math.Max(1, this.minimumSize.Y);
+ }
+
+ if (systemWindow != null)
+ {
+ // PSize is "the program picked this size", as opposed to USSize which claims the user did.
+ hints.Flags |= X11.PSize;
+ hints.Width = (int)Math.Max(1, systemWindow.Width);
+ hints.Height = (int)Math.Max(1, systemWindow.Height);
+
+ // (-1, -1) is agg's "no preference", which means let the window manager place it.
+ if (systemWindow.InitialDesktopPosition != new Point2D(-1, -1))
+ {
+ hints.Flags |= X11.PPosition;
+ hints.X = systemWindow.InitialDesktopPosition.x;
+ hints.Y = systemWindow.InitialDesktopPosition.y;
+
+ Xlib.XMoveWindow(display, this.window, hints.X, hints.Y);
+ }
+ }
+
+ if (hints.Flags == 0)
+ {
+ return;
+ }
+
+ Xlib.XSetWMNormalHints(display, this.window, &hints);
+ }
+
+ ///
+ /// Asks for the window to come up maximized, by setting _NET_WM_STATE on it while it is still
+ /// unmapped.
+ ///
+ ///
+ /// EWMH splits this into two mechanisms and the split is by map state, not by preference. Before the
+ /// window is mapped the property is the request: the manager reads it when it takes the
+ /// window over, and that is the only way to ask for an initial state. Once mapped the manager owns
+ /// the property and a client that writes it is ignored - from then on the request has to be a
+ /// _NET_WM_STATE ClientMessage to the root window, where the manager is the one selecting for
+ /// substructure events. Nothing here needs the second form (agg has no "maximize now" call), so only
+ /// the pre-map one is implemented.
+ ///
+ /// The property is a list of atoms in "format 32", which on LP64 means Xlib expects an array of C
+ /// long - 8 bytes per element - even though only 32 bits per element reach the wire.
+ ///
+ private unsafe void RequestMaximizeBeforeMap()
+ {
+ long* states = stackalloc long[2];
+ states[0] = (long)netWmStateMaximizedVertAtom;
+ states[1] = (long)netWmStateMaximizedHorzAtom;
+
+ Xlib.XChangeProperty(
+ display,
+ this.window,
+ netWmStateAtom,
+ X11.XA_ATOM,
+ 32,
+ X11.PropModeReplace,
+ (byte*)states,
+ 2);
+ }
+
+ /// Sends this window the same close request the window manager's close button sends.
+ private unsafe void SendCloseRequestToSelf()
+ {
+ var message = default(XEvent);
+ ref XClientMessageEvent clientMessage = ref message.As();
+
+ clientMessage.Type = X11.ClientMessage;
+ clientMessage.Display = display;
+ clientMessage.Window = this.window;
+ clientMessage.MessageType = wmProtocolsAtom;
+ clientMessage.Format = 32;
+ clientMessage.Data[0] = (long)wmDeleteWindowAtom;
+ clientMessage.Data[1] = (long)X11.CurrentTime;
+
+ // No mask: an event sent with NoEventMask goes to the client that created the window, which for
+ // this window is us. Propagate is false for the same reason.
+ Xlib.XSendEvent(display, this.window, 0, X11.NoEventMask, ref message);
+ Xlib.XFlush(display);
+ }
+
+ /// Re-reads the window's size from the server into /.
+ private void MeasureWindow()
+ {
+ if (Xlib.XGetWindowAttributes(display, this.window, out XWindowAttributes attributes) == 0)
+ {
+ return;
+ }
+
+ this.pixelWidth = (uint)Math.Max(1, attributes.Width);
+ this.pixelHeight = (uint)Math.Max(1, attributes.Height);
+ }
+
+ ///
+ /// How much room the screen has for a window, in device pixels.
+ ///
+ ///
+ /// The whole screen. X11 has no notion of a work area at all - the space a panel or a dock occupies
+ /// is a window-manager convention published as _NET_WORKAREA, which is frequently absent and
+ /// is meaningless on a multi-head setup where it describes the union of the displays. The screen
+ /// size is the honest answer this host can give; a manager-aware refinement belongs with the rest
+ /// of the multi-monitor work, which nothing in agg needs yet.
+ ///
+ private Vector2 MeasureUsableScreenSize()
+ {
+ if (display == IntPtr.Zero)
+ {
+ return Vector2.Zero;
+ }
+
+ int width = Xlib.XDisplayWidth(display, screenNumber);
+ int height = Xlib.XDisplayHeight(display, screenNumber);
+
+ return width > 0 && height > 0 ? new Vector2(width, height) : Vector2.Zero;
+ }
+
+ ///
+ /// The user's display scaling, from Xft.dpi over 96. Read per window rather than cached,
+ /// because a user who changes their scaling writes a new value into the resource database and every
+ /// running client is expected to notice.
+ ///
+ private static double ReadDisplayScale()
+ {
+ if (display != IntPtr.Zero && Xlib.TryReadXftDpi(display, out double dpi) && dpi > 0)
+ {
+ return dpi / BaselineDpi;
+ }
+
+ return 1;
+ }
+
+ ///
+ /// Reads the window manager's frame thickness from _NET_FRAME_EXTENTS.
+ ///
+ /// False when no manager published the property, which is also the bare-X-server case.
+ private bool ReadFrameExtents(out int left, out int top)
+ {
+ left = 0;
+ top = 0;
+
+ if (this.window == X11.None || display == IntPtr.Zero)
+ {
+ return false;
+ }
+
+ // The property is four CARDINALs: left, right, top, bottom. Lengths here are in 32-bit units,
+ // but a "format 32" property is unpacked into C longs, so each item is 8 bytes wide on LP64.
+ int status = Xlib.XGetWindowProperty(
+ display,
+ this.window,
+ netFrameExtentsAtom,
+ 0,
+ 4,
+ 0,
+ X11.XA_CARDINAL,
+ out _,
+ out int actualFormat,
+ out ulong itemCount,
+ out _,
+ out IntPtr property);
+
+ if (status != 0 || property == IntPtr.Zero)
+ {
+ return false;
+ }
+
+ try
+ {
+ if (actualFormat != 32 || itemCount < 4)
+ {
+ return false;
+ }
+
+ left = (int)System.Runtime.InteropServices.Marshal.ReadInt64(property, 0);
+ top = (int)System.Runtime.InteropServices.Marshal.ReadInt64(property, 2 * sizeof(long));
+
+ return true;
+ }
+ finally
+ {
+ // Xlib allocated this even when it found nothing, which is the leak everyone writes once.
+ Xlib.XFree(property);
+ }
+ }
+
+ /// The cursor for a font shape, made once per shape and kept for the life of the process.
+ private static ulong ResolveCursor(uint shape)
+ {
+ lock (ResolvedCursors)
+ {
+ if (ResolvedCursors.TryGetValue(shape, out ulong cached))
+ {
+ return cached;
+ }
+
+ ulong cursor = Xlib.XCreateFontCursor(display, shape);
+ if (cursor != X11.None)
+ {
+ ResolvedCursors[shape] = cursor;
+ }
+
+ return cursor;
+ }
+ }
+
+ // -----------------------------------------------------------------------------------------
+ // Resize
+ // -----------------------------------------------------------------------------------------
+
+ ///
+ /// Decides whether a ConfigureNotify has to paint the frame itself rather than leaving it to
+ /// the pump.
+ ///
+ ///
+ /// The same decision MacSystemWindow makes, reached from the other direction. There it is
+ /// forced: a live resize runs inside AppKit's nested tracking loop, the host's pump is frozen for
+ /// its duration, and without a synchronous paint nothing draws until the mouse comes up. X11 has no
+ /// nested loop, so the pump would get there on its own - but not before the server has already
+ /// resized the window under the last presented frame, which reads as the same smear. Painting from
+ /// inside the burst keeps the drawable and the window the same size at every step.
+ ///
+ /// Two things have to be true, and they are not the same test. The burst is what says a drag is in
+ /// progress rather than one deliberate resize the pump will pick up on its very next pass. The show
+ /// gate is what keeps the mapping and settling sequence out of it: those configures arrive back to
+ /// back, so they look exactly like a burst to a timer, and painting there would draw before
+ /// has finished bringing the window up. A threshold cannot separate
+ /// the two - the show sequence's configures are as close together as a drag's - which is why the
+ /// caller passes both.
+ ///
+ /// Factored out and parameterised for the same reason the mac one is - a resize burst cannot be
+ /// synthesised in a unit test, but this decision can.
+ ///
+ /// Whether another size change arrived within .
+ /// False until has mapped and settled the window.
+ /// True when a paint is already on the stack; painting again would re-enter the frame.
+ /// True once the window is gone, which configure events can still outlive.
+ /// False before there is a swapchain to draw into - the first resizes land there.
+ internal static bool ShouldPaintSynchronouslyForResize(
+ bool inResizeBurst,
+ bool showCompleted,
+ bool isInsidePaint,
+ bool hasClosed,
+ bool webGpuInitialized)
+ {
+ return inResizeBurst && showCompleted && webGpuInitialized && !isInsidePaint && !hasClosed;
+ }
+
+ ///
+ /// Pushes the window's current size everywhere it has to go: the swapchain and the agg window's
+ /// bounds, scale and usable size.
+ ///
+ private void SyncSizeFromWindow()
+ {
+ if (this.window == X11.None || this.hasClosed)
+ {
+ return;
+ }
+
+ uint previousWidth = this.pixelWidth;
+ uint previousHeight = this.pixelHeight;
+
+ this.MeasureWindow();
+
+ if (this.webGpuLayer != null
+ && this.webGpuLayer.IsWebGpuInitialized
+ && (previousWidth != this.pixelWidth || previousHeight != this.pixelHeight))
+ {
+ this.webGpuLayer.Resize(this.pixelWidth, this.pixelHeight);
+ }
+
+ this.viewPortHasBeenSet = false;
+
+ if (this.aggSystemWindow != null)
+ {
+ // The drawable is this big whatever the application's minimum says. Assigning LocalBounds
+ // would let a minimum computed elsewhere inflate the layout past the drawable, and agg being
+ // y-up that clips off the top - the toolbars vanish under the title bar.
+ this.aggSystemWindow.SetBoundsFromPlatform(this.pixelWidth, this.pixelHeight);
+
+ // SetDisplayScale only stores the value; it raises its event from the idle queue, so this is
+ // safe to call from inside a resize burst where a subscriber that rebuilt the UI would stall.
+ this.aggSystemWindow.SetDisplayScale(this.displayScale);
+ this.aggSystemWindow.SetDisplayUsableSize(this.MeasureUsableScreenSize());
+
+ this.aggSystemWindow.Invalidate();
+ }
+
+ this.needsRedraw = true;
+ }
+
+ // -----------------------------------------------------------------------------------------
+ // The event loop
+ // -----------------------------------------------------------------------------------------
+
+ ///
+ /// Drives X11 ourselves, the way a toolkit with its own frame scheduler must: the queue is drained
+ /// without ever blocking in XNextEvent, so the loop can paint between batches of events, and
+ /// the wait for the next one is a poll with a timeout rather than a block. See the class
+ /// remarks for why that timeout is also the idle tick.
+ ///
+ private static void RunEventLoop()
+ {
+ runLoopActive = true;
+
+ try
+ {
+ while (runLoopActive)
+ {
+ PumpEvents();
+
+ // This loop owns its thread for as long as a window is up, so anything another thread
+ // asked it to do has to come through here or it never runs at all. A no-op when nothing
+ // hosted the dispatcher, which off macOS is the normal case.
+ MainThreadDispatcher.DrainPending();
+
+ InvokeIdleActions();
+
+ bool paintedSomething = false;
+
+ X11SystemWindow[] windows;
+ lock (StaticInitLock)
+ {
+ windows = LiveWindows.ToArray();
+ }
+
+ foreach (var x11Window in windows)
+ {
+ if (x11Window.needsRedraw && !x11Window.hasClosed)
+ {
+ x11Window.PaintFrame();
+ paintedSomething = true;
+ }
+ }
+
+ lock (StaticInitLock)
+ {
+ if (LiveWindows.Count == 0)
+ {
+ runLoopActive = false;
+ }
+ }
+
+ if (!paintedSomething && runLoopActive)
+ {
+ // Nothing to draw and nothing queued. Without this the loop is a 100% CPU spin.
+ WaitForEvents(IdlePumpMilliseconds);
+ }
+ }
+ }
+ finally
+ {
+ // A paint that throws must not leave the process believing a loop is still running, or the
+ // next window shown would return immediately and never be pumped.
+ runLoopActive = false;
+ }
+ }
+
+ /// Drains the X event queue, dispatching each event to the window it belongs to.
+ private static void PumpEvents()
+ {
+ if (display == IntPtr.Zero)
+ {
+ return;
+ }
+
+ // Input that arrived while a clipboard round trip was outstanding was held back rather than
+ // dispatched into half-finished widget code (see X11Selection's remarks). It goes first, ahead
+ // of anything still on the X queue, so a keystroke typed during a paste still lands before one
+ // typed after it.
+ X11Selection.DispatchDeferredInput();
+
+ // XPending flushes the output buffer and then reports what has already been decoded, so this is
+ // both "send my requests" and "is there anything for me". XNextEvent blocks, which is why it is
+ // only ever reached with a non-zero count in hand.
+ while (Xlib.XPending(display) > 0)
+ {
+ Xlib.XNextEvent(display, out XEvent nextEvent);
+ DispatchEvent(ref nextEvent);
+ }
+ }
+
+ ///
+ /// Sleeps until the X connection has something to say or elapse.
+ ///
+ ///
+ /// poll on the connection's file descriptor rather than a plain sleep, so an event that
+ /// arrives one millisecond in is acted on then rather than at the end of the interval. The queue is
+ /// re-checked first because Xlib may already hold a decoded event with nothing left on the socket to
+ /// wake the poll - which would be a wait for something that has already happened.
+ ///
+ internal static unsafe void WaitForEvents(int milliseconds)
+ {
+ if (display == IntPtr.Zero)
+ {
+ Thread.Sleep(milliseconds);
+ return;
+ }
+
+ if (Xlib.XPending(display) > 0)
+ {
+ return;
+ }
+
+ var pollFd = new PollFd
+ {
+ Fd = Xlib.XConnectionNumber(display),
+ Events = Xlib.POLLIN,
+ };
+
+ Xlib.Poll(&pollFd, 1, milliseconds);
+ }
+
+ ///
+ /// Routes one X event to the window it names, with the same two guards the mac host's dispatch has:
+ /// input is dropped when a parallel automation run has turned real input off, and nothing is allowed
+ /// to throw out of here.
+ ///
+ ///
+ /// Internal rather than private because a clipboard read is a round trip through the X server on
+ /// this same single thread: runs a nested pump while it waits for the
+ /// answer, and hands everything that is not that answer back here so the application does not go
+ /// deaf for the length of a paste.
+ ///
+ internal static void DispatchEvent(ref XEvent nextEvent)
+ {
+ // A few events are about the display rather than about a window, and the per-window lookup below
+ // would drop them: they carry a window field that is meaningless (MappingNotify names whatever
+ // window happened to have the focus, which is frequently not one of ours and is sometimes None).
+ // They have to be handled before the lookup, not after it.
+ if (HandleDisplayWideEvent(ref nextEvent))
+ {
+ return;
+ }
+
+ ulong eventWindow = WindowOf(ref nextEvent);
+
+ X11SystemWindow target = null;
+ lock (StaticInitLock)
+ {
+ foreach (var x11Window in LiveWindows)
+ {
+ if (x11Window.window == eventWindow && eventWindow != X11.None)
+ {
+ target = x11Window;
+ break;
+ }
+ }
+ }
+
+ if (target == null || target.hasClosed)
+ {
+ return;
+ }
+
+ try
+ {
+ target.HandleEvent(ref nextEvent);
+ }
+ catch (Exception ex)
+ {
+ UiThread.ReportUnhandledException(ex);
+ Console.Error.WriteLine($"X11SystemWindow event handler threw {ex}");
+ }
+ }
+
+ ///
+ /// Handles the events that belong to the connection rather than to any one window.
+ ///
+ /// True when the event was handled here and must not be routed to a window.
+ private static bool HandleDisplayWideEvent(ref XEvent nextEvent)
+ {
+ // The selection events belong to X11Selection's hidden window, which is deliberately not one of
+ // LiveWindows - so the lookup below would drop the SelectionRequest that *is* the clipboard.
+ if (X11Selection.TryHandleEvent(ref nextEvent))
+ {
+ return true;
+ }
+
+ if (nextEvent.Type != X11.MappingNotify)
+ {
+ return false;
+ }
+
+ try
+ {
+ // Xlib caches the keyboard mapping, and until it is told the layout changed every keysym
+ // lookup answers from the old one - so a user who switches layout keeps typing the previous
+ // one until the process restarts. The cache is per display, which is why this is not a
+ // window's business.
+ Xlib.XRefreshKeyboardMapping(ref nextEvent.As());
+ }
+ catch (Exception ex)
+ {
+ UiThread.ReportUnhandledException(ex);
+ Console.Error.WriteLine($"X11SystemWindow MappingNotify handler threw {ex}");
+ }
+
+ return true;
+ }
+
+ ///
+ /// The window an event is about. Every arm of that this host handles carries a
+ /// window field, but not at the same offset - and
+ /// have an event field ahead of it, because a structure
+ /// event can be selected on the parent. The one wanted here is always the subject window.
+ ///
+ private static ulong WindowOf(ref XEvent nextEvent)
+ {
+ switch (nextEvent.Type)
+ {
+ case X11.ConfigureNotify:
+ return nextEvent.As().Window;
+
+ case X11.DestroyNotify:
+ return nextEvent.As().Window;
+
+ case X11.KeyPress:
+ case X11.KeyRelease:
+ return nextEvent.As().Window;
+
+ case X11.ButtonPress:
+ case X11.ButtonRelease:
+ return nextEvent.As().Window;
+
+ case X11.MotionNotify:
+ return nextEvent.As().Window;
+
+ case X11.EnterNotify:
+ case X11.LeaveNotify:
+ return nextEvent.As().Window;
+
+ case X11.FocusIn:
+ case X11.FocusOut:
+ return nextEvent.As().Window;
+
+ case X11.Expose:
+ return nextEvent.As().Window;
+
+ case X11.ClientMessage:
+ return nextEvent.As().Window;
+
+ default:
+ // MappingNotify was already taken by HandleDisplayWideEvent; everything else left here
+ // is something this host does not handle at all.
+ return X11.None;
+ }
+ }
+
+ private unsafe void HandleEvent(ref XEvent nextEvent)
+ {
+ switch (nextEvent.Type)
+ {
+ case X11.Expose:
+ // The whole frame is redrawn either way, so the damage rectangle is not read. Count is
+ // how many more Expose events for this same damage are still queued; they will all set
+ // the same flag, which is harmless.
+ this.needsRedraw = true;
+ return;
+
+ case X11.ConfigureNotify:
+ this.HandleConfigureNotify(ref nextEvent.As());
+ return;
+
+ case X11.ClientMessage:
+ this.HandleClientMessage(ref nextEvent.As());
+ return;
+
+ case X11.DestroyNotify:
+ this.HandleDestroyNotify();
+ return;
+
+ case X11.FocusIn:
+ this.HandleFocusGained(ref nextEvent.As());
+ return;
+
+ case X11.FocusOut:
+ this.HandleFocusLost(ref nextEvent.As());
+ return;
+
+ case X11.KeyPress:
+ case X11.KeyRelease:
+ case X11.ButtonPress:
+ case X11.ButtonRelease:
+ case X11.MotionNotify:
+ case X11.EnterNotify:
+ case X11.LeaveNotify:
+ // Parallel automation tests turn this off so a real mouse or keyboard cannot perturb a
+ // run. Only the input arms are gated: a window still has to resize, repaint and close.
+ if (!IPlatformWindow.EnablePlatformWindowInput || this.aggSystemWindow == null)
+ {
+ return;
+ }
+
+ this.HandleInputEvent(ref nextEvent);
+ return;
+
+ default:
+ return;
+ }
+ }
+
+ ///
+ /// Handles a geometry change. A move alone changes nothing this host cares about, so the size is
+ /// what is tested - the window manager sends a ConfigureNotify for a restack too.
+ ///
+ private void HandleConfigureNotify(ref XConfigureEvent configureEvent)
+ {
+ uint newWidth = (uint)Math.Max(1, configureEvent.Width);
+ uint newHeight = (uint)Math.Max(1, configureEvent.Height);
+
+ if (newWidth == this.pixelWidth && newHeight == this.pixelHeight)
+ {
+ return;
+ }
+
+ // Read before the timestamp is moved on, so this asks "was there a size change just before this
+ // one" - which is what makes the first configure of a burst leave the paint to the pump.
+ long now = Stopwatch.GetTimestamp();
+ bool inResizeBurst = this.lastResizeTimestamp != long.MinValue
+ && (now - this.lastResizeTimestamp) < (Stopwatch.Frequency * ResizeBurstMilliseconds / 1000);
+
+ this.lastResizeTimestamp = now;
+
+ this.SyncSizeFromWindow();
+
+ if (ShouldPaintSynchronouslyForResize(
+ inResizeBurst,
+ this.showCompleted,
+ this.isInsidePaint,
+ this.hasClosed,
+ this.webGpuLayer?.IsWebGpuInitialized ?? false))
+ {
+ // DispatchEvent's catch would swallow a paint failure into a log line; report it the way
+ // the loop's own paint would have.
+ try
+ {
+ this.PaintFrame();
+ }
+ catch (Exception ex)
+ {
+ UiThread.ReportUnhandledException(ex);
+ Console.Error.WriteLine($"X11SystemWindow resize paint threw {ex}");
+ }
+ }
+ }
+
+ private unsafe void HandleClientMessage(ref XClientMessageEvent clientMessage)
+ {
+ if (clientMessage.MessageType != wmProtocolsAtom || clientMessage.Format != 32)
+ {
+ return;
+ }
+
+ if ((ulong)clientMessage.Data[0] != wmDeleteWindowAtom)
+ {
+ return;
+ }
+
+ this.HandleCloseRequest();
+ }
+
+ ///
+ /// Runs a close request - the window manager's close button, or - against the
+ /// application, and destroys the native window if it is allowed to.
+ ///
+ private void HandleCloseRequest()
+ {
+ bool mayClose = HandlePlatformCloseRequest(
+ SingleWindowMode,
+ this.WindowProvider,
+ this.aggSystemWindow,
+ closing => this.platformAlreadyClosing = closing);
+
+ if (mayClose)
+ {
+ this.DestroyNativeWindow();
+ }
+ }
+
+ // -----------------------------------------------------------------------------------------
+ // Input translation
+ // -----------------------------------------------------------------------------------------
+
+ ///
+ /// Re-derives the held modifiers from the live keyboard now that this window has the focus again.
+ ///
+ ///
+ /// The counterpart to . Every modifier change that happened while
+ /// another window had the keyboard was delivered to that window, and a user genuinely can be holding
+ /// a modifier at the moment focus returns - releasing an Alt-Tab commonly leaves Alt down for a beat
+ /// over the newly focused window. Without this the first drag back would be wrong in the opposite
+ /// direction, with a held modifier this window never heard about.
+ ///
+ /// The one guarded handler of the pair, because it is the only one that polls the real
+ /// keyboard rather than reacting to an event about it - the same reasoning as
+ /// MacSystemWindow.HandleDidBecomeKey. Both conditions say the same thing from different
+ /// directions: EnablePlatformWindowInput off means a run has asked that the real machine not
+ /// perturb it, and ' contract is that once a synthetic event has
+ /// declared what it is holding the real keyboard is never read again. Answering "nothing is held"
+ /// from a machine with no user at it would overwrite the synthetic state with a lie.
+ ///
+ private void HandleFocusGained(ref XFocusChangeEvent focusEvent)
+ {
+ if (!IsRealFocusChange(focusEvent.Mode, focusEvent.Detail))
+ {
+ return;
+ }
+
+ if (!IPlatformWindow.EnablePlatformWindowInput || this.modifiersOverridden)
+ {
+ return;
+ }
+
+ if (display == IntPtr.Zero || this.window == X11.None)
+ {
+ return;
+ }
+
+ // The live "what is held right now", which is what is wanted when no event told us. Its return
+ // value is deliberately ignored: it reports false only when the pointer is on another screen,
+ // which invalidates the coordinates and not the modifier mask. Same call, same moment and same
+ // purpose as the mac host's +[NSEvent modifierFlags].
+ Xlib.XQueryPointer(
+ display,
+ this.window,
+ out _,
+ out _,
+ out _,
+ out _,
+ out _,
+ out _,
+ out uint mask);
+
+ this.lastModifierState = mask & X11.AllModifierMask;
+ this.appliedModifierKeys = ApplyModifierFlagsToKeyboard(this.lastModifierState);
+ }
+
+ ///
+ /// Releases the modifiers this window put down, because it no longer has the keyboard and can no
+ /// longer be told they were let go of.
+ ///
+ ///
+ /// X11 delivers a key event only to the focus window, so a modifier released while another
+ /// application is focused is never reported here and stays latched down forever. Alt-Tab is the
+ /// everyday case - it begins with Alt held - and the symptom is that coming back to the
+ /// application leaves the 3D view convinced a modifier is down, so a plain left drag pans instead of
+ /// selecting.
+ ///
+ /// Deliberately unguarded, unlike : this is the exact inverse of what
+ /// this window applied and can touch nothing else, so there is no synthetic state for it to damage.
+ /// Never Keyboard.Clear() - see .
+ ///
+ private void HandleFocusLost(ref XFocusChangeEvent focusEvent)
+ {
+ if (!IsRealFocusChange(focusEvent.Mode, focusEvent.Detail))
+ {
+ return;
+ }
+
+ this.lastModifierState = ReleaseAppliedModifierKeys(this.appliedModifierKeys);
+ this.appliedModifierKeys = NoModifierKeys;
+
+ // The keyboard is gone, and with it any hope of hearing the release that ends a drag in flight -
+ // a button up delivered to whoever has the input now is a button this window would hold captured
+ // forever, and a pointer grab held by a window that is not even focused is a desktop that feels
+ // broken. Both go here rather than waiting for an up that is not coming.
+ this.ReleasePointerGrab();
+ this.mouseCapture.ClearCapturedButtons();
+ }
+
+ ///
+ /// Whether a focus event means this window really gained or lost the keyboard.
+ ///
+ ///
+ /// Two different impostors have to be turned away, and they are spelled in two different fields.
+ ///
+ /// The mode catches a grab: taking or dropping one synthesises a FocusOut/FocusIn pair "as if
+ /// the focus warped", the same way it synthesises crossing events. Acting on those releases the held
+ /// modifiers in the middle of a gesture that is still running - the keyboard half of the bug
+ /// exists for on the pointer side.
+ ///
+ /// The detail catches focus-follows-mouse. On a desktop where the focus is PointerRoot, every
+ /// crossing of every window produces a FocusIn/FocusOut pair with detail Pointer or PointerRoot -
+ /// they say "the keyboard goes wherever the pointer is", not "this window lost it". A drag that
+ /// leaves the window (which is normal, and is what the pointer grab exists to support) would
+ /// otherwise release every modifier mid-gesture, so the user's Ctrl-drag becomes a plain drag halfway
+ /// through. NotifyDetailNone rides along above the same threshold: it is the focus becoming None,
+ /// which is a transient the window manager passes through and not a window this one lost to.
+ ///
+ private static bool IsRealFocusChange(int mode, int detail)
+ => (mode == X11.NotifyNormal || mode == X11.NotifyWhileGrabbed)
+ && detail < X11.NotifyPointer;
+
+ ///
+ /// Translates a pointer or keyboard event into agg's events: the button and keysym mapping, the Y
+ /// flip, the wheel conventions and the out-of-view drag capture.
+ ///
+ private unsafe void HandleInputEvent(ref XEvent nextEvent)
+ {
+ switch (nextEvent.Type)
+ {
+ case X11.ButtonPress:
+ this.HandleButton(ref nextEvent.As(), pressed: true);
+ return;
+
+ case X11.ButtonRelease:
+ this.HandleButton(ref nextEvent.As(), pressed: false);
+ return;
+
+ case X11.MotionNotify:
+ this.HandleMotion(ref nextEvent.As());
+ return;
+
+ case X11.EnterNotify:
+ case X11.LeaveNotify:
+ this.HandleCrossing(ref nextEvent.As());
+ return;
+
+ case X11.KeyPress:
+ this.HandleKeyPress(ref nextEvent.As());
+ return;
+
+ case X11.KeyRelease:
+ this.HandleKeyRelease(ref nextEvent.As());
+ return;
+
+ default:
+ return;
+ }
+ }
+
+ ///
+ /// Turns a ButtonPress or ButtonRelease into a mouse down, a mouse up or a wheel event.
+ ///
+ private void HandleButton(ref XButtonEvent buttonEvent, bool pressed)
+ {
+ this.lastModifierState = buttonEvent.State & X11.AllModifierMask;
+
+ bool insideWindow = IsInsideBounds(buttonEvent.X, buttonEvent.Y, this.pixelWidth, this.pixelHeight);
+
+ // The state word is the state *before* the event, so a press's own button is not in it yet and has
+ // to be put there or the reconcile would immediately drop the button just captured.
+ uint heldButtons = buttonEvent.State;
+ if (pressed)
+ {
+ heldButtons |= ButtonStateMaskForButtonNumber(buttonEvent.Button);
+ }
+
+ this.mouseCapture.ReconcileWithButtonState(heldButtons);
+
+ try
+ {
+ // 4 to 7 are not buttons at all. X11 has no wheel event, so a detent is a press/release pair on
+ // a synthetic button - which is why a wheel-only device still reports "buttons". Only the press
+ // carries the notch (delivering the release as well would double every scroll), and none of
+ // these is a button a drag can capture.
+ if (buttonEvent.Button >= X11.Button4 && buttonEvent.Button <= X11.Button7)
+ {
+ if (pressed && insideWindow)
+ {
+ var wheelArgs = new MouseEventArgs(
+ MouseButtons.None,
+ 0,
+ buttonEvent.X,
+ FlipY(buttonEvent.Y, this.pixelHeight),
+ 0);
+
+ ApplyButtonWheelDeltas(wheelArgs, buttonEvent.Button);
+
+ this.aggSystemWindow.OnMouseWheel(wheelArgs);
+ }
+
+ return;
+ }
+
+ MouseButtons button = TranslateButton(buttonEvent.Button);
+ if (button == MouseButtons.None)
+ {
+ // Buttons 8 and 9 are the thumb "back" and "forward" buttons on most mice. agg has no
+ // MouseButtons for them, and reporting them as some other button would be worse than not
+ // reporting them at all.
+ return;
+ }
+
+ if (!this.mouseCapture.ShouldDeliver(
+ pressed ? X11.ButtonPress : X11.ButtonRelease,
+ button,
+ insideWindow))
+ {
+ return;
+ }
+
+ // A mouse up reports the click count of the press it ends. That is AppKit's behaviour, which
+ // this host matches deliberately: WinForms reports 1 on every mouse up and puts the 2 only on
+ // the second mouse down. Carrying it on the up as well is what lets a widget act on a double
+ // click at the end of the gesture rather than the start.
+ int clicks = pressed
+ ? this.clickCounter.CountPress(buttonEvent.Button, buttonEvent.Time, buttonEvent.X, buttonEvent.Y)
+ : this.clickCounter.LastClickCount;
+
+ // Deliberately not clamped to the window: a drag that ran past the edge should reach the widget
+ // with where the pointer really is, so that dragging out and back does not look like a jump to
+ // the edge and stop.
+ var args = new MouseEventArgs(
+ button,
+ clicks,
+ buttonEvent.X,
+ FlipY(buttonEvent.Y, this.pixelHeight),
+ 0);
+
+ if (pressed)
+ {
+ this.aggSystemWindow.OnMouseDown(args);
+ }
+ else
+ {
+ this.aggSystemWindow.OnMouseUp(args);
+ }
+ }
+ finally
+ {
+ // In a finally, and that is the whole point of the try. This runs after ShouldDeliver, which is
+ // what owns the captured-button set, so it cannot disagree with the filter about whether a drag
+ // is still in flight - but the delivery between them is arbitrary application code. A widget
+ // that throws out of its OnMouseUp would otherwise leave this process holding the X pointer
+ // grab with no button down and no further up coming, which is a desktop the user cannot click
+ // their way out of.
+ this.SyncPointerGrab(buttonEvent.Time);
+ }
+ }
+
+ private void HandleMotion(ref XMotionEvent motionEvent)
+ {
+ this.lastModifierState = motionEvent.State & X11.AllModifierMask;
+
+ // A motion event's state is current, so this is the cheapest and most frequent chance to notice a
+ // button release that never reached us.
+ this.mouseCapture.ReconcileWithButtonState(motionEvent.State);
+
+ try
+ {
+ bool insideWindow = IsInsideBounds(motionEvent.X, motionEvent.Y, this.pixelWidth, this.pixelHeight);
+ MouseButtons button = TranslateButtonState(motionEvent.State);
+
+ if (!this.mouseCapture.ShouldDeliver(X11.MotionNotify, button, insideWindow))
+ {
+ return;
+ }
+
+ this.aggSystemWindow.OnMouseMove(new MouseEventArgs(
+ button,
+ 0,
+ motionEvent.X,
+ FlipY(motionEvent.Y, this.pixelHeight),
+ 0));
+ }
+ finally
+ {
+ this.SyncPointerGrab(motionEvent.Time);
+ }
+ }
+
+ ///
+ /// Turns an EnterNotify into a move, and a LeaveNotify into the pointer-gone sentinel -
+ /// but only when the geometry proves the pointer really left. See .
+ ///
+ private void HandleCrossing(ref XCrossingEvent crossingEvent)
+ {
+ this.lastModifierState = crossingEvent.State & X11.AllModifierMask;
+
+ this.mouseCapture.ReconcileWithButtonState(crossingEvent.State);
+
+ try
+ {
+ if (crossingEvent.Type == X11.EnterNotify)
+ {
+ // Filtered on the mode for the same reason a leave is: a grab manufactures an enter the
+ // pointer never made, and turning that into a move would report the pointer arriving
+ // somewhere it has been sitting all along - which for a widget mid-drag reads as a jump.
+ if (crossingEvent.Mode != X11.NotifyNormal)
+ {
+ return;
+ }
+
+ bool insideWindow = IsInsideBounds(
+ crossingEvent.X,
+ crossingEvent.Y,
+ this.pixelWidth,
+ this.pixelHeight);
+
+ MouseButtons button = TranslateButtonState(crossingEvent.State);
+
+ // Through the same filter as a move, because that is what it becomes. An enter carrying a
+ // button whose press this window never saw is somebody else's drag arriving here, and it is
+ // no more ours than the motion events behind it.
+ if (!this.mouseCapture.ShouldDeliver(X11.MotionNotify, button, insideWindow))
+ {
+ return;
+ }
+
+ // agg has no enter event of its own: a move at the entry point is what makes the widget
+ // under the pointer light up, which is what WinForms' MouseEnter ends up doing too.
+ this.aggSystemWindow.OnMouseMove(new MouseEventArgs(
+ button,
+ 0,
+ crossingEvent.X,
+ FlipY(crossingEvent.Y, this.pixelHeight),
+ 0));
+
+ return;
+ }
+
+ if (IsRealPointerExit(
+ crossingEvent.X,
+ crossingEvent.Y,
+ this.pixelWidth,
+ this.pixelHeight,
+ this.mouseCapture.HasCapturedButtons,
+ crossingEvent.Mode))
+ {
+ // The same sentinel the Windows sink and the mac host use for "the pointer is nowhere near me".
+ this.aggSystemWindow.OnMouseMove(new MouseEventArgs(MouseButtons.None, 0, -10, -10, 0));
+ }
+ }
+ finally
+ {
+ this.SyncPointerGrab(crossingEvent.Time);
+ }
+ }
+
+ private unsafe void HandleKeyPress(ref XKeyEvent keyEvent)
+ {
+ // XLookupString answers twice over: the keysym (the symbol the key produces under the active
+ // layout, which is what agg's Keys maps onto) and the bytes the key types, in the process
+ // locale's encoding - which BootstrapDisplay's setlocale is what makes UTF-8. 32 bytes is far
+ // more than one key can produce without an input method.
+ byte* typedBytes = stackalloc byte[32];
+ int byteCount = Xlib.XLookupString(ref keyEvent, typedBytes, 32, out ulong keysym, IntPtr.Zero);
+
+ this.TrackModifierState(keyEvent.State, keysym, pressed: true);
+
+ // From the corrected state and not from keyEvent.State, because for a bare modifier the two
+ // disagree: X11's state word is the state before its own event, so pressing Shift arrives with no
+ // ShiftMask set and the args would say Shift is not held at the exact moment it was pressed. Every
+ // other key is unaffected - TrackModifierState only corrects a modifier keysym - so this costs
+ // nothing and makes a Shift down report Shift.
+ var keyArgs = MakeKeyEventArgs(keysym, this.lastModifierState);
+
+ this.aggSystemWindow.OnKeyDown(keyArgs);
+ Keyboard.SetKeyDownState(keyArgs.KeyCode, true);
+
+ // A Control chord is a shortcut, never text: typing Ctrl+S must not also insert an "s" into
+ // whatever has focus. (XLookupString would hand back the C0 control character for it, 0x13, which
+ // is worse than nothing.) The mac host makes the same cut on Command.
+ if (keyArgs.SuppressKeyPress || (keyEvent.State & X11.ControlMask) != 0)
+ {
+ return;
+ }
+
+ if (byteCount <= 0)
+ {
+ return;
+ }
+
+ // Every character is forwarded, control characters included, because that is exactly what the
+ // Windows sink does - WM_CHAR delivers \b, \t and \r and WinformsEventSink passes them straight
+ // through. InternalTextEditWidget is where the filtering lives (it ignores everything under 32
+ // except \r and \t), so a host that filtered here would be second-guessing the widget and would
+ // silently differ from Windows. Nothing is skipped for being in a private-use range either: that
+ // is an AppKit quirk, and on X11 a named key produces no text at all.
+ string typed = Encoding.UTF8.GetString(typedBytes, byteCount);
+ foreach (char character in typed)
+ {
+ this.aggSystemWindow.OnKeyPress(new KeyPressEventArgs(character));
+ }
+ }
+
+ private unsafe void HandleKeyRelease(ref XKeyEvent keyEvent)
+ {
+ if (IsAutoRepeatRelease(ref keyEvent))
+ {
+ return;
+ }
+
+ // The text this produces is thrown away - a release types nothing - but the keysym has to come
+ // from the same lookup the press used or a shifted key would resolve differently on the way up.
+ byte* typedBytes = stackalloc byte[32];
+ Xlib.XLookupString(ref keyEvent, typedBytes, 32, out ulong keysym, IntPtr.Zero);
+
+ // Computed into a local rather than read back off lastModifierState, because the state has to be
+ // corrected for this event before the args are built but written into the field only after the
+ // down-state read below. Releasing Shift must report Shift as no longer held, the mirror of the
+ // press correction in HandleKeyPress.
+ uint stateAfterThisKey = StateAfterModifierKey(keyEvent.State, keysym, pressed: false);
+
+ var keyArgs = MakeKeyEventArgs(keysym, stateAfterThisKey);
+
+ // Read before TrackModifierState, and that ordering is the whole of a bug worth naming. Only if
+ // we saw the down, matching the Windows sink and the mac host: a dialog that closed on a key down
+ // hands us back the key up for it, and a widget told about an up it never saw a down for will act
+ // on it. But for a bare modifier the key being released and the modifier state being updated are
+ // the same event on X11 - unlike AppKit, where flagsChanged is separate from keyUp - so
+ // TrackModifierState clears exactly the down state this test reads. Asking afterwards means every
+ // modifier release answers "it was not down" and no OnKeyUp for Shift, Control or Alt is ever
+ // delivered. Observed: Shift+Tab produced a ShiftKey down with no matching up.
+ bool sawTheKeyDown = Keyboard.IsKeyDown(keyArgs.KeyCode);
+
+ this.TrackModifierState(keyEvent.State, keysym, pressed: false);
+
+ if (sawTheKeyDown)
+ {
+ this.aggSystemWindow.OnKeyUp(keyArgs);
+ Keyboard.SetKeyDownState(keyArgs.KeyCode, false);
+ }
+ }
+
+ ///
+ /// Whether a KeyRelease is the first half of an autorepeat rather than the user letting go.
+ ///
+ ///
+ /// X11 spells a held key as a KeyRelease immediately followed by a KeyPress carrying the same
+ /// keycode and the same timestamp - the server has no "this is a repeat" flag, and the one
+ /// way to ask for one (XkbSetDetectableAutoRepeat) is a per-connection server setting this
+ /// host would then own on behalf of every library sharing the display. Peeking at the next event
+ /// instead costs nothing: the pair is already decoded in Xlib's queue by the time the release is
+ /// handled, because they arrive in one batch.
+ ///
+ /// Swallowing the release is what turns a held key into repeated KeyDown/KeyPress, which is what
+ /// Windows and macOS both deliver. Letting it through makes a held arrow key a stream of down/up
+ /// pairs, with 's down state flickering off between each one - so anything that
+ /// asks "is this key held" during a repeat gets the wrong answer half the time.
+ ///
+ /// Known limits, and why they are acceptable. A peek of exactly one event can be wrong in two
+ /// directions, and both fail the same safe way. The repeat's KeyPress can be pushed back a slot by an
+ /// unrelated event the server interleaved between the pair (a MotionNotify from a mouse being moved
+ /// during the repeat is the realistic one), and the pair can be split across two reads when the
+ /// release is the last event decoded and its KeyPress has not arrived from the socket yet. Either way
+ /// the release is not recognised, is delivered, and the repeat's press follows it - so the symptom is
+ /// a spurious up/down pair inside a repeat, which reads as a key being retyped. It is never a stuck
+ /// key: the release is delivered rather than lost, so the down state cannot be left latched. Fixing
+ /// it properly means draining and reordering the queue, or owning
+ /// XkbSetDetectableAutoRepeat for the whole process; neither is worth it for a glitch that
+ /// costs a repeated character.
+ ///
+ private static unsafe bool IsAutoRepeatRelease(ref XKeyEvent releaseEvent)
+ {
+ // XPeekEvent blocks when the queue is empty, so the count has to be in hand first.
+ if (display == IntPtr.Zero || Xlib.XPending(display) <= 0)
+ {
+ return false;
+ }
+
+ Xlib.XPeekEvent(display, out XEvent nextEvent);
+
+ if (nextEvent.Type != X11.KeyPress)
+ {
+ return false;
+ }
+
+ ref XKeyEvent nextKey = ref nextEvent.As();
+
+ return nextKey.Window == releaseEvent.Window
+ && nextKey.Keycode == releaseEvent.Keycode
+ && nextKey.Time == releaseEvent.Time;
+ }
+
+ ///
+ /// Remembers what the keyboard is holding after this key event, and - for a bare modifier - writes it
+ /// into .
+ ///
+ ///
+ /// Restricted to modifier keysyms on purpose. Writing the modifier down state on every key press
+ /// would overwrite what an automation run put there directly (it sets Shift down and then sends a
+ /// key), which is the same reason the mac host only writes it from flagsChanged.
+ ///
+ private void TrackModifierState(uint state, ulong keysym, bool pressed)
+ {
+ this.lastModifierState = StateAfterModifierKey(state, keysym, pressed);
+
+ if (ModifierMaskForKeySym(keysym) != 0)
+ {
+ this.appliedModifierKeys = ApplyModifierFlagsToKeyboard(this.lastModifierState);
+ }
+ }
+
+ // -----------------------------------------------------------------------------------------
+ // Pointer grab
+ // -----------------------------------------------------------------------------------------
+
+ ///
+ /// Takes or releases the X pointer grab so that it matches whether a drag this window owns is in
+ /// flight.
+ ///
+ ///
+ /// Why both this and . They solve the two halves of one
+ /// problem and neither is enough alone. Without the grab, X11 simply does not deliver motion or a
+ /// button release that happens outside the window - the events go to whatever window the pointer is
+ /// over, and a drag that leaves the window goes silent mid-gesture and its up never arrives, leaving
+ /// the widget convinced its button is still held. The grab is what makes those events exist here at
+ /// all. But a grab with owner_events true also hands this window events that are not
+ /// its business, and it does not distinguish a drag that started inside from a press that did not, so
+ /// the filter is still what decides which of the delivered events agg should see. AppKit needs only
+ /// the filter because it routes a drag to the window that saw the down for free; X11 has no such
+ /// rule, which is why this half exists here and not there.
+ ///
+ /// owner_events is true so that events over this window keep being reported in this window's
+ /// coordinates rather than being forced through the grab window - which is the same window here, but
+ /// the setting is also what keeps the grab from swallowing events other windows of this client
+ /// should get. Both modes are Async: a Sync grab freezes the device after every event until
+ /// XAllowEvents lets the next one through, which in a single-threaded pump is a deadlock
+ /// waiting to happen.
+ ///
+ /// The timestamp of the event asking for this, so a stale request is refused by
+ /// the server rather than grabbing on something the user has since finished doing.
+ private void SyncPointerGrab(ulong time)
+ {
+ if (display == IntPtr.Zero || this.window == X11.None)
+ {
+ return;
+ }
+
+ if (this.mouseCapture.HasCapturedButtons)
+ {
+ if (!this.pointerGrabbed)
+ {
+ int result = Xlib.XGrabPointer(
+ display,
+ this.window,
+ X11.True,
+ (uint)(X11.ButtonPressMask | X11.ButtonReleaseMask | X11.PointerMotionMask),
+ X11.GrabModeAsync,
+ X11.GrabModeAsync,
+ X11.None,
+ X11.None,
+ time);
+
+ // A refusal (another client already holds the pointer) is not fatal: the drag still works
+ // inside the window, it just goes quiet if the pointer leaves. Recording the failure is
+ // what keeps the release from ungrabbing a grab somebody else owns.
+ this.pointerGrabbed = result == X11.GrabSuccess;
+ }
+
+ return;
+ }
+
+ this.ReleasePointerGrab();
+ }
+
+ ///
+ /// Drops the pointer grab if this window holds one. Flushed rather than left in the output buffer:
+ /// until the ungrab reaches the server every other client's pointer input is still being routed here,
+ /// so a delay of even one pump pass is a desktop that feels stuck.
+ ///
+ private void ReleasePointerGrab()
+ {
+ if (!this.pointerGrabbed)
+ {
+ return;
+ }
+
+ this.pointerGrabbed = false;
+
+ if (display != IntPtr.Zero)
+ {
+ Xlib.XUngrabPointer(display, X11.CurrentTime);
+ Xlib.XFlush(display);
+ }
+ }
+
+ // -----------------------------------------------------------------------------------------
+ // The pure parts of the translation - no Xlib, so they can be tested without a server
+ // -----------------------------------------------------------------------------------------
+
+ ///
+ /// Converts an X11 event's Y into agg's.
+ ///
+ ///
+ /// The one conversion X11 needs and macOS does not. X11's origin is the top-left with Y
+ /// increasing downwards, which is Win32's convention and not agg's - a non-flipped NSView is already
+ /// bottom-left, which is why MacSystemWindow has no flip at all and copying its absence here
+ /// would put every click on the wrong half of the window.
+ ///
+ /// height - y and not height - 1 - y, which is WinformsEventSink's convention
+ /// exactly ((int)widgetToSendTo.Height - y). The off-by-one is only apparent: agg's bounds are
+ /// a closed interval, so a window of height H spans y = 0 through y = H rather than H-1, and this is
+ /// the mapping the rest of the stack is built around. AutomationRunner converts the other way
+ /// with the same Height - y, so a synthetic click round-trips exactly; subtracting one here
+ /// would land every automated click one pixel low.
+ ///
+ internal static double FlipY(int eventY, uint pixelHeight) => (double)pixelHeight - eventY;
+
+ /// Maps an X11 button number onto agg's .
+ /// for a button agg has no name for.
+ internal static MouseButtons TranslateButton(uint button) => button switch
+ {
+ X11.Button1 => MouseButtons.Left,
+
+ // X11 numbers the buttons by physical position, so 2 is the middle one and 3 is the right one.
+ // Win32 and AppKit both number them by role instead, which is why this pair looks transposed.
+ X11.Button2 => MouseButtons.Middle,
+ X11.Button3 => MouseButtons.Right,
+ _ => MouseButtons.None,
+ };
+
+ /// The state-word bit that is set while an X11 button number is held.
+ /// Zero for a button with no bit - the wheel's 6 and 7, and the thumb buttons.
+ internal static uint ButtonStateMaskForButtonNumber(uint button) => button switch
+ {
+ X11.Button1 => X11.Button1Mask,
+ X11.Button2 => X11.Button2Mask,
+ X11.Button3 => X11.Button3Mask,
+ _ => 0,
+ };
+
+ /// The state-word bit that is set while an agg button is held.
+ internal static uint ButtonStateMaskFor(MouseButtons button) => button switch
+ {
+ MouseButtons.Left => X11.Button1Mask,
+ MouseButtons.Middle => X11.Button2Mask,
+ MouseButtons.Right => X11.Button3Mask,
+ _ => 0,
+ };
+
+ ///
+ /// The button a motion or crossing event is carrying, from the button half of its state word.
+ ///
+ ///
+ /// A state word can name several buttons at once, but is one
+ /// value and not a flag set, so one has to win. Left first, then right, then middle: that is the
+ /// order of how likely a widget is to be mid-gesture on it, and it matches what WinForms reports for
+ /// a move while more than one button is down.
+ ///
+ internal static MouseButtons TranslateButtonState(uint state)
+ {
+ if ((state & X11.Button1Mask) != 0)
+ {
+ return MouseButtons.Left;
+ }
+
+ if ((state & X11.Button3Mask) != 0)
+ {
+ return MouseButtons.Right;
+ }
+
+ if ((state & X11.Button2Mask) != 0)
+ {
+ return MouseButtons.Middle;
+ }
+
+ return MouseButtons.None;
+ }
+
+ ///
+ /// Fills a wheel event's axes from the synthetic button the detent arrived on.
+ ///
+ ///
+ /// Buttons 4 and 5 are the wheel forward and back; 6 and 7 are the horizontal pair a tilt wheel (or a
+ /// touchpad driver emulating one) sends. The vertical sign is agg's existing convention - forward is
+ /// positive, which every consumer reads as zoom in or scroll up. The horizontal sign follows
+ /// 's: positive means the content should move right,
+ /// revealing what is off the left edge, which is what a leftward tilt (button 6) asks for.
+ ///
+ /// Never a precise scroll. A detent carries no distance at all - it is one click - so the consumer
+ /// picks its own step, exactly as on Windows. That is also why no DPI is applied here: this is the
+ /// one place a precise scroll would need it, and X11 has no precise scroll to give. (A high
+ /// resolution wheel reports through XInput2 valuators, which this host does not use.)
+ ///
+ internal static void ApplyButtonWheelDeltas(MouseEventArgs args, uint button)
+ {
+ switch (button)
+ {
+ case X11.Button4:
+ args.WheelDelta = WheelDeltaPerDetent;
+ break;
+
+ case X11.Button5:
+ args.WheelDelta = -WheelDeltaPerDetent;
+ break;
+
+ case X11.Button6:
+ args.WheelDeltaX = WheelDeltaPerDetent;
+ break;
+
+ case X11.Button7:
+ args.WheelDeltaX = -WheelDeltaPerDetent;
+ break;
+ }
+
+ args.WheelDeltaIsPreciseScroll = false;
+ }
+
+ ///
+ /// Whether a point in window coordinates lies within the window.
+ ///
+ ///
+ /// Exclusive on the far edges, unlike the mac host's inclusive version. That is not a change of mind
+ /// but a change of units: AppKit reports a point in a continuous coordinate space where the bounds
+ /// width is the right edge, while an X11 coordinate is an integer pixel index and a window
+ /// of width W has columns 0 through W-1. A pointer leaving to the right reports exactly W, so an
+ /// inclusive test would call a real exit "inside" and the pointer-gone sentinel would never fire.
+ ///
+ internal static bool IsInsideBounds(int x, int y, uint width, uint height)
+ => x >= 0 && y >= 0 && x < (int)width && y < (int)height;
+
+ ///
+ /// Whether a LeaveNotify means the pointer actually left the window.
+ ///
+ ///
+ /// The event type on its own does not mean that, which is the trap this exists for - the same trap
+ /// the mac host hits with cursor-rect rebuilds, reached by a different route. On X11 the artifact is
+ /// a grab: taking or dropping a pointer grab synthesises a LeaveNotify/EnterNotify pair "as if the
+ /// pointer warped", so a host that believes every leave fires the pointer-gone sentinel every time a
+ /// drag begins. The mode field is what tells those apart, and the geometry is what catches the
+ /// rest.
+ ///
+ /// A drag holding a captured button is exempt as well: it owns the pointer wherever it has gone, and
+ /// its button release is what ends it. Getting this wrong is what makes MatterCAD's 3D view snap a
+ /// dragged part back to where the drag started.
+ ///
+ internal static bool IsRealPointerExit(int x, int y, uint width, uint height, bool dragInFlight, int mode)
+ => !dragInFlight
+ && mode == X11.NotifyNormal
+ && !IsInsideBounds(x, y, width, height);
+
+ ///
+ /// Composes the agg key event a KeyPress or KeyRelease carries, from the two parts of
+ /// the X event that determine it.
+ ///
+ ///
+ /// Pure - no Xlib calls, no state - so the whole key translation can be exercised without a server,
+ /// in the same spirit as MacSystemWindow.MakeKeyEventArgs.
+ ///
+ /// What XLookupString resolved the keycode to under the active layout.
+ /// The event's state word; only its modifier half is read.
+ internal static KeyEventArgs MakeKeyEventArgs(ulong keysym, uint state)
+ => new KeyEventArgs(TranslateKeySym(keysym) | TranslateModifiers(state));
+
+ ///
+ /// Maps an X11 keysym onto agg's .
+ ///
+ ///
+ /// A keysym and not a keycode, because a keycode is a hardware position - "where S sits on a US
+ /// layout" is another letter on an AZERTY one - and every agg shortcut is spelled as a key
+ /// (Ctrl+S, Ctrl+Z). The keysym is what the active layout says that position produces, which is the
+ /// thing worth matching on.
+ ///
+ /// Case is folded, which is what makes Shift+Z and z the same : the keysym for a
+ /// shifted letter is the uppercase one, and WinForms reports the same key code either way.
+ ///
+ /// for a keysym agg has no key for - a dead key, a media key, a
+ /// letter outside Latin-1. The modifiers still ride along on the event; see
+ /// .
+ internal static Keys TranslateKeySym(ulong keysym)
+ {
+ // Latin-1 needs no table at all: keysyms 0x20 to 0xFF are exactly their ISO 8859-1 code points,
+ // which is what lets every letter, digit and punctuation key be answered by looking at the
+ // character it is.
+ if (keysym >= X11.XK_space && keysym <= 0x00FF)
+ {
+ return TranslateLatin1KeySym((char)keysym);
+ }
+
+ // Both of these are contiguous blocks in keysymdef.h, so they are ranges rather than 22 cases.
+ if (keysym >= X11.XK_F1 && keysym <= X11.XK_F12)
+ {
+ return Keys.F1 + (int)(keysym - X11.XK_F1);
+ }
+
+ if (keysym >= X11.XK_KP_0 && keysym <= X11.XK_KP_9)
+ {
+ return Keys.NumPad0 + (int)(keysym - X11.XK_KP_0);
+ }
+
+ return keysym switch
+ {
+ X11.XK_BackSpace => Keys.Back,
+
+ // XK_ISO_Left_Tab is not an obscure corner: it is what an ordinary Shift+Tab produces, and a
+ // host that does not name it loses back-tab navigation entirely.
+ X11.XK_Tab or X11.XK_ISO_Left_Tab or X11.XK_KP_Tab => Keys.Tab,
+ X11.XK_Return or X11.XK_KP_Enter => Keys.Enter,
+ X11.XK_Escape => Keys.Escape,
+ X11.XK_Delete or X11.XK_KP_Delete => Keys.Delete,
+ X11.XK_Insert or X11.XK_KP_Insert => Keys.Insert,
+ X11.XK_Home or X11.XK_KP_Home => Keys.Home,
+ X11.XK_End or X11.XK_KP_End => Keys.End,
+ X11.XK_Page_Up or X11.XK_KP_Page_Up => Keys.PageUp,
+ X11.XK_Page_Down or X11.XK_KP_Page_Down => Keys.PageDown,
+ X11.XK_Left or X11.XK_KP_Left => Keys.Left,
+ X11.XK_Up or X11.XK_KP_Up => Keys.Up,
+ X11.XK_Right or X11.XK_KP_Right => Keys.Right,
+ X11.XK_Down or X11.XK_KP_Down => Keys.Down,
+
+ // Keypad 5 with Num Lock off. VK_CLEAR is what Win32 calls the same key.
+ X11.XK_Begin or X11.XK_KP_Begin => Keys.Clear,
+
+ X11.XK_KP_Space => Keys.Space,
+ X11.XK_KP_Multiply => Keys.Multiply,
+ X11.XK_KP_Add => Keys.Add,
+ X11.XK_KP_Separator => Keys.Separator,
+ X11.XK_KP_Subtract => Keys.Subtract,
+ X11.XK_KP_Decimal => Keys.Decimal,
+ X11.XK_KP_Divide => Keys.Divide,
+
+ // agg has no keypad-equals; the main-row one is the nearest thing that means the same.
+ X11.XK_KP_Equal => Keys.Oemplus,
+
+ X11.XK_Pause => Keys.Pause,
+ X11.XK_Scroll_Lock => Keys.Scroll,
+ X11.XK_Num_Lock => Keys.NumLock,
+ X11.XK_Caps_Lock => Keys.CapsLock,
+ X11.XK_Print => Keys.PrintScreen,
+
+ // The context-menu key, which Win32 calls VK_APPS. Not agg's Keys.Menu, which is Alt.
+ X11.XK_Menu => Keys.Apps,
+
+ // A bare modifier is a real KeyPress/KeyRelease on X11, unlike AppKit's separate
+ // FlagsChanged, so these have to resolve to the physical key rather than to None.
+ X11.XK_Shift_L or X11.XK_Shift_R => Keys.ShiftKey,
+ X11.XK_Control_L or X11.XK_Control_R => Keys.ControlKey,
+
+ // Meta alongside Alt because a keyboard mapped the traditional way (and every Sun-derived
+ // layout) puts Meta where a PC keyboard puts Alt.
+ X11.XK_Alt_L or X11.XK_Alt_R or X11.XK_Meta_L or X11.XK_Meta_R => Keys.Menu,
+
+ X11.XK_Super_L => Keys.LWin,
+ X11.XK_Super_R => Keys.RWin,
+
+ _ => Keys.None,
+ };
+ }
+
+ ///
+ /// Maps a keysym that is its own Latin-1 character - every letter, digit, space and punctuation key -
+ /// onto agg's .
+ ///
+ ///
+ /// Each punctuation key is listed under both of its spellings, unshifted and shifted, because the
+ /// keysym Shift produces is the shifted symbol while WinForms reports one Oem key either way. Without
+ /// the second spelling Ctrl+Shift+= would be a different key from Ctrl+=.
+ ///
+ private static Keys TranslateLatin1KeySym(char keysymCharacter)
+ {
+ char upper = char.ToUpperInvariant(keysymCharacter);
+
+ if (upper >= 'A' && upper <= 'Z')
+ {
+ return Keys.A + (upper - 'A');
+ }
+
+ if (upper >= '0' && upper <= '9')
+ {
+ return Keys.D0 + (upper - '0');
+ }
+
+ switch (keysymCharacter)
+ {
+ case ' ':
+ return Keys.Space;
+
+ case ';':
+ case ':':
+ return Keys.OemSemicolon;
+
+ // The zoom shortcuts: Ctrl+= and Ctrl++ are one key, as are Ctrl+- and Ctrl+_.
+ case '=':
+ case '+':
+ return Keys.Oemplus;
+
+ case ',':
+ case '<':
+ return Keys.Oemcomma;
+
+ case '-':
+ case '_':
+ return Keys.OemMinus;
+
+ case '.':
+ case '>':
+ return Keys.OemPeriod;
+
+ case '/':
+ case '?':
+ return Keys.OemQuestion;
+
+ case '`':
+ case '~':
+ return Keys.Oemtilde;
+
+ case '[':
+ case '{':
+ return Keys.OemOpenBrackets;
+
+ case '\\':
+ case '|':
+ return Keys.OemPipe;
+
+ case ']':
+ case '}':
+ return Keys.OemCloseBrackets;
+
+ case '\'':
+ case '"':
+ return Keys.OemQuotes;
+
+ default:
+ return Keys.None;
+ }
+ }
+
+ ///
+ /// The X modifier mask a keysym is the key for, or zero when it is not a modifier.
+ ///
+ ///
+ /// Mod1 is Alt and Mod4 is Super only by convention - X11 itself only knows Mod1 through Mod5, and
+ /// which physical key sits on which is a property of the keymap. Every desktop in use follows this
+ /// convention, and reading the modifier map to find out for certain would be a round trip per key.
+ ///
+ internal static uint ModifierMaskForKeySym(ulong keysym) => keysym switch
+ {
+ X11.XK_Shift_L or X11.XK_Shift_R => X11.ShiftMask,
+ X11.XK_Control_L or X11.XK_Control_R => X11.ControlMask,
+ X11.XK_Alt_L or X11.XK_Alt_R or X11.XK_Meta_L or X11.XK_Meta_R => X11.Mod1Mask,
+ X11.XK_Super_L or X11.XK_Super_R => X11.Mod4Mask,
+ _ => 0,
+ };
+
+ ///
+ /// The modifier state after a key event, given the state word the event carries.
+ ///
+ ///
+ /// X11's state is the state before the event, which for an ordinary key is exactly
+ /// what is wanted (Shift+A reports Shift held) but for the modifier keys themselves is always one
+ /// event behind: the KeyPress of Shift carries no ShiftMask and its KeyRelease carries one. Applying
+ /// that word straight to would leave every modifier reported inverted for as
+ /// long as it is held.
+ ///
+ /// Caps Lock is deliberately not corrected. It toggles rather than latches, so neither press nor
+ /// release means what this function would compute - and agg has no modifier for it anyway, so
+ /// ignores LockMask entirely.
+ ///
+ internal static uint StateAfterModifierKey(uint state, ulong keysym, bool pressed)
+ {
+ uint modifierState = state & X11.AllModifierMask;
+ uint mask = ModifierMaskForKeySym(keysym);
+
+ if (mask == 0)
+ {
+ return modifierState;
+ }
+
+ return pressed ? modifierState | mask : modifierState & ~mask;
+ }
+
+ ///
+ /// Maps an X11 modifier state word onto the agg down-state keys it implies.
+ ///
+ ///
+ /// The answer is a set and not an OR'd value because ShiftKey (16), ControlKey
+ /// (17) and Menu (18) are consecutive integers rather than disjoint bits - OR-ing them would produce
+ /// unrelated key codes. The modifier flags returns are
+ /// disjoint bits and do combine.
+ ///
+ /// Lock (Caps Lock), Mod2 (Num Lock), Mod4 (Super) and Mod5 (AltGr) are all deliberately absent: agg
+ /// has no modifier for any of them, and mistaking one for a modifier it does have would make a user
+ /// with Caps Lock on unable to click on anything normally.
+ ///
+ internal static IReadOnlySet ModifierDownStateKeys(uint state)
+ {
+ var downKeys = new HashSet();
+
+ if ((state & X11.ShiftMask) != 0)
+ {
+ downKeys.Add(Keys.ShiftKey);
+ }
+
+ if ((state & X11.ControlMask) != 0)
+ {
+ downKeys.Add(Keys.ControlKey);
+ }
+
+ if ((state & X11.Mod1Mask) != 0)
+ {
+ downKeys.Add(Keys.Menu);
+ }
+
+ return downKeys;
+ }
+
+ ///
+ /// The modifier bits agg carries on a and reports from
+ /// .
+ ///
+ ///
+ /// Expressed in terms of so the two cannot drift apart: what
+ /// Keyboard.IsKeyDown(Keys.Control) says and what says have to
+ /// agree, or a gesture that checks one and a shortcut that checks the other disagree about the same
+ /// keyboard.
+ ///
+ internal static Keys TranslateModifiers(uint state)
+ {
+ Keys modifiers = Keys.None;
+
+ foreach (Keys downKey in ModifierDownStateKeys(state))
+ {
+ // Unlike the down-state keys these are disjoint bits, so they OR cleanly.
+ modifiers |= downKey switch
+ {
+ Keys.ShiftKey => Keys.Shift,
+ Keys.ControlKey => Keys.Control,
+ Keys.Menu => Keys.Alt,
+ _ => Keys.None,
+ };
+ }
+
+ return modifiers;
+ }
+
+ ///
+ /// Puts the modifier down state a state word implies into , and reports the
+ /// keys it left held so can undo exactly those.
+ ///
+ ///
+ /// Every modifier is written on every call, including the ones being released. There is no "has this
+ /// changed?" test here on purpose: Keyboard.SetKeyDownState is idempotent and raises
+ /// StateChanged only on a real change, so the redundant writes cost nothing, and a test here could
+ /// only compare the physical spelling (ControlKey) while automation latches the fanned-out one
+ /// (Control) - it would conclude "no change" and leave the very latch this call exists to correct.
+ ///
+ internal static IReadOnlySet ApplyModifierFlagsToKeyboard(uint state)
+ {
+ IReadOnlySet shouldBeDown = ModifierDownStateKeys(state);
+ foreach (Keys modifierKey in ModifierStateKeys)
+ {
+ Keyboard.SetKeyDownState(modifierKey, shouldBeDown.Contains(modifierKey));
+ }
+
+ return shouldBeDown;
+ }
+
+ ///
+ /// Releases the modifier keys this window put into the down state, and reports the state word that
+ /// now describes what it is holding - nothing.
+ ///
+ ///
+ /// Narrow on purpose, where a Keyboard.Clear() would not be. is
+ /// process-wide and other callers write to it directly - an automation test sets Shift down and then
+ /// shift-clicks - so a blunt clear turns any incidental focus change into a dropped selection with no
+ /// visible cause. Releasing only what this window applied cannot reach anything it did not put there,
+ /// which is what lets run unguarded.
+ ///
+ internal static uint ReleaseAppliedModifierKeys(IReadOnlySet appliedModifierKeys)
+ {
+ foreach (Keys modifierKey in appliedModifierKeys)
+ {
+ Keyboard.SetKeyDownState(modifierKey, false);
+ }
+
+ return 0;
+ }
+
+ ///
+ /// Remembers which buttons went down inside the window, so a drag that wanders outside it still
+ /// delivers its moves and, critically, its button release.
+ ///
+ ///
+ /// A straight port of MacSystemWindow.OutOfViewMouseCapture, which is itself WinForms' implicit
+ /// capture written out by hand: a button is "ours" only if its press landed inside, and drags and the
+ /// matching release are then delivered wherever the pointer has gone. On X11 it works alongside
+ /// rather than instead of it - see that method for which half does what.
+ ///
+ /// A press that landed outside is never captured, which is what keeps a title-bar drag (whose press
+ /// agg never saw) from delivering a phantom release. Plain hover moves outside the window are still
+ /// dropped: with no button held they really are nobody's business.
+ ///
+ internal sealed class OutOfViewMouseCapture
+ {
+ // Not a bit set: MouseButtons is not [Flags], and more than one button can be held at once.
+ private readonly HashSet capturedButtons = new HashSet();
+
+ ///
+ /// Whether a drag this window owns is in flight, and so the pointer is its business wherever it is.
+ ///
+ internal bool HasCapturedButtons => this.capturedButtons.Count > 0;
+
+ ///
+ /// Drops any captured button the server no longer reports as held.
+ ///
+ ///
+ /// The recovery path, and X11 is the platform that needs one. The captured set is only ever
+ /// emptied by the button release that ends the drag, and there are ways for that release never to
+ /// arrive: the grab was refused because another client already held the pointer, or was broken by
+ /// a window manager taking one of its own mid-drag, or the button came up over another screen. A
+ /// button stuck in this set is a window that keeps claiming every move on the desktop is part of a
+ /// drag that ended minutes ago, and nothing else would ever clear it.
+ ///
+ /// Every mouse event carries the truth alongside the question, which is what makes this cheap:
+ /// the state word says which buttons are physically down right now, so the set can simply be
+ /// intersected with it on the way past. Note the caller has to correct for a press reporting the
+ /// state before itself, or this would drop the button being captured on the very event
+ /// that captures it.
+ ///
+ /// The button half of a state word, corrected for the event carrying it.
+ internal void ReconcileWithButtonState(uint heldButtonMask)
+ {
+ if (this.capturedButtons.Count == 0)
+ {
+ return;
+ }
+
+ this.capturedButtons.RemoveWhere(
+ captured => (heldButtonMask & ButtonStateMaskFor(captured)) == 0);
+ }
+
+ ///
+ /// Forgets every captured button, for the case where no release is coming at all - see
+ /// .
+ ///
+ internal void ClearCapturedButtons() => this.capturedButtons.Clear();
+
+ ///
+ /// Decides whether an event should reach agg, and updates the captured-button set.
+ ///
+ /// The X11 event type - ButtonPress, ButtonRelease or MotionNotify.
+ /// The agg button the event carries, or None for a hover.
+ /// Whether the event's point lies within the window.
+ internal bool ShouldDeliver(int eventType, MouseButtons button, bool insideWindow)
+ {
+ switch (eventType)
+ {
+ case X11.ButtonPress:
+ if (!insideWindow)
+ {
+ return false;
+ }
+
+ this.capturedButtons.Add(button);
+ return true;
+
+ case X11.ButtonRelease:
+ // Removed whether or not it is delivered, so a button can never stay captured.
+ bool wasCaptured = this.capturedButtons.Remove(button);
+ return insideWindow || wasCaptured;
+
+ case X11.MotionNotify:
+ // X11 has one motion event for both hover and drag - the button is what tells them
+ // apart, where AppKit has a separate event type. A hover outside is nobody's business;
+ // a drag outside is ours if its press was.
+ return insideWindow
+ || (button != MouseButtons.None && this.capturedButtons.Contains(button));
+
+ default:
+ return insideWindow;
+ }
+ }
+ }
+
+ ///
+ /// Turns a stream of button presses into single, double and triple clicks.
+ ///
+ ///
+ /// X11 has no click count. Win32 puts one on every WM_LBUTTONDBLCLK and AppKit puts one on every
+ /// event, but on X11 a double click is just two presses and every toolkit counts them itself, from
+ /// the timestamps the server does provide. Both thresholds have to be tested, not just the time: a
+ /// double click at two different places is two clicks, and using only the clock makes a fast user
+ /// clicking down a list select the wrong thing.
+ ///
+ /// The timestamps are the server's own clock (milliseconds since the server started), not this
+ /// process's - which is what makes the interval right even when the pump was stalled between the two
+ /// presses.
+ ///
+ internal sealed class ClickCounter
+ {
+ private uint lastButton;
+ private ulong lastTime;
+ private int lastX;
+ private int lastY;
+ private int clicks;
+
+ ///
+ /// The count the last press produced, which is what the matching release reports. Zero before any
+ /// press.
+ ///
+ internal int LastClickCount => this.clicks;
+
+ /// Counts a press and reports what click it is - 1, 2, 3 and up.
+ /// The X11 button number.
+ /// The event's server timestamp, in milliseconds.
+ /// The press position in window coordinates, unflipped - only the distance
+ /// between two of them is read, and that is the same either way round.
+ /// See .
+ internal int CountPress(uint button, ulong time, int x, int y)
+ {
+ bool continuesTheLastClick = this.clicks > 0
+ && button == this.lastButton
+
+ // The server's clock is milliseconds in a 32-bit field, so it wraps every 49.7 days. The
+ // ordering test is what keeps a wrap from being read as an enormous interval (harmless) or,
+ // on unsigned subtraction, an enormous one that underflows back into range (not).
+ && time >= this.lastTime
+ && (time - this.lastTime) <= DoubleClickMilliseconds
+ && Math.Abs(x - this.lastX) <= DoubleClickSlopPixels
+ && Math.Abs(y - this.lastY) <= DoubleClickSlopPixels;
+
+ this.clicks = continuesTheLastClick ? this.clicks + 1 : 1;
+
+ this.lastButton = button;
+ this.lastTime = time;
+ this.lastX = x;
+ this.lastY = y;
+
+ return this.clicks;
+ }
+ }
+
+ // -----------------------------------------------------------------------------------------
+ // Painting
+ // -----------------------------------------------------------------------------------------
+
+ private void PaintFrame()
+ {
+ this.needsRedraw = false;
+
+ if (this.aggSystemWindow == null
+ || this.aggSystemWindow.HasBeenClosed
+ || this.webGpuLayer == null
+ || this.webGpuLayer.IsDisposed)
+ {
+ return;
+ }
+
+ // An unattended run must fail loudly rather than sitting there: a paint that throws takes the
+ // repaint pump with it, so the run would otherwise just hang.
+ if (SmokeFrameTarget > 0)
+ {
+ try
+ {
+ this.DrawAndPresent();
+ }
+ catch (Exception ex)
+ {
+ Console.Error.WriteLine($"AGG_SMOKE paint failed on frame {this.drawCount}: {ex}");
+ Environment.ExitCode = 1;
+ this.smokeRunFinished = true;
+ UiThread.RunOnIdle(this.FinishSmokeRun);
+ }
+
+ return;
+ }
+
+ this.DrawAndPresent();
+ }
+
+ private void DrawAndPresent()
+ {
+ MatterHackers.RenderCore.FrameProfiler.BeginFrame();
+
+ if (this.pixelWidth > 0 && this.pixelHeight > 0)
+ {
+ this.drawCount++;
+ this.isInsidePaint = true;
+
+ try
+ {
+ Graphics2D graphics2D;
+ using (MatterHackers.RenderCore.FrameProfiler.Time("NewGraphics2D+Acquire"))
+ {
+ graphics2D = this.NewGraphics2D();
+ }
+
+ using (MatterHackers.RenderCore.FrameProfiler.Time("WidgetTreeDraw"))
+ {
+ if (SingleWindowMode && this.WindowProvider != null)
+ {
+ // Every window this provider hosts is drawn into this one frame: the shell first,
+ // then - for each dialog stacked on it - a scrim over the whole frame and the
+ // dialog on top of that. Drawing only the active window would leave a dialog
+ // floating on an empty background. Kept identical to the other two hosts.
+ var openWindows = this.WindowProvider.OpenWindows;
+ for (int i = 0; i < openWindows.Count; i++)
+ {
+ graphics2D.FillRectangle(openWindows[0].LocalBounds, new Color(Color.Black, 160));
+ openWindows[i].OnDraw(graphics2D);
+ }
+ }
+ else
+ {
+ // OnDrawBackground before OnDraw, the way a parent calls into a child in GuiWidget.
+ this.aggSystemWindow.OnDrawBackground(graphics2D);
+ this.aggSystemWindow.OnDraw(graphics2D);
+ }
+ }
+
+ // A widget that rasterized into Graphics2D.DestImage drew into a CPU buffer, not into
+ // the frame. On a GPU surface that buffer is a layer this uploads and draws over the
+ // frame now, after every widget has had its turn.
+ if (graphics2D is Graphics2DGpu gpuGraphics && gpuGraphics.HasCpuLayer)
+ {
+ MatterHackers.RenderCore.FrameProfiler.Count("CompositeCpuLayer");
+ using (MatterHackers.RenderCore.FrameProfiler.Time("CompositeCpuLayer"))
+ {
+ gpuGraphics.CompositeCpuLayer();
+ }
+ }
+
+ // Before the present, because a GPU window can only read a frame back while the frame's
+ // texture is still the one being drawn into.
+ this.CheckSmokeRunProgress();
+ }
+ finally
+ {
+ this.isInsidePaint = false;
+ }
+
+ using (MatterHackers.RenderCore.FrameProfiler.Time("Present"))
+ {
+ this.PresentOrCapture();
+ }
+ }
+
+ MatterHackers.RenderCore.FrameProfiler.EndFrame();
+
+ // A demo that has nothing to animate would paint once and wait forever for input that a smoke
+ // run never sends, so the run pumps its own frames.
+ if (SmokeFrameTarget > 0 && !this.smokeRunFinished)
+ {
+ this.needsRedraw = true;
+ }
+ }
+
+ ///
+ /// Presents the frame. Any screenshot requested for this frame is read back first: after the
+ /// present the texture is the swapchain's again.
+ ///
+ private void PresentOrCapture()
+ {
+ this.viewPortHasBeenSet = false;
+
+ string screenshotPath = this.pendingScreenshotPath;
+ if (screenshotPath == null)
+ {
+ this.webGpuLayer.Present();
+ return;
+ }
+
+ this.pendingScreenshotPath = null;
+ this.CaptureThenPresent(screenshotPath, this.screenshotComplete);
+ }
+
+ ///
+ /// Saves the frame and then presents it. async void on purpose: this is the end of a frame
+ /// and there is nobody to hand a Task to. The native read-back completes before its ValueTask is
+ /// returned, so the present still happens inline, while the frame is alive.
+ ///
+ private async void CaptureThenPresent(string path, ManualResetEventSlim completed)
+ {
+ try
+ {
+ await this.webGpuLayer.SaveCurrentFrameAsync(path);
+ }
+ catch (Exception ex)
+ {
+ Console.Error.WriteLine($"X11SystemWindow screenshot failed: {ex.Message}");
+ }
+ finally
+ {
+ completed?.Set();
+ }
+
+ this.webGpuLayer.Present();
+ }
+
+ private void SetAndClearViewPort()
+ {
+ this.webGpuLayer.BeginFrame();
+
+ var gl = this.webGpuLayer.Gl?.GpuContext;
+ if (gl == null)
+ {
+ return;
+ }
+
+ gl.Viewport(0, 0, (int)this.pixelWidth, (int)this.pixelHeight);
+ this.viewPortHasBeenSet = true;
+
+ gl.MatrixMode(MatrixMode.Projection);
+ gl.LoadIdentity();
+
+ gl.MatrixMode(MatrixMode.Modelview);
+ gl.LoadIdentity();
+ gl.Scissor(0, 0, (int)this.pixelWidth, (int)this.pixelHeight);
+
+ this.NewGraphics2D().Clear(new ColorF(1, 1, 1, 1));
+ }
+
+ // -----------------------------------------------------------------------------------------
+ // Closing
+ // -----------------------------------------------------------------------------------------
+
+ ///
+ /// Runs a native close request - the window manager's close button, the session ending - against the
+ /// application rather than against whatever window happens to be on top, and reports whether the
+ /// platform may go ahead and tear its window down.
+ ///
+ /// See .
+ /// The provider holding the open windows, if there is one.
+ /// The window currently being drawn and given events.
+ ///
+ /// Sets (and, if the close does not take, clears) the host's "the platform is already closing" flag.
+ ///
+ ///
+ /// Static and parameterised because the decision it makes - which window is asked, and whether the
+ /// native window may go away - is the whole bug, and none of it needs X11 to exercise. Identical to
+ /// MacSystemWindow's and WinformsSystemWindow's: the three hosts have to agree here or
+ /// closing the application means something different per platform.
+ ///
+ internal static bool HandlePlatformCloseRequest(
+ bool singleWindowMode,
+ ISystemWindowProvider provider,
+ SystemWindow activeWindow,
+ Action setPlatformClosing)
+ {
+ // The user closed the application, not the dialog drawn inside it. Asking the dialog runs none of
+ // the shell's ShouldClose/Closed handlers - window bounds persistence, save on exit - and the
+ // native window is torn down immediately afterwards regardless, so that work is simply lost.
+ var shellWindow = ShellWindowForClose(singleWindowMode, provider, activeWindow);
+
+ if (shellWindow == null || shellWindow.HasBeenClosed)
+ {
+ return true;
+ }
+
+ // Only the shell decides whether the application may close: an open dialog does not veto here.
+ // In single window mode a dialog is a widget drawn inside this window, so its titlebar button is
+ // the only close that belongs to it - the frame's close button has always meant "close the
+ // application", and applications that want to refuse mid-dialog do it in their own ShouldClose.
+ var shouldClose = new ShouldCloseEventArgs();
+ shellWindow.OnShouldClose(shouldClose);
+
+ if (shouldClose.Cancel)
+ {
+ return false;
+ }
+
+ // The agg close runs first so widgets get their Closed events while the window is still alive. It
+ // calls back through the provider into CloseSystemWindow, which the flag makes a no-op - the
+ // platform is already in the middle of closing us.
+ setPlatformClosing?.Invoke(true);
+ shellWindow.Close();
+
+ if (!shellWindow.HasBeenClosed)
+ {
+ // Close asks OnShouldClose a second time and an application may cancel on that one (having
+ // just put up its "save first?" dialog on the first ask). Letting the platform destroy the
+ // window anyway is exactly the "closed with no Closed events" bug, so the shell that is still
+ // open keeps its native window.
+ setPlatformClosing?.Invoke(false);
+ return false;
+ }
+
+ return true;
+ }
+
+ ///
+ /// The agg window whose close ends the application: the shell, not whatever is currently on top.
+ ///
+ ///
+ /// In single window mode the active window is the one being drawn and given the events, which the
+ /// provider re-points at every dialog that opens. Closing that only dismisses the dialog - the shell
+ /// stays up, the event loop keeps running, and the process never exits. The provider keeps the shell
+ /// first in and takes the dialogs above it down with
+ /// it, so closing that one window is the whole application closing.
+ ///
+ internal static SystemWindow ShellWindowForClose(
+ bool singleWindowMode,
+ ISystemWindowProvider provider,
+ SystemWindow activeWindow)
+ {
+ if (singleWindowMode && provider != null)
+ {
+ var openWindows = provider.OpenWindows;
+
+ if (openWindows.Count > 0)
+ {
+ return openWindows[0];
+ }
+ }
+
+ return activeWindow;
+ }
+
+ ///
+ /// Asks the server to destroy the window. The teardown itself waits for the resulting
+ /// DestroyNotify, so that a window destroyed by anyone - us, the window manager, the server
+ /// shutting down - unwinds through exactly one path.
+ ///
+ private void DestroyNativeWindow()
+ {
+ if (this.hasClosed || this.window == X11.None || display == IntPtr.Zero)
+ {
+ return;
+ }
+
+ // Before anything else. Destroying the grab window releases the grab as a side effect, but only
+ // once the server processes the destroy - and a grab still held while this process tears down is
+ // a desktop with an unresponsive pointer, which is the one failure a user cannot click their way
+ // out of.
+ this.ReleasePointerGrab();
+
+ // The swapchain goes first, while the window it was made over still exists. Vulkan's teardown
+ // talks to the X server about the drawable - destroying the surface and its images are real X
+ // requests - so releasing the window first turns every one of them into a BadDrawable, on every
+ // close. The mac host has the same ordering for the same reason (it disposes the layer inside
+ // windowWillClose:, while the NSWindow is still alive).
+ this.DisposeWebGpuLayer();
+
+ Xlib.XDestroyWindow(display, this.window);
+ Xlib.XFlush(display);
+
+ // XDestroyWindow's DestroyNotify only comes back through the queue, and the queue is only pumped
+ // by a running loop. A close from outside one - or the last close, which ends the loop - would
+ // otherwise leave the window half torn down forever.
+ if (!runLoopActive)
+ {
+ this.HandleDestroyNotify();
+ }
+ }
+
+ ///
+ /// Releases the wgpu device and its swapchain. Separate from so
+ /// that the ordinary close path can run it before the window is destroyed; the teardown still calls
+ /// it as a fallback, for the window that went away without this host asking (the window manager
+ /// killed the client, the session ended), where there is no drawable left to be tidy about.
+ ///
+ private void DisposeWebGpuLayer()
+ {
+ this.webGpuLayer?.Dispose();
+ this.webGpuLayer = null;
+ }
+
+ /// Tears everything down once the window is gone from the server.
+ private void HandleDestroyNotify()
+ {
+ if (this.hasClosed)
+ {
+ return;
+ }
+
+ this.hasClosed = true;
+
+ this.DisposeWebGpuLayer();
+
+ this.window = X11.None;
+ this.aggSystemWindow = null;
+
+ bool wasLast;
+ lock (StaticInitLock)
+ {
+ LiveWindows.Remove(this);
+ wasLast = LiveWindows.Count == 0;
+ }
+
+ if (wasLast)
+ {
+ runLoopActive = false;
+ }
+ }
+
+ // -----------------------------------------------------------------------------------------
+ // Smoke runs
+ // -----------------------------------------------------------------------------------------
+
+ ///
+ /// Counts frames for an AGG_SMOKE_FRAMES run and, on the target frame, asks for the
+ /// screenshot and schedules the close. Called from inside the paint, after the widgets have drawn
+ /// and before the present, which is the only moment both a finished frame and its pixels exist.
+ ///
+ private void CheckSmokeRunProgress()
+ {
+ if (SmokeFrameTarget <= 0 || this.smokeRunFinished || this.drawCount < SmokeFrameTarget)
+ {
+ return;
+ }
+
+ this.smokeRunFinished = true;
+
+ if (!string.IsNullOrEmpty(SmokeScreenshotPath))
+ {
+ try
+ {
+ this.CaptureScreenshot(SmokeScreenshotPath);
+ }
+ catch (Exception ex)
+ {
+ Console.Error.WriteLine($"AGG_SMOKE screenshot failed: {ex}");
+ Environment.ExitCode = 1;
+ }
+ }
+
+ // Closing from inside a paint would tear the window down mid-frame (and before the
+ // screenshot's present has run), so the close waits for this frame to finish.
+ UiThread.RunOnIdle(this.FinishSmokeRun);
+ }
+
+ private void FinishSmokeRun()
+ {
+ string report = this.RenderErrorReport;
+ if (!string.IsNullOrEmpty(report))
+ {
+ Console.Error.WriteLine($"AGG_SMOKE render error: {report}");
+ Environment.ExitCode = 1;
+ }
+
+ string status = this.RenderStatusReport;
+ string detail = $"{this.drawCount} frames on {this.GetType().Name}"
+ + (string.IsNullOrEmpty(status) ? string.Empty : $" [{status}]");
+
+ if (Environment.ExitCode != 0)
+ {
+ Console.WriteLine($"AGG_SMOKE FAILED: {detail}");
+ }
+ else
+ {
+ Console.WriteLine($"AGG_SMOKE ok: {detail}");
+ }
+
+ // Armed before the close, not after: a close that throws or blocks is exactly the case the
+ // watchdog exists for.
+ StartSmokeExitWatchdog();
+
+ try
+ {
+ // Closing the agg window is what tears the platform window down with it; the platform's own
+ // close is only the fallback for a window that was never attached to one.
+ var windowToClose = ShellWindowForClose(SingleWindowMode, this.WindowProvider, this.aggSystemWindow);
+
+ if (windowToClose != null)
+ {
+ windowToClose.Close();
+ }
+ else
+ {
+ this.Close();
+ }
+ }
+ catch (Exception ex)
+ {
+ Console.Error.WriteLine($"AGG_SMOKE: close threw {ex.GetType().Name}: {ex}");
+ }
+ }
+
+ ///
+ /// Guarantees a smoke run terminates. Closing the window ends the event loop, but a teardown that
+ /// throws part way or a demo that left a foreground thread running would keep the process alive
+ /// forever, and an unattended run that never returns is indistinguishable from a hang in the
+ /// renderer. Firing is itself a failure and is reported as one.
+ ///
+ private static void StartSmokeExitWatchdog()
+ {
+ var watchdog = new System.Threading.Timer(
+ _ =>
+ {
+ Console.Error.WriteLine("AGG_SMOKE: the process did not exit on its own after closing; forcing exit.");
+ Console.WriteLine("AGG_SMOKE FAILED: the exit watchdog had to force the process down.");
+ Environment.Exit(Environment.ExitCode != 0 ? Environment.ExitCode : 1);
+ },
+ null,
+ TimeSpan.FromSeconds(5),
+ System.Threading.Timeout.InfiniteTimeSpan);
+
+ // Nothing else holds this; keeping the reference alive is the only thing standing between the
+ // timer and the collector.
+ smokeExitWatchdog = watchdog;
+ }
+
+ private static int ParseSmokeFrames()
+ {
+ return int.TryParse(Environment.GetEnvironmentVariable("AGG_SMOKE_FRAMES"), out int frames) && frames > 0
+ ? frames
+ : 0;
+ }
+ }
+}
diff --git a/PlatformLinux/linux/X11WebGpuLayer.cs b/PlatformLinux/linux/X11WebGpuLayer.cs
new file mode 100644
index 000000000..4c613d135
--- /dev/null
+++ b/PlatformLinux/linux/X11WebGpuLayer.cs
@@ -0,0 +1,518 @@
+/*
+Copyright (c) 2026, Lars Brubaker
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+1. Redistributions of source code must retain the above copyright notice, this
+ list of conditions and the following disclaimer.
+2. Redistributions in binary form must reproduce the above copyright notice,
+ this list of conditions and the following disclaimer in the documentation
+ and/or other materials provided with the distribution.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
+ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
+ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+*/
+
+using System;
+using System.Threading.Tasks;
+using MatterHackers.Agg.Image;
+using MatterHackers.RenderCore;
+using MatterHackers.RenderGl;
+using MatterHackers.RenderGl.Compat;
+using MatterHackers.RenderGl.Scene;
+using MatterHackers.WebGpu;
+using MatterHackers.WebGpuRender;
+
+namespace MatterHackers.Agg.UI
+{
+ ///
+ /// The X11 sibling of WebGpuControl and MacWebGpuLayer: it owns the wgpu device, the
+ /// swapchain over an X11 window, and the the whole 2D stack draws through.
+ ///
+ /// What is different from the Windows control. There is no Control underneath, so there
+ /// is no handle to wait for and no deferred-initialization dance: the window is created by the X11 host
+ /// before this type is constructed, and an X11 drawable is a valid surface source the moment the server
+ /// has it. Sizing is pushed in explicitly by the host (see ) rather than arriving as
+ /// an OnResize event, and it is always in device pixels - X11 has no scaling of its own,
+ /// so on X11 that is simply the window's size in pixels.
+ ///
+ ///
+ /// Frame shape is identical to Windows' and macOS'. acquires the
+ /// swapchain texture and points the compat context at it plus a depth buffer; widget paint draws through
+ /// ; submits and presents. A frame the swapchain cannot hand out
+ /// is drawn into a scratch texture and never presented, because widget paint has no way to be told
+ /// "not this time".
+ ///
+ ///
+ public class X11WebGpuLayer : IDisposable
+ {
+ private readonly IntPtr display;
+ private readonly ulong window;
+
+ private WebGpuRenderDevice device;
+ private WebGpuSurfaceTarget surface;
+ private GlCompatContext compat;
+ private WebGpuSceneRenderer sceneRenderer;
+ private IGpuTexture depthTarget;
+
+ /// Guards against re-entering recovery from a failure raised by recovery itself.
+ private bool isRecoveringDevice;
+
+ /// How many times this layer has rebuilt its device after a loss. Diagnostics and tests.
+ private int deviceRecoveryCount;
+
+ /// A present mode set before the swapchain existed, replayed onto it when it does.
+ private WGPUPresentMode? requestedPresentMode;
+
+ ///
+ /// Where a frame goes when the swapchain has none to give. Drawing has to land somewhere legal or
+ /// every widget draw in that frame throws; this is that somewhere.
+ ///
+ private IGpuTexture scratchTarget;
+
+ private uint pixelWidth;
+ private uint pixelHeight;
+
+ private bool isInitialized;
+ private bool isDisposed;
+ private bool frameIsPresentable;
+
+ /// Creates the host for a window that already exists.
+ /// The Display* the window lives on. Must stay open for this layer's life.
+ /// The X11 window XID wgpu will make its surface over.
+ /// Initial swapchain width in device pixels.
+ /// Initial swapchain height in device pixels.
+ public X11WebGpuLayer(IntPtr display, ulong window, uint pixelWidth, uint pixelHeight)
+ {
+ if (display == IntPtr.Zero)
+ {
+ throw new ArgumentNullException(nameof(display));
+ }
+
+ // Zero is X11's None, never a real window - see WindowSurfaceRequest.ForXlibWindow.
+ if (window == 0)
+ {
+ throw new ArgumentOutOfRangeException(nameof(window), "An X11 surface needs a window XID; zero is None.");
+ }
+
+ this.display = display;
+ this.window = window;
+ this.pixelWidth = Math.Max(1u, pixelWidth);
+ this.pixelHeight = Math.Max(1u, pixelHeight);
+ }
+
+ ///
+ /// Gets or sets a value indicating whether to demand wgpu's software (fallback) adapter rather than
+ /// real hardware. Must be set before .
+ ///
+ public bool UseSoftwareAdapter { get; set; }
+
+ /// The facade the 2D stack draws through, or null before initialization.
+ public MatterHackers.RenderGl.OpenGl.GL Gl { get; private set; }
+
+ /// The compat context under the facade, for diagnostics.
+ public GlCompatContext Compat => this.compat;
+
+ /// The 3D scene compositor, or null before initialization.
+ public INativeSceneRenderer SceneRenderer => this.sceneRenderer;
+
+ /// The wgpu device, for diagnostics and error reporting.
+ public WebGpuRenderDevice Device => this.device;
+
+ /// The swapchain.
+ public WebGpuSurfaceTarget Surface => this.surface;
+
+ /// True once the device and swapchain exist.
+ public bool IsWebGpuInitialized => this.isInitialized;
+
+ /// True once has run.
+ public bool IsDisposed => this.isDisposed;
+
+ /// The backend wgpu chose (Vulkan on Linux), or Undefined before initialization.
+ public WGPUBackendType BackendType => this.device?.AdapterBackend ?? WGPUBackendType.Undefined;
+
+ ///
+ /// The first thing wgpu complained about - a validation error or a lost device - or null while
+ /// everything is well. A smoke run turns this into a non-zero exit code.
+ ///
+ public string LastError => this.device?.DeviceLostMessage ?? this.device?.LastUncapturedError;
+
+ /// How many times this layer has rebuilt its device after a loss; zero on a healthy run.
+ public int DeviceRecoveryCount => this.deviceRecoveryCount;
+
+ /// The swapchain's current width in device pixels.
+ public uint PixelWidth => this.pixelWidth;
+
+ /// The swapchain's current height in device pixels.
+ public uint PixelHeight => this.pixelHeight;
+
+ ///
+ /// How the swapchain paces presents. Defaults to AGG_PRESENT_MODE (Fifo when unset); the
+ /// automation harness sets Immediate, because a vsync wait per frame is wall time a test suite pays
+ /// for nothing.
+ ///
+ public WGPUPresentMode PresentMode
+ {
+ get => this.surface?.PresentMode ?? PresentModeSettings.FromEnvironment();
+
+ set
+ {
+ this.requestedPresentMode = value;
+ if (this.surface != null)
+ {
+ this.surface.PresentMode = value;
+ }
+ }
+ }
+
+ ///
+ /// Creates the device, the swapchain over the X11 window, and the compat context. Safe to call more
+ /// than once.
+ ///
+ public void InitializeWebGpu()
+ {
+ if (this.isInitialized || this.isDisposed)
+ {
+ return;
+ }
+
+ // The surface is described to the constructor rather than made afterwards so that it exists
+ // before the adapter is requested and can be passed as compatibleSurface - without that, wgpu
+ // may pick an adapter that cannot present to this window at all.
+ //
+ // Undefined rather than the Windows host's hardcoded D3D12: on Linux it resolves to Vulkan,
+ // which is the only backend wgpu can present an Xlib surface through, so naming a backend would
+ // only be a way to be wrong.
+ this.device = new WebGpuRenderDevice(
+ this.UseSoftwareAdapter,
+ WGPUBackendType.Undefined,
+ "X11WebGpuLayer",
+ WindowSurfaceRequest.ForXlibWindow(this.display, this.window, this.pixelWidth, this.pixelHeight, "window"));
+
+ this.surface = this.device.WindowSurface;
+
+ if (this.requestedPresentMode.HasValue)
+ {
+ this.surface.PresentMode = this.requestedPresentMode.Value;
+ }
+
+ this.compat = new GlCompatContext(this.device);
+ this.Gl = new MatterHackers.RenderGl.OpenGl.GL(this.compat);
+
+ // The scene compositor is a separate object from the context here, so the context forwards
+ // INativeSceneRenderer to it - which is how RenderHelper and the editors find it - and it is
+ // handed the facade the mesh render-data caches are keyed on.
+ this.sceneRenderer = new WebGpuSceneRenderer(this.compat) { OwnerGl = this.Gl };
+ this.compat.SceneRenderer = this.sceneRenderer;
+
+ // Textures, display lists and tessellations cached against a previous context belong to a
+ // device that no longer exists - the readers only notice through this generation bump.
+ Graphics2DGpu.InvalidateGlCaches();
+
+ this.CreateSizedTargets();
+ this.isInitialized = true;
+ }
+
+ ///
+ /// Acquires the frame's swapchain texture and points the compat context at it. Idempotent within
+ /// a frame, because the window host calls it from every NewGraphics2D.
+ ///
+ public void BeginFrame()
+ {
+ if (!this.isInitialized)
+ {
+ return;
+ }
+
+ // wgpu reports device loss through a callback, not by failing the call that hit it, so the top
+ // of a frame is the first place it can be acted on - and the only place where nothing is
+ // half-recorded.
+ if (this.device.IsDeviceLost && !this.TryRecoverDevice())
+ {
+ return;
+ }
+
+ if (this.compat.Passes.ColorTarget != null)
+ {
+ return;
+ }
+
+ IGpuTexture frame;
+ try
+ {
+ using (FrameProfiler.Time("AcquireTexture"))
+ {
+ frame = this.surface.AcquireCurrentTexture();
+ }
+ }
+ catch (Exception) when (this.TryRecoverIfDeviceLost())
+ {
+ // Recovered; this frame is skipped and the next one draws on the new device.
+ return;
+ }
+
+ this.frameIsPresentable = frame != null;
+ this.compat.SetRenderTarget(frame ?? this.EnsureScratchTarget(), this.depthTarget);
+ }
+
+ /// Ends the frame: submits everything recorded and presents it.
+ public void Present()
+ {
+ if (!this.isInitialized)
+ {
+ return;
+ }
+
+ try
+ {
+ if (this.frameIsPresentable)
+ {
+ using (FrameProfiler.Time("PresentSwapchain"))
+ {
+ this.compat.Present(this.surface);
+ }
+ }
+ else
+ {
+ // Nothing to show, but the recorded commands still have to reach the queue or the next
+ // frame inherits a half-recorded encoder.
+ this.compat.Submit();
+ }
+ }
+ catch (Exception) when (this.TryRecoverIfDeviceLost())
+ {
+ return;
+ }
+
+ // Forgetting the target is what makes BeginFrame acquire again next time; the texture it
+ // referred to was released by the present.
+ this.compat.SetRenderTarget(null, null);
+ }
+
+ ///
+ /// Reconfigures the swapchain and the sized targets for a new drawable size.
+ ///
+ /// The new width in device pixels.
+ /// The new height in device pixels.
+ public void Resize(uint newPixelWidth, uint newPixelHeight)
+ {
+ newPixelWidth = Math.Max(1u, newPixelWidth);
+ newPixelHeight = Math.Max(1u, newPixelHeight);
+
+ this.pixelWidth = newPixelWidth;
+ this.pixelHeight = newPixelHeight;
+
+ if (!this.isInitialized)
+ {
+ return;
+ }
+
+ // A resize can arrive with a frame already open (the host paints straight out of a
+ // ConfigureNotify while a resize burst is in flight). Everything below frees the textures that
+ // frame is drawing into: Configure drops the acquired swapchain texture, CreateSizedTargets
+ // disposes the depth and scratch ones. So the frame's recorded work is submitted and the targets
+ // let go of first, and the frame is marked unpresentable - its swapchain texture is gone.
+ bool frameWasOpen = this.compat.Passes.ColorTarget != null;
+ if (frameWasOpen)
+ {
+ this.compat.Submit();
+ this.compat.SetRenderTarget(null, null);
+ this.frameIsPresentable = false;
+ }
+
+ this.surface.Configure(newPixelWidth, newPixelHeight);
+ this.CreateSizedTargets();
+
+ if (frameWasOpen)
+ {
+ // Whatever is left of this frame's paint still has to land somewhere legal, and the scratch
+ // target is exactly the "drawn but never shown" destination BeginFrame uses.
+ this.compat.SetRenderTarget(this.EnsureScratchTarget(), this.depthTarget);
+ }
+ }
+
+ ///
+ /// Rebuilds the device, swapchain and compat context after a device loss.
+ ///
+ /// True if a working device now exists.
+ public bool TryRecoverDevice()
+ {
+ if (this.isRecoveringDevice || this.isDisposed)
+ {
+ return false;
+ }
+
+ try
+ {
+ this.isRecoveringDevice = true;
+ this.DisposeDeviceResources();
+ this.InitializeWebGpu();
+ this.deviceRecoveryCount++;
+
+ return this.isInitialized;
+ }
+ catch
+ {
+ return false;
+ }
+ finally
+ {
+ this.isRecoveringDevice = false;
+ }
+ }
+
+ ///
+ /// Reads the frame currently being drawn back into a PNG at . Must be
+ /// called after the widget draw and before - once presented, the frame's
+ /// texture is gone.
+ ///
+ /// File to write; an existing file is replaced.
+ public async Task SaveCurrentFrameAsync(string path)
+ {
+ if (!this.isInitialized || this.compat.Passes.ColorTarget == null)
+ {
+ return;
+ }
+
+ var target = this.compat.Passes.ColorTarget;
+ if ((target.Descriptor.Usage & TextureUsage.CopySrc) == 0)
+ {
+ throw new InvalidOperationException(
+ "This swapchain's textures were not created with CopySrc, so the window cannot be read back.");
+ }
+
+ // The pass has to be closed before a copy can be recorded; ReadTextureAsync submits the rest.
+ this.compat.Submit();
+
+ int width = (int)target.Descriptor.Width;
+ int height = (int)target.Descriptor.Height;
+ uint rowStride = TextureFormatInfo.AlignedRowStride(target.Descriptor.Format, (uint)width);
+ var bytes = new byte[rowStride * (long)height];
+ var read = await this.device.ReadTextureAsync(target, bytes);
+
+ var image = new ImageBuffer(width, height, 32, new BlenderBGRA());
+ var buffer = image.GetBuffer();
+
+ // wgpu rows run top down and agg's run bottom up.
+ for (int y = 0; y < height; y++)
+ {
+ long sourceOffset = (height - 1 - y) * (long)read.RowStride;
+ Array.Copy(bytes, sourceOffset, buffer, image.GetBufferOffsetY(y), width * 4);
+ }
+
+ image.MarkImageChanged();
+
+ // ImageIO.SaveImageData will not overwrite, and a stale screenshot that looks fresh is worse
+ // than no screenshot.
+ if (System.IO.File.Exists(path))
+ {
+ System.IO.File.Delete(path);
+ }
+
+ ImageIO.SaveImageData(path, image);
+ }
+
+ public void Dispose()
+ {
+ if (this.isDisposed)
+ {
+ return;
+ }
+
+ this.isDisposed = true;
+ this.DisposeDeviceResources();
+
+ // Same reason as on creation: everything cached against this device is about to be a handle to
+ // freed memory.
+ Graphics2DGpu.InvalidateGlCaches();
+ }
+
+ ///
+ /// An exception filter: recovers and swallows the exception when wgpu has reported the device lost,
+ /// and lets anything else propagate. Used as catch (Exception) when (...) so a genuine bug
+ /// still throws with its original stack.
+ ///
+ private bool TryRecoverIfDeviceLost()
+ {
+ return this.device != null && this.device.IsDeviceLost && this.TryRecoverDevice();
+ }
+
+ private void DisposeDeviceResources()
+ {
+ this.isInitialized = false;
+ this.frameIsPresentable = false;
+
+ this.sceneRenderer?.Dispose();
+ this.compat?.Dispose();
+ this.depthTarget?.Dispose();
+ this.scratchTarget?.Dispose();
+
+ // The surface belongs to the device now (it was made before the adapter), so the device's
+ // Dispose releases it - releasing it here as well would be a double free.
+ this.device?.Dispose();
+
+ this.sceneRenderer = null;
+ this.compat = null;
+ this.depthTarget = null;
+ this.scratchTarget = null;
+ this.surface = null;
+ this.device = null;
+ this.Gl = null;
+ }
+
+ ///
+ /// Rebuilds the depth (and any scratch) target at the swapchain's current size. The caller must
+ /// have let go of any open frame first (see ): this disposes textures a live
+ /// pass could still be drawing into.
+ ///
+ private void CreateSizedTargets()
+ {
+ this.depthTarget?.Dispose();
+ this.depthTarget = null;
+
+ this.scratchTarget?.Dispose();
+ this.scratchTarget = null;
+
+ if (this.surface.Width == 0 || this.surface.Height == 0)
+ {
+ return;
+ }
+
+ this.depthTarget = this.device.CreateTexture(new TextureDescriptor(
+ this.surface.Width,
+ this.surface.Height,
+ TextureFormat.Depth32Float,
+ TextureUsage.RenderAttachment,
+ 1,
+ 1,
+ "windowDepth"));
+ }
+
+ private IGpuTexture EnsureScratchTarget()
+ {
+ if (this.scratchTarget == null)
+ {
+ this.scratchTarget = this.device.CreateTexture(new TextureDescriptor(
+ Math.Max(1u, this.surface.Width),
+ Math.Max(1u, this.surface.Height),
+ this.surface.Format,
+ TextureUsage.RenderAttachment | TextureUsage.CopySrc,
+ 1,
+ 1,
+ "windowScratch"));
+ }
+
+ return this.scratchTarget;
+ }
+ }
+}
diff --git a/PlatformLinux/linux/Xlib.cs b/PlatformLinux/linux/Xlib.cs
new file mode 100644
index 000000000..23e78527c
--- /dev/null
+++ b/PlatformLinux/linux/Xlib.cs
@@ -0,0 +1,1109 @@
+/*
+Copyright (c) 2026, Lars Brubaker
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+1. Redistributions of source code must retain the above copyright notice, this
+ list of conditions and the following disclaimer.
+2. Redistributions in binary form must reproduce the above copyright notice,
+ this list of conditions and the following disclaimer in the documentation
+ and/or other materials provided with the distribution.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
+ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
+ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+*/
+
+using System;
+using System.Diagnostics.CodeAnalysis;
+using System.Globalization;
+using System.Runtime.CompilerServices;
+using System.Runtime.InteropServices;
+
+namespace MatterHackers.Agg.Platform.Linux
+{
+ // ---------------------------------------------------------------------------------------------
+ // The event structs.
+ //
+ // Every one of these mirrors a struct in X11/Xlib.h field for field, and the field order IS the
+ // ABI - Xlib hands back raw memory the server wrote and there is no marshalling layer to catch a
+ // mistake. The types below are the LP64 (Linux x86-64 / aarch64) mapping:
+ //
+ // int, Bool, Status -> int (4 bytes; Bool is an int, not a C99 _Bool and not a byte)
+ // unsigned long -> ulong (8 bytes on LP64 - NOT uint, which is the usual way to get this wrong)
+ // Window, Atom, Time,
+ // Colormap, Cursor,
+ // Drawable, XID -> ulong (all are `unsigned long` typedefs)
+ // Display*, Visual*,
+ // Screen* -> IntPtr
+ //
+ // C#'s sequential layout uses the same natural alignment the System V ABI does, so the padding
+ // falls out on its own - VerifyLayouts() is what proves that claim rather than assuming it.
+ // ---------------------------------------------------------------------------------------------
+
+ ///
+ /// Xlib's XEvent union. The union's largest member is long pad[24], so the whole thing
+ /// is 24 longs wide no matter which arm is live, and every arm begins with the same int type.
+ /// Read first, then take the matching arm with .
+ ///
+ [StructLayout(LayoutKind.Sequential)]
+ internal unsafe struct XEvent
+ {
+ /// The union's storage, sized by Xlib's own long pad[24].
+ public fixed long Payload[24];
+
+ ///
+ /// The event type - one of the X11.KeyPress family. It aliases the first four bytes of the
+ /// union because every arm starts with int type. Read through rather
+ /// than off [0] so it does not quietly depend on byte order.
+ ///
+ public int Type => Unsafe.As(ref this);
+
+ ///
+ /// Reinterprets the union as one of its arms. No copy is made, so writing through the returned
+ /// reference writes the event - which is what XSendEvent round-trips need.
+ ///
+ /// The arm to view, e.g. .
+ [UnscopedRef]
+ public ref T As()
+ where T : unmanaged
+ {
+ return ref Unsafe.As(ref this);
+ }
+ }
+
+ /// Xlib's XKeyEvent (KeyPress / KeyRelease).
+ [StructLayout(LayoutKind.Sequential)]
+ internal struct XKeyEvent
+ {
+ public int Type;
+ public ulong Serial;
+
+ /// Bool: non-zero when this arrived through XSendEvent rather than real hardware.
+ public int SendEvent;
+
+ public IntPtr Display;
+ public ulong Window;
+ public ulong Root;
+ public ulong Subwindow;
+ public ulong Time;
+
+ /// Pointer position in the event window, X11's top-left origin (agg's is bottom-left).
+ public int X;
+ public int Y;
+
+ public int XRoot;
+ public int YRoot;
+
+ /// Modifier and button mask as it was before this event.
+ public uint State;
+
+ /// The hardware key position. Meaningless on its own - resolve it to a keysym.
+ public uint Keycode;
+
+ /// Bool.
+ public int SameScreen;
+ }
+
+ /// Xlib's XButtonEvent (ButtonPress / ButtonRelease).
+ [StructLayout(LayoutKind.Sequential)]
+ internal struct XButtonEvent
+ {
+ public int Type;
+ public ulong Serial;
+ public int SendEvent;
+ public IntPtr Display;
+ public ulong Window;
+ public ulong Root;
+ public ulong Subwindow;
+ public ulong Time;
+ public int X;
+ public int Y;
+ public int XRoot;
+ public int YRoot;
+ public uint State;
+
+ /// One of X11.Button1..Button7; 4-7 are the wheel.
+ public uint Button;
+
+ public int SameScreen;
+ }
+
+ /// Xlib's XMotionEvent (MotionNotify).
+ [StructLayout(LayoutKind.Sequential)]
+ internal struct XMotionEvent
+ {
+ public int Type;
+ public ulong Serial;
+ public int SendEvent;
+ public IntPtr Display;
+ public ulong Window;
+ public ulong Root;
+ public ulong Subwindow;
+ public ulong Time;
+ public int X;
+ public int Y;
+ public int XRoot;
+ public int YRoot;
+ public uint State;
+
+ /// A char, not a Bool: set only when PointerMotionHintMask compressed the stream.
+ public byte IsHint;
+
+ public int SameScreen;
+ }
+
+ /// Xlib's XCrossingEvent (EnterNotify / LeaveNotify).
+ [StructLayout(LayoutKind.Sequential)]
+ internal struct XCrossingEvent
+ {
+ public int Type;
+ public ulong Serial;
+ public int SendEvent;
+ public IntPtr Display;
+ public ulong Window;
+ public ulong Root;
+ public ulong Subwindow;
+ public ulong Time;
+ public int X;
+ public int Y;
+ public int XRoot;
+ public int YRoot;
+
+ /// NotifyNormal / NotifyGrab / NotifyUngrab. A grab produces crossings the pointer never made.
+ public int Mode;
+
+ public int Detail;
+ public int SameScreen;
+ public int Focus;
+ public uint State;
+ }
+
+ /// Xlib's XFocusChangeEvent (FocusIn / FocusOut).
+ [StructLayout(LayoutKind.Sequential)]
+ internal struct XFocusChangeEvent
+ {
+ public int Type;
+ public ulong Serial;
+ public int SendEvent;
+ public IntPtr Display;
+ public ulong Window;
+ public int Mode;
+ public int Detail;
+ }
+
+ /// Xlib's XExposeEvent (Expose).
+ [StructLayout(LayoutKind.Sequential)]
+ internal struct XExposeEvent
+ {
+ public int Type;
+ public ulong Serial;
+ public int SendEvent;
+ public IntPtr Display;
+ public ulong Window;
+ public int X;
+ public int Y;
+ public int Width;
+ public int Height;
+
+ /// How many more Expose events for this same damage are still queued behind this one.
+ public int Count;
+ }
+
+ /// Xlib's XConfigureEvent (ConfigureNotify) - move, resize and restack.
+ [StructLayout(LayoutKind.Sequential)]
+ internal struct XConfigureEvent
+ {
+ public int Type;
+ public ulong Serial;
+ public int SendEvent;
+ public IntPtr Display;
+
+ /// The window the event was selected on, which for StructureNotify is the window itself.
+ public ulong Event;
+
+ public ulong Window;
+ public int X;
+ public int Y;
+ public int Width;
+ public int Height;
+ public int BorderWidth;
+ public ulong Above;
+
+ /// Bool.
+ public int OverrideRedirect;
+ }
+
+ ///
+ /// Xlib's XClientMessageEvent (ClientMessage). This is how WM_DELETE_WINDOW
+ /// arrives, so it is the close button.
+ ///
+ [StructLayout(LayoutKind.Sequential)]
+ internal unsafe struct XClientMessageEvent
+ {
+ public int Type;
+ public ulong Serial;
+ public int SendEvent;
+ public IntPtr Display;
+ public ulong Window;
+ public ulong MessageType;
+
+ /// 8, 16 or 32 - the element width the sender used. WM protocol messages are always 32.
+ public int Format;
+
+ ///
+ /// The payload. Xlib's union is char b[20] / short s[10] / long l[5]; the long arm is the
+ /// widest, so it is the one that fixes the size. Note that a "format 32" message travels in
+ /// five longs on LP64 even though the protocol only carries 32 bits per slot.
+ ///
+ public fixed long Data[5];
+ }
+
+ /// Xlib's XSelectionRequestEvent - another client asking for our clipboard.
+ [StructLayout(LayoutKind.Sequential)]
+ internal struct XSelectionRequestEvent
+ {
+ public int Type;
+ public ulong Serial;
+ public int SendEvent;
+ public IntPtr Display;
+ public ulong Owner;
+ public ulong Requestor;
+ public ulong Selection;
+ public ulong Target;
+
+ /// Where to put the answer on the requestor's window. None means an obsolete client.
+ public ulong Property;
+
+ public ulong Time;
+ }
+
+ /// Xlib's XSelectionEvent - the answer to our own XConvertSelection.
+ [StructLayout(LayoutKind.Sequential)]
+ internal struct XSelectionEvent
+ {
+ public int Type;
+ public ulong Serial;
+ public int SendEvent;
+ public IntPtr Display;
+ public ulong Requestor;
+ public ulong Selection;
+ public ulong Target;
+
+ /// None when the owner refused the conversion.
+ public ulong Property;
+
+ public ulong Time;
+ }
+
+ ///
+ /// Xlib's XSelectionClearEvent - somebody else claimed a selection we owned. This is the only
+ /// notice an owner gets, and the moment it must stop answering for that selection.
+ ///
+ [StructLayout(LayoutKind.Sequential)]
+ internal struct XSelectionClearEvent
+ {
+ public int Type;
+ public ulong Serial;
+ public int SendEvent;
+ public IntPtr Display;
+ public ulong Window;
+ public ulong Selection;
+ public ulong Time;
+ }
+
+ /// Xlib's XPropertyEvent (PropertyNotify).
+ [StructLayout(LayoutKind.Sequential)]
+ internal struct XPropertyEvent
+ {
+ public int Type;
+ public ulong Serial;
+ public int SendEvent;
+ public IntPtr Display;
+ public ulong Window;
+ public ulong Atom;
+ public ulong Time;
+
+ /// PropertyNewValue (0) or PropertyDelete (1).
+ public int State;
+ }
+
+ /// Xlib's XDestroyWindowEvent (DestroyNotify).
+ [StructLayout(LayoutKind.Sequential)]
+ internal struct XDestroyWindowEvent
+ {
+ public int Type;
+ public ulong Serial;
+ public int SendEvent;
+ public IntPtr Display;
+ public ulong Event;
+ public ulong Window;
+ }
+
+ ///
+ /// Xlib's XMappingEvent (MappingNotify) - the keyboard layout changed under us. Xlib's
+ /// cached keymap is stale until XRefreshKeyboardMapping is called with this.
+ ///
+ [StructLayout(LayoutKind.Sequential)]
+ internal struct XMappingEvent
+ {
+ public int Type;
+ public ulong Serial;
+ public int SendEvent;
+ public IntPtr Display;
+ public ulong Window;
+
+ /// MappingModifier (0), MappingKeyboard (1) or MappingPointer (2).
+ public int Request;
+
+ public int FirstKeycode;
+ public int Count;
+ }
+
+ ///
+ /// Xlib's XSizeHints (Xutil.h) - what the window manager is told about acceptable geometry.
+ /// Only the fields named in are read; the rest are ignored no matter what they hold.
+ ///
+ [StructLayout(LayoutKind.Sequential)]
+ internal struct XSizeHints
+ {
+ /// A bitwise OR of X11.PMinSize and friends.
+ public long Flags;
+
+ public int X;
+ public int Y;
+ public int Width;
+ public int Height;
+ public int MinWidth;
+ public int MinHeight;
+ public int MaxWidth;
+ public int MaxHeight;
+ public int WidthInc;
+ public int HeightInc;
+ public int MinAspectX;
+ public int MinAspectY;
+ public int MaxAspectX;
+ public int MaxAspectY;
+ public int BaseWidth;
+ public int BaseHeight;
+ public int WinGravity;
+ }
+
+ /// Xlib's XSetWindowAttributes - the creation-time and change-time window fields.
+ [StructLayout(LayoutKind.Sequential)]
+ internal struct XSetWindowAttributes
+ {
+ public ulong BackgroundPixmap;
+ public ulong BackgroundPixel;
+ public ulong BorderPixmap;
+ public ulong BorderPixel;
+ public int BitGravity;
+ public int WinGravity;
+ public int BackingStore;
+ public ulong BackingPlanes;
+ public ulong BackingPixel;
+
+ /// Bool.
+ public int SaveUnder;
+
+ public long EventMask;
+ public long DoNotPropagateMask;
+
+ /// Bool: true asks the window manager to leave this window entirely alone.
+ public int OverrideRedirect;
+
+ public ulong Colormap;
+ public ulong Cursor;
+ }
+
+ /// Xlib's XWindowAttributes - what XGetWindowAttributes fills in.
+ [StructLayout(LayoutKind.Sequential)]
+ internal struct XWindowAttributes
+ {
+ /// Position relative to the parent, which under a reparenting window manager is
+ /// the frame and not the root. Use XTranslateCoordinates for a screen position.
+ public int X;
+ public int Y;
+
+ public int Width;
+ public int Height;
+ public int BorderWidth;
+ public int Depth;
+ public IntPtr Visual;
+ public ulong Root;
+
+ /// Xlib calls this class: InputOutput or InputOnly.
+ public int Class;
+
+ public int BitGravity;
+ public int WinGravity;
+ public int BackingStore;
+ public ulong BackingPlanes;
+ public ulong BackingPixel;
+ public int SaveUnder;
+ public ulong Colormap;
+ public int MapInstalled;
+
+ /// IsUnmapped (0), IsUnviewable (1) or IsViewable (2).
+ public int MapState;
+
+ public long AllEventMasks;
+ public long YourEventMask;
+ public long DoNotPropagateMask;
+ public int OverrideRedirect;
+ public IntPtr Screen;
+ }
+
+ ///
+ /// Xlib's XErrorEvent. Despite the name it is not an arm of and never
+ /// reaches the event queue: a protocol error is delivered by calling the installed error
+ /// handler. That matters because the default handler prints to stderr and then calls exit, so an
+ /// X11 host that installs nothing dies on the first BadWindow instead of reporting it - see
+ /// .
+ ///
+ [StructLayout(LayoutKind.Sequential)]
+ internal struct XErrorEvent
+ {
+ public int Type;
+ public IntPtr Display;
+
+ /// The XID the failed request named, when the error carries one.
+ public ulong ResourceId;
+
+ /// Serial of the failed request, which is how it is matched back to the call that made it.
+ public ulong Serial;
+
+ /// BadWindow, BadMatch and friends. A byte, not an int.
+ public byte ErrorCode;
+
+ /// Major opcode of the failed request.
+ public byte RequestCode;
+
+ /// Minor opcode, which is only meaningful for an extension's request.
+ public byte MinorCode;
+ }
+
+ /// libc's struct pollfd, for waiting on the X connection without spinning.
+ [StructLayout(LayoutKind.Sequential)]
+ internal struct PollFd
+ {
+ public int Fd;
+ public short Events;
+ public short Revents;
+ }
+
+ ///
+ /// The raw Xlib entry points PlatformLinux is built on, plus the one libc call needed to sleep on the
+ /// X connection. This is the Linux counterpart of PlatformMac's ObjC: a flat P/Invoke surface
+ /// with no policy in it, so that the host above can be read as X11 protocol rather than as marshalling.
+ ///
+ /// Why the versioned soname. The import is libX11.so.6, not libX11: the unversioned
+ /// libX11.so is a linker symlink that ships in the -dev package, and a machine that can run
+ /// an X client is not a machine that has development headers installed. .NET's probing would find
+ /// neither and the first call would throw on an otherwise perfectly
+ /// good desktop.
+ ///
+ ///
+ /// Threading. Xlib is only thread-safe after XInitThreads, which is not called here.
+ /// Everything in this class must therefore be reached from the one thread that owns the display - which
+ /// costs nothing, because the host pumps the connection from a single loop anyway. Note this is a
+ /// different rule from AppKit's: it is "one thread", not "the main thread" (see
+ /// MainThreadDispatcher.MainThreadRequired, which is false off macOS for exactly this reason).
+ ///
+ ///
+ internal static unsafe class Xlib
+ {
+ /// See the class remarks: the versioned soname is the one that exists at runtime.
+ private const string X11Lib = "libX11.so.6";
+
+ private const string LibC = "libc";
+
+ /// poll(2)'s "there is data to read" bit.
+ public const short POLLIN = 0x001;
+
+ ///
+ /// setlocale's character-classification category - the one that decides what encoding
+ /// XLookupString writes. These two numbers are glibc's; the C standard fixes the names but
+ /// not the values, so they would need checking against another libc.
+ ///
+ public const int LC_CTYPE = 0;
+
+ /// Every category at once. What an application normally sets.
+ public const int LC_ALL = 6;
+
+ // ---- Display ---------------------------------------------------------------------------------
+
+ /// Opens a connection. null means "$DISPLAY".
+ /// The Display*, or when there is no X server to talk to.
+ [DllImport(X11Lib)]
+ public static extern IntPtr XOpenDisplay([MarshalAs(UnmanagedType.LPUTF8Str)] string displayName);
+
+ [DllImport(X11Lib)]
+ public static extern int XCloseDisplay(IntPtr display);
+
+ [DllImport(X11Lib)]
+ public static extern int XDefaultScreen(IntPtr display);
+
+ [DllImport(X11Lib)]
+ public static extern ulong XRootWindow(IntPtr display, int screenNumber);
+
+ [DllImport(X11Lib)]
+ public static extern IntPtr XDefaultVisual(IntPtr display, int screenNumber);
+
+ [DllImport(X11Lib)]
+ public static extern int XDefaultDepth(IntPtr display, int screenNumber);
+
+ [DllImport(X11Lib)]
+ public static extern ulong XBlackPixel(IntPtr display, int screenNumber);
+
+ /// Screen size in pixels. This is the whole screen, not the work area - X11 has no notion
+ /// of a work area at all; that is a window-manager convention carried in _NET_WORKAREA.
+ [DllImport(X11Lib)]
+ public static extern int XDisplayWidth(IntPtr display, int screenNumber);
+
+ [DllImport(X11Lib)]
+ public static extern int XDisplayHeight(IntPtr display, int screenNumber);
+
+ /// Screen size in millimetres, as reported by the server. Frequently a fiction (many
+ /// drivers report a made-up 1024x768-ish default), so it is a last-resort DPI source at best.
+ [DllImport(X11Lib)]
+ public static extern int XDisplayWidthMM(IntPtr display, int screenNumber);
+
+ [DllImport(X11Lib)]
+ public static extern int XDisplayHeightMM(IntPtr display, int screenNumber);
+
+ /// The socket behind the display, for poll/select. See .
+ [DllImport(X11Lib)]
+ public static extern int XConnectionNumber(IntPtr display);
+
+ // ---- Error handling --------------------------------------------------------------------------
+ // Xlib does not return protocol errors from the call that caused them - requests are asynchronous,
+ // so by the time the server objects the call has long since returned. Errors arrive by callback
+ // instead, and the two defaults are both fatal in practice: the protocol-error default prints and
+ // exits for some codes, and the I/O-error default always exits. Installing replacements is step 3's
+ // job; these are the bindings it needs.
+
+ ///
+ /// A protocol error handler. Must not throw: it is called from native code, and an exception
+ /// crossing that boundary tears the process down with no diagnostic - the same rule the mac host's
+ /// [UnmanagedCallersOnly] IMPs follow.
+ ///
+ /// Ignored by Xlib; return 0.
+ [UnmanagedFunctionPointer(CallingConvention.Cdecl)]
+ public delegate int XErrorHandler(IntPtr display, XErrorEvent* error);
+
+ ///
+ /// A fatal I/O error handler - the connection to the server is gone and no further request can be
+ /// made on it. Xlib requires this one not to return; if it does, Xlib calls exit
+ /// itself. The same no-throw rule as applies.
+ ///
+ [UnmanagedFunctionPointer(CallingConvention.Cdecl)]
+ public delegate int XIOErrorHandler(IntPtr display);
+
+ ///
+ /// Installs a protocol-error handler. The caller must keep the delegate alive for as long as the
+ /// handler is installed - nothing on the native side roots it, and a collected delegate turns the
+ /// next BadWindow into a jump to freed memory.
+ ///
+ ///
+ /// The previous handler as a raw function pointer, not a delegate: what is usually there is Xlib's
+ /// own C default, which has no managed identity to marshal back into. Keep it if the new handler
+ /// wants to chain, and treat it as opaque otherwise.
+ ///
+ [DllImport(X11Lib)]
+ public static extern IntPtr XSetErrorHandler(XErrorHandler handler);
+
+ /// Installs a fatal I/O error handler. Same lifetime and return-value notes as
+ /// .
+ [DllImport(X11Lib)]
+ public static extern IntPtr XSetIOErrorHandler(XIOErrorHandler handler);
+
+ // ---- Locale ----------------------------------------------------------------------------------
+ // XLookupString hands back bytes in the *current locale's* encoding, and a C program starts in the
+ // "C" locale, where that encoding is ASCII - so without the three calls below every non-ASCII key
+ // silently produces nothing. .NET setting its own managed encoding does not help: this is libc's
+ // per-process locale, which Xlib reads directly.
+
+ ///
+ /// libc's setlocale. Pass and "" to adopt the environment's
+ /// locale, which is what makes XLookupString produce UTF-8 on any modern desktop.
+ ///
+ /// The locale now in effect, in static storage libc owns - do not free it.
+ [DllImport(LibC, EntryPoint = "setlocale")]
+ public static extern IntPtr SetLocale(int category, [MarshalAs(UnmanagedType.LPUTF8Str)] string locale);
+
+ ///
+ /// Whether Xlib can work in the locale just established. False means the X
+ /// locale database has nothing for it, and the caller should fall back to the "C" locale rather
+ /// than proceed - Xlib's behaviour in an unsupported locale is undefined.
+ ///
+ /// Bool.
+ [DllImport(X11Lib)]
+ public static extern int XSupportsLocale();
+
+ ///
+ /// Sets the X locale modifiers, which is how an input method is selected. Pass "" to take the
+ /// value of XMODIFIERS from the environment - the usual choice, since that is where a running
+ /// IME advertises itself.
+ ///
+ /// The modifier string now in effect, or null when it could not be set. Xlib owns it.
+ [DllImport(X11Lib)]
+ public static extern IntPtr XSetLocaleModifiers([MarshalAs(UnmanagedType.LPUTF8Str)] string modifierList);
+
+ // ---- Windows ---------------------------------------------------------------------------------
+
+ [DllImport(X11Lib)]
+ public static extern ulong XCreateSimpleWindow(
+ IntPtr display,
+ ulong parent,
+ int x,
+ int y,
+ uint width,
+ uint height,
+ uint borderWidth,
+ ulong border,
+ ulong background);
+
+ ///
+ /// The full creation call. Only the fields of named in
+ /// are read, so a field set without its CW* bit is silently lost.
+ ///
+ [DllImport(X11Lib)]
+ public static extern ulong XCreateWindow(
+ IntPtr display,
+ ulong parent,
+ int x,
+ int y,
+ uint width,
+ uint height,
+ uint borderWidth,
+ int depth,
+ uint windowClass,
+ IntPtr visual,
+ ulong valueMask,
+ XSetWindowAttributes* attributes);
+
+ [DllImport(X11Lib)]
+ public static extern int XDestroyWindow(IntPtr display, ulong window);
+
+ [DllImport(X11Lib)]
+ public static extern int XMapWindow(IntPtr display, ulong window);
+
+ [DllImport(X11Lib)]
+ public static extern int XUnmapWindow(IntPtr display, ulong window);
+
+ [DllImport(X11Lib)]
+ public static extern int XRaiseWindow(IntPtr display, ulong window);
+
+ [DllImport(X11Lib)]
+ public static extern int XSetInputFocus(IntPtr display, ulong focus, int revertTo, ulong time);
+
+ [DllImport(X11Lib)]
+ public static extern int XMoveWindow(IntPtr display, ulong window, int x, int y);
+
+ [DllImport(X11Lib)]
+ public static extern int XResizeWindow(IntPtr display, ulong window, uint width, uint height);
+
+ [DllImport(X11Lib)]
+ public static extern int XMoveResizeWindow(IntPtr display, ulong window, int x, int y, uint width, uint height);
+
+ /// Reads geometry and state. The position it returns is parent-relative - see
+ /// for the screen position a desktop position needs.
+ [DllImport(X11Lib)]
+ public static extern int XGetWindowAttributes(IntPtr display, ulong window, out XWindowAttributes attributes);
+
+ ///
+ /// Maps a point from one window's coordinates to another's. Passing the root as
+ /// is how a window's true screen position is found under a reparenting
+ /// window manager, where the frame - not the window - is what the root actually contains.
+ ///
+ [DllImport(X11Lib)]
+ public static extern int XTranslateCoordinates(
+ IntPtr display,
+ ulong srcWindow,
+ ulong destWindow,
+ int srcX,
+ int srcY,
+ out int destX,
+ out int destY,
+ out ulong child);
+
+ [DllImport(X11Lib)]
+ public static extern int XStoreName(IntPtr display, ulong window, [MarshalAs(UnmanagedType.LPUTF8Str)] string windowName);
+
+ /// Allocates a zeroed the Xlib way. Free with .
+ /// A stack works just as well - this exists for the paths that would rather
+ /// let Xlib own the memory than trust a hand-written layout.
+ [DllImport(X11Lib)]
+ public static extern IntPtr XAllocSizeHints();
+
+ [DllImport(X11Lib)]
+ public static extern void XSetWMNormalHints(IntPtr display, ulong window, XSizeHints* hints);
+
+ // ---- Events ----------------------------------------------------------------------------------
+
+ [DllImport(X11Lib)]
+ public static extern int XSelectInput(IntPtr display, ulong window, long eventMask);
+
+ /// How many events are already decoded and waiting. Zero does not mean the socket is
+ /// empty - it means Xlib's queue is; the flush inside it is why a pump can call this and then poll.
+ [DllImport(X11Lib)]
+ public static extern int XPending(IntPtr display);
+
+ /// Removes the next event, blocking until there is one. Only safe after
+ /// reports a non-zero count, or the pump stalls.
+ [DllImport(X11Lib)]
+ public static extern int XNextEvent(IntPtr display, out XEvent eventReturn);
+
+ /// Reads the next event without removing it. Used to collapse a burst of
+ /// ConfigureNotify or MotionNotify into just the last one.
+ [DllImport(X11Lib)]
+ public static extern int XPeekEvent(IntPtr display, out XEvent eventReturn);
+
+ [DllImport(X11Lib)]
+ public static extern int XSendEvent(IntPtr display, ulong window, int propagate, long eventMask, ref XEvent eventSend);
+
+ /// Pushes buffered requests to the server without waiting for them.
+ [DllImport(X11Lib)]
+ public static extern int XFlush(IntPtr display);
+
+ /// Flushes and then waits for the server to finish. non-zero
+ /// throws away everything queued, which is only ever right during teardown.
+ [DllImport(X11Lib)]
+ public static extern int XSync(IntPtr display, int discard);
+
+ // ---- Atoms and properties --------------------------------------------------------------------
+
+ /// Interns a name. non-zero returns None rather
+ /// than creating an atom nobody else knows about.
+ [DllImport(X11Lib)]
+ public static extern ulong XInternAtom(
+ IntPtr display,
+ [MarshalAs(UnmanagedType.LPUTF8Str)] string atomName,
+ int onlyIfExists);
+
+ /// Declares which WM protocols this window handles - WM_DELETE_WINDOW above all,
+ /// without which the window manager kills the connection instead of asking to close.
+ [DllImport(X11Lib)]
+ public static extern int XSetWMProtocols(IntPtr display, ulong window, ulong[] protocols, int count);
+
+ [DllImport(X11Lib)]
+ public static extern int XChangeProperty(
+ IntPtr display,
+ ulong window,
+ ulong property,
+ ulong type,
+ int format,
+ int mode,
+ byte* data,
+ int elementCount);
+
+ ///
+ /// Reads a property. comes back as memory Xlib owns and the caller must
+ /// release with - including when is zero, which is
+ /// the leak everyone writes at least once.
+ ///
+ ///
+ /// The offset and length are in 32-bit units, and a "format 32" property is unpacked into
+ /// C longs, so on LP64 each item in is 8 bytes wide even though the
+ /// wire carried 4.
+ ///
+ [DllImport(X11Lib)]
+ public static extern int XGetWindowProperty(
+ IntPtr display,
+ ulong window,
+ ulong property,
+ long longOffset,
+ long longLength,
+ int delete,
+ ulong requestedType,
+ out ulong actualType,
+ out int actualFormat,
+ out ulong itemCount,
+ out ulong bytesAfter,
+ out IntPtr prop);
+
+ ///
+ /// Removes a property from a window. On the receiving side of an INCR transfer this is not a
+ /// tidy-up but the protocol's flow control: deleting the property is how the reader tells the
+ /// sender it has taken the chunk and is ready for the next one.
+ ///
+ [DllImport(X11Lib)]
+ public static extern int XDeleteProperty(IntPtr display, ulong window, ulong property);
+
+ [DllImport(X11Lib)]
+ public static extern int XFree(IntPtr data);
+
+ ///
+ /// The largest request this connection may send, in 4-byte units. Bounds how much property data a
+ /// single XChangeProperty can carry - Xlib does not split one for you.
+ ///
+ [DllImport(X11Lib)]
+ public static extern long XMaxRequestSize(IntPtr display);
+
+ ///
+ /// The same limit raised by the BIG-REQUESTS extension, in 4-byte units, or 0 when the server does
+ /// not offer it - in which case is the real ceiling.
+ ///
+ [DllImport(X11Lib)]
+ public static extern long XExtendedMaxRequestSize(IntPtr display);
+
+ ///
+ /// The server-wide X resource database as one string, or null when nothing has been loaded into
+ /// RESOURCE_MANAGER. The returned pointer belongs to Xlib and must not be freed.
+ /// See for the only thing this is used for here.
+ ///
+ [DllImport(X11Lib)]
+ public static extern IntPtr XResourceManagerString(IntPtr display);
+
+ // ---- Keyboard --------------------------------------------------------------------------------
+
+ ///
+ /// Translates a key event into both the typed text and the keysym. This is the layout-aware call -
+ /// it honours Shift, Caps Lock and the group - so it is what a character-producing key goes through.
+ ///
+ /// The event; taken by reference because Xlib's prototype is non-const.
+ /// Where the typed bytes go, in the current locale's encoding.
+ /// Capacity of .
+ /// The resolved keysym, or None.
+ /// An XComposeStatus*; pass - the compose
+ /// state this would carry is dead weight, since real compose handling needs an XIM instead.
+ /// How many bytes were written to .
+ [DllImport(X11Lib)]
+ public static extern int XLookupString(ref XKeyEvent keyEvent, byte* buffer, int bytesBuffer, out ulong keysym, IntPtr status);
+
+ ///
+ /// The keysym at a shift level of the event's keycode, ignoring the event's own modifier state.
+ /// Index 0 is the unshifted symbol, which is what a shortcut should be matched on so that Ctrl+Shift+S
+ /// still resolves to S rather than to whatever S produces when shifted.
+ ///
+ [DllImport(X11Lib)]
+ public static extern ulong XLookupKeysym(ref XKeyEvent keyEvent, int index);
+
+ ///
+ /// The XKB form of the same lookup, taking a keycode with no event around it. Exported by libX11
+ /// itself (XKB is not a separate library), so it needs no extra import.
+ ///
+ /// An X keycode - a KeyCode, which is a single byte.
+ /// The keyboard group (layout); 0 is the active one for most setups.
+ /// The shift level; 0 is unshifted.
+ [DllImport(X11Lib)]
+ public static extern ulong XkbKeycodeToKeysym(IntPtr display, byte keycode, int group, int level);
+
+ ///
+ /// Re-reads the server's keyboard mapping into Xlib's cache. Must be called with the
+ /// MappingNotify that reported the change: until it is, and
+ /// keep answering from the layout that was in effect before the user
+ /// switched it, so every keystroke resolves to the wrong symbol.
+ ///
+ /// The event; taken by reference because Xlib's prototype is non-const.
+ [DllImport(X11Lib)]
+ public static extern int XRefreshKeyboardMapping(ref XMappingEvent mappingEvent);
+
+ // ---- Cursors and pointer grabs ---------------------------------------------------------------
+
+ /// Makes a cursor from the standard "cursor" font. is an
+ /// X11.XC_* id. The result must be freed with .
+ [DllImport(X11Lib)]
+ public static extern ulong XCreateFontCursor(IntPtr display, uint shape);
+
+ [DllImport(X11Lib)]
+ public static extern int XDefineCursor(IntPtr display, ulong window, ulong cursor);
+
+ /// Drops the window's cursor override, so it inherits the parent's again.
+ [DllImport(X11Lib)]
+ public static extern int XUndefineCursor(IntPtr display, ulong window);
+
+ [DllImport(X11Lib)]
+ public static extern int XFreeCursor(IntPtr display, ulong cursor);
+
+ ///
+ /// Redirects all pointer events to one window until . This is how X11
+ /// spells the implicit capture WinForms gives a button press for free: without it a drag that leaves
+ /// the window stops being delivered mid-gesture.
+ ///
+ /// X11.GrabSuccess, or a refusal code when another client already holds the pointer.
+ [DllImport(X11Lib)]
+ public static extern int XGrabPointer(
+ IntPtr display,
+ ulong grabWindow,
+ int ownerEvents,
+ uint eventMask,
+ int pointerMode,
+ int keyboardMode,
+ ulong confineTo,
+ ulong cursor,
+ ulong time);
+
+ [DllImport(X11Lib)]
+ public static extern int XUngrabPointer(IntPtr display, ulong time);
+
+ ///
+ /// Asks the server where the pointer is and which modifiers and buttons are held right now. The
+ /// point of it here is : it is the live equivalent of the state word
+ /// every input event carries, and is the only way to learn what is held when no event said so -
+ /// which is exactly the case on regaining the focus, where every modifier change that happened
+ /// while another window had the keyboard was delivered somewhere else. The mac host reads
+ /// +[NSEvent modifierFlags] at the same moment and for the same reason.
+ ///
+ /// Bool: false when the pointer is on another screen, in which case the window-relative
+ /// coordinates are meaningless (the mask still is not).
+ [DllImport(X11Lib)]
+ public static extern int XQueryPointer(
+ IntPtr display,
+ ulong window,
+ out ulong root,
+ out ulong child,
+ out int rootX,
+ out int rootY,
+ out int windowX,
+ out int windowY,
+ out uint mask);
+
+ // ---- Selections (the clipboard) --------------------------------------------------------------
+
+ ///
+ /// Claims a selection. X11 has no clipboard daemon in the protocol: the owner is the
+ /// clipboard, and must answer SelectionRequest events for as long as it holds the claim.
+ ///
+ [DllImport(X11Lib)]
+ public static extern int XSetSelectionOwner(IntPtr display, ulong selection, ulong owner, ulong time);
+
+ [DllImport(X11Lib)]
+ public static extern ulong XGetSelectionOwner(IntPtr display, ulong selection);
+
+ /// Asks the current owner to convert a selection into a target type. The answer arrives
+ /// later as a SelectionNotify event, so a paste is asynchronous by construction.
+ [DllImport(X11Lib)]
+ public static extern int XConvertSelection(
+ IntPtr display,
+ ulong selection,
+ ulong target,
+ ulong property,
+ ulong requestor,
+ ulong time);
+
+ // ---- libc ------------------------------------------------------------------------------------
+
+ ///
+ /// Waits for readability on the X connection with a timeout, so an idle pump can sleep instead of
+ /// spinning. is milliseconds; -1 blocks forever and 0 returns at once.
+ ///
+ [DllImport(LibC, EntryPoint = "poll", SetLastError = true)]
+ public static extern int Poll(PollFd* fds, nuint fdCount, int timeout);
+
+ // ---- Helpers ---------------------------------------------------------------------------------
+
+ ///
+ /// Reads Xft.dpi out of the X resource database, which is where every desktop environment
+ /// records the user's chosen scaling. There is no Xrm parse here on purpose: the database is a
+ /// newline-separated list of Name:\tvalue lines, and pulling one known key out of it in C# is
+ /// less code than binding XrmGetStringDatabase/XrmGetResource and freeing the database
+ /// afterwards.
+ ///
+ /// An open display.
+ /// The value found, in dots per inch.
+ /// False when the resource database is empty or has no Xft.dpi line.
+ public static bool TryReadXftDpi(IntPtr display, out double dpi)
+ {
+ dpi = 0;
+
+ if (display == IntPtr.Zero)
+ {
+ return false;
+ }
+
+ IntPtr resources = XResourceManagerString(display);
+ if (resources == IntPtr.Zero)
+ {
+ return false;
+ }
+
+ // Xlib owns this string, so it is read and never freed.
+ string database = Marshal.PtrToStringUTF8(resources);
+ if (string.IsNullOrEmpty(database))
+ {
+ return false;
+ }
+
+ foreach (string line in database.Split('\n'))
+ {
+ int separator = line.IndexOf(':');
+ if (separator < 0)
+ {
+ continue;
+ }
+
+ if (!line.AsSpan(0, separator).Trim().Equals("Xft.dpi", StringComparison.Ordinal))
+ {
+ continue;
+ }
+
+ // The value is conventionally separated by a tab, and is an integer in every writer seen in
+ // the wild - but it is parsed as a double and with the invariant culture anyway, because a
+ // decimal comma from the ambient culture would otherwise silently reject "96.0".
+ ReadOnlySpan value = line.AsSpan(separator + 1).Trim();
+ if (double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out double parsed)
+ && parsed > 0)
+ {
+ dpi = parsed;
+ return true;
+ }
+
+ return false;
+ }
+
+ return false;
+ }
+
+ ///
+ /// Checks the hand-written struct layouts above against the sizes X11/Xlib.h produces on
+ /// LP64. Every one of these is memory the X server wrote and Xlib handed over untouched, so a field
+ /// in the wrong place does not fail - it silently reads a neighbouring field, which is the kind of
+ /// bug that looks like "the mouse is off by a bit sometimes". A test calls this.
+ ///
+ /// A struct does not have the size the C ABI gives it.
+ public static void VerifyLayouts()
+ {
+ Expect(sizeof(XEvent), 192, nameof(XEvent));
+ Expect(sizeof(XKeyEvent), 96, nameof(XKeyEvent));
+ Expect(sizeof(XButtonEvent), 96, nameof(XButtonEvent));
+ Expect(sizeof(XMotionEvent), 96, nameof(XMotionEvent));
+ Expect(sizeof(XCrossingEvent), 104, nameof(XCrossingEvent));
+ Expect(sizeof(XFocusChangeEvent), 48, nameof(XFocusChangeEvent));
+ Expect(sizeof(XExposeEvent), 64, nameof(XExposeEvent));
+ Expect(sizeof(XConfigureEvent), 88, nameof(XConfigureEvent));
+ Expect(sizeof(XClientMessageEvent), 96, nameof(XClientMessageEvent));
+ Expect(sizeof(XSelectionRequestEvent), 80, nameof(XSelectionRequestEvent));
+ Expect(sizeof(XSelectionEvent), 72, nameof(XSelectionEvent));
+ Expect(sizeof(XSelectionClearEvent), 56, nameof(XSelectionClearEvent));
+ Expect(sizeof(XPropertyEvent), 64, nameof(XPropertyEvent));
+ Expect(sizeof(XDestroyWindowEvent), 48, nameof(XDestroyWindowEvent));
+ Expect(sizeof(XMappingEvent), 56, nameof(XMappingEvent));
+ Expect(sizeof(XErrorEvent), 40, nameof(XErrorEvent));
+ Expect(sizeof(XSizeHints), 80, nameof(XSizeHints));
+ Expect(sizeof(XSetWindowAttributes), 112, nameof(XSetWindowAttributes));
+ Expect(sizeof(XWindowAttributes), 136, nameof(XWindowAttributes));
+ Expect(sizeof(PollFd), 8, nameof(PollFd));
+
+ static void Expect(int actual, int expected, string name)
+ {
+ if (actual != expected)
+ {
+ throw new InvalidOperationException(
+ $"{name} marshals to {actual} bytes but the X11 ABI says {expected}. "
+ + "A field type or its order does not match X11/Xlib.h.");
+ }
+ }
+ }
+ }
+}
diff --git a/Tests/Agg.Tests/Agg.Tests.csproj b/Tests/Agg.Tests/Agg.Tests.csproj
index 09aa7c0ea..b33960c05 100644
--- a/Tests/Agg.Tests/Agg.Tests.csproj
+++ b/Tests/Agg.Tests/Agg.Tests.csproj
@@ -5,10 +5,21 @@
checked from another host with -p:WindowsBuild=true -p:EnableWindowsTargeting=true. -->
$([MSBuild]::IsOSPlatform('Windows'))
+
+ $([MSBuild]::IsOSPlatform('OSX'))
+ false
+ $([MSBuild]::IsOSPlatform('Linux'))
+ false
+ true
+
+ project drops to plain net10.0, swaps PlatformWin32 for this OS's platform layer, and drops the
+ subset of tests whose subject is WinForms - see the conditional items below. -->
net10.0-windows
net10.0
Exe
@@ -43,7 +54,7 @@
-
+
@@ -57,7 +68,8 @@
-
+
+
@@ -89,9 +101,9 @@
-
-
+
+
@@ -102,6 +114,20 @@
+
+
+
+
+
+
+
+
+
+
+
+
+