From a85ef2397ff057393111949c857d7ceca2bab99a Mon Sep 17 00:00:00 2001 From: cope413 Date: Tue, 25 Aug 2026 12:45:23 -0600 Subject: [PATCH] Linux window host: hand-rolled X11 + wgpu/Vulkan (PlatformLinux) PlatformLinux mirrors PlatformMac: Xlib P/Invoke layer, X11SystemWindow (event loop, paint/present, resize, close, input with Y flip and pointer capture), X11WebGpuLayer over WindowSurfaceRequest.ForXlibWindow, LinuxInformationProvider, LinuxFileDialogProvider (zenity/kdialog, non-blocking), LinuxClipboard + X11Selection (real CLIPBOARD owner and requestor with INCR receive). AggContext provider settings become three-way; Agg.Tests gains Windows/Mac/Linux build legs and 145 Linux-only tests. Also fixes the agg/Agg.csproj reference casing that kept Agg.Tests from building on case-sensitive filesystems. --- Gui/SystemWindow/SystemWindow.cs | 13 +- PlatformLinux/PlatformLinux.csproj | 37 + PlatformLinux/linux/LinuxClipboard.cs | 273 ++ .../linux/LinuxFileDialogProvider.cs | 882 ++++ .../linux/LinuxInformationProvider.cs | 200 + .../linux/WebGpuX11WindowProvider.cs | 83 + PlatformLinux/linux/X11Constants.cs | 436 ++ PlatformLinux/linux/X11Selection.cs | 1376 +++++++ PlatformLinux/linux/X11SystemWindow.cs | 3536 +++++++++++++++++ PlatformLinux/linux/X11WebGpuLayer.cs | 518 +++ PlatformLinux/linux/Xlib.cs | 1109 ++++++ Tests/Agg.Tests/Agg.Tests.csproj | 40 +- Tests/Agg.Tests/Agg.UI/LinuxClipboardTests.cs | 413 ++ .../Agg.UI/LinuxFileDialogProviderTests.cs | 457 +++ .../Agg.UI/X11DragOutsideViewTests.cs | 423 ++ Tests/Agg.Tests/Agg.UI/X11EventLayoutTests.cs | 162 + .../Agg.UI/X11KeyTranslationTests.cs | 243 ++ .../Agg.Tests/Agg.UI/X11ModifierStateTests.cs | 295 ++ Tests/Agg.Tests/Agg.UI/X11ResizePaintTests.cs | 92 + .../X11SystemWindowCloseRoutingTests.cs | 206 + Tests/Agg.Tests/Agg.UI/X11WheelTests.cs | 109 + .../WindowSurfaceRequestTests.cs | 94 + WebGpuRender/WebGpuRenderDevice.cs | 64 +- WebGpuRender/WindowSurfaceRequest.cs | 75 +- agg-sharp.sln | 17 + agg/Platform/AggContext.cs | 23 +- 26 files changed, 11141 insertions(+), 35 deletions(-) create mode 100644 PlatformLinux/PlatformLinux.csproj create mode 100644 PlatformLinux/linux/LinuxClipboard.cs create mode 100644 PlatformLinux/linux/LinuxFileDialogProvider.cs create mode 100644 PlatformLinux/linux/LinuxInformationProvider.cs create mode 100644 PlatformLinux/linux/WebGpuX11WindowProvider.cs create mode 100644 PlatformLinux/linux/X11Constants.cs create mode 100644 PlatformLinux/linux/X11Selection.cs create mode 100644 PlatformLinux/linux/X11SystemWindow.cs create mode 100644 PlatformLinux/linux/X11WebGpuLayer.cs create mode 100644 PlatformLinux/linux/Xlib.cs create mode 100644 Tests/Agg.Tests/Agg.UI/LinuxClipboardTests.cs create mode 100644 Tests/Agg.Tests/Agg.UI/LinuxFileDialogProviderTests.cs create mode 100644 Tests/Agg.Tests/Agg.UI/X11DragOutsideViewTests.cs create mode 100644 Tests/Agg.Tests/Agg.UI/X11EventLayoutTests.cs create mode 100644 Tests/Agg.Tests/Agg.UI/X11KeyTranslationTests.cs create mode 100644 Tests/Agg.Tests/Agg.UI/X11ModifierStateTests.cs create mode 100644 Tests/Agg.Tests/Agg.UI/X11ResizePaintTests.cs create mode 100644 Tests/Agg.Tests/Agg.UI/X11SystemWindowCloseRoutingTests.cs create mode 100644 Tests/Agg.Tests/Agg.UI/X11WheelTests.cs create mode 100644 Tests/Agg.Tests/Agg.WebGpuRender/WindowSurfaceRequestTests.cs diff --git a/Gui/SystemWindow/SystemWindow.cs b/Gui/SystemWindow/SystemWindow.cs index 415e1c330..83aa0e86a 100644 --- a/Gui/SystemWindow/SystemWindow.cs +++ b/Gui/SystemWindow/SystemWindow.cs @@ -515,9 +515,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. /// /// @@ -544,6 +546,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( @@ -559,7 +564,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 cb441231b..d8dbd300c 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 @@ - - + + @@ -101,6 +113,20 @@ + + + + + + + + + + + + +