Skip to content

Commit d66bc43

Browse files
authored
Merge pull request #1550 from cope413/linux-x11-host
Linux window host: hand-rolled X11 + wgpu/Vulkan (PlatformLinux)
2 parents 9b70afa + 6cba86b commit d66bc43

26 files changed

Lines changed: 11141 additions & 35 deletions

Gui/SystemWindow/SystemWindow.cs

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -532,9 +532,11 @@ public static void ResetSystemWindowProvider()
532532
/// <para>
533533
/// The override deliberately beats code that assigned the config value (several demos hard-code
534534
/// theirs), because its whole purpose is running an unmodified demo on a chosen host. It
535-
/// understands the short names <c>webgpu</c> (the WinForms host) and <c>mac</c> (the AppKit host),
536-
/// and passes anything else through as a fully qualified type name so an out-of-tree provider can be
537-
/// named too. Neither short name is normally needed - the per-OS default in
535+
/// understands the short names <c>webgpu</c> (the WinForms host), <c>mac</c> (the AppKit host) and
536+
/// <c>x11</c> (the Linux host), and passes anything else through as a fully qualified type name so
537+
/// an out-of-tree provider can be named too. Note that <c>webgpu</c> names the WinForms host
538+
/// specifically rather than "whichever host this OS has" - every host is a WebGPU host now, so the
539+
/// name is historical. None of the three is normally needed: the per-OS default in
538540
/// <c>AggContext.Config.ProviderTypes</c> already resolves to the right one.
539541
/// </para>
540542
/// <para>
@@ -561,6 +563,9 @@ private static string ResolveSystemWindowProviderTypeName()
561563
case "mac":
562564
return "MatterHackers.Agg.UI.WebGpuMacWindowProvider, agg_platform_mac";
563565

566+
case "x11":
567+
return "MatterHackers.Agg.UI.WebGpuX11WindowProvider, agg_platform_linux";
568+
564569
case "bitmap":
565570
case "d3d11":
566571
throw new InvalidOperationException(
@@ -576,7 +581,7 @@ private static string ResolveSystemWindowProviderTypeName()
576581
}
577582

578583
throw new InvalidOperationException(
579-
$"AGG_WINDOW_PROVIDER='{requested}' is not 'webgpu' or 'mac' and is not a 'Type, Assembly' name.");
584+
$"AGG_WINDOW_PROVIDER='{requested}' is not 'webgpu', 'mac' or 'x11' and is not a 'Type, Assembly' name.");
580585
}
581586
}
582587

PlatformLinux/PlatformLinux.csproj

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
<?xml version="1.0" encoding="utf-8"?>
2+
<Project Sdk="Microsoft.NET.Sdk">
3+
<PropertyGroup>
4+
<!-- Plain net10.0 for the same reason PlatformMac is: everything X11 here is raw P/Invoke into
5+
libX11.so.6, so there is no SDK-specific TFM to ask for, and a plain one keeps this assembly
6+
restorable (and compile-checkable) on a machine that is not Linux at all. -->
7+
<TargetFramework>net10.0</TargetFramework>
8+
<Company>MatterHackers Inc.</Company>
9+
<AssemblyName>agg_platform_linux</AssemblyName>
10+
<RootNamespace>agg_platform_linux</RootNamespace>
11+
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
12+
</PropertyGroup>
13+
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
14+
<DefineConstants>TRACE;DEBUG</DefineConstants>
15+
</PropertyGroup>
16+
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|AnyCPU'">
17+
<DefineConstants>TRACE;RELEASE</DefineConstants>
18+
</PropertyGroup>
19+
<ItemGroup>
20+
<!-- The Xlib struct layouts are an ABI contract with a C header, and the only way to check them is
21+
to name the internal types and take their size. Same reasoning as PlatformMac's. -->
22+
<InternalsVisibleTo Include="Agg.Tests" />
23+
</ItemGroup>
24+
<ItemGroup>
25+
<!-- The same set PlatformMac pulls in. No PackageReferences at all: this assembly must be buildable
26+
with zero NuGet dependencies of its own. -->
27+
<ProjectReference Include="..\agg\Agg.csproj" />
28+
<ProjectReference Include="..\GuiAutomation\GuiAutomation.csproj" />
29+
<ProjectReference Include="..\Gui\Gui.csproj" />
30+
<ProjectReference Include="..\ImageProcessing\ImageProcessing.csproj" />
31+
<ProjectReference Include="..\RenderCore\RenderCore.csproj" />
32+
<ProjectReference Include="..\RenderGl\RenderGl.csproj" />
33+
<!-- Also what carries libwgpu_native.so into the output of every app that hosts a window. -->
34+
<ProjectReference Include="..\WebGpuRender\WebGpuRender.csproj" />
35+
<ProjectReference Include="..\VectorMath\VectorMath.csproj" />
36+
</ItemGroup>
37+
</Project>
Lines changed: 273 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,273 @@
1+
/*
2+
Copyright (c) 2026, Lars Brubaker
3+
All rights reserved.
4+
5+
Redistribution and use in source and binary forms, with or without
6+
modification, are permitted provided that the following conditions are met:
7+
8+
1. Redistributions of source code must retain the above copyright notice, this
9+
list of conditions and the following disclaimer.
10+
2. Redistributions in binary form must reproduce the above copyright notice,
11+
this list of conditions and the following disclaimer in the documentation
12+
and/or other materials provided with the distribution.
13+
14+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
15+
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
16+
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
17+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
18+
ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
19+
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
20+
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
21+
ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
22+
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
23+
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
24+
25+
The views and conclusions contained in the software and documentation are those
26+
of the authors and should not be interpreted as representing official policies,
27+
either expressed or implied, of the FreeBSD Project.
28+
*/
29+
30+
using System;
31+
using System.Collections.Specialized;
32+
using MatterHackers.Agg.Image;
33+
using MatterHackers.Agg.Platform.Linux;
34+
35+
namespace MatterHackers.Agg.UI
36+
{
37+
/// <summary>
38+
/// The Linux <see cref="ISystemClipboard"/>, backed by the X11 CLIPBOARD selection through
39+
/// <see cref="X11Selection"/>. The peer of <c>MacClipboard</c> and PlatformWin32's
40+
/// <c>WindowsFormsClipboard</c>; an app installs it with
41+
/// <c>Clipboard.SetSystemClipboard(new LinuxClipboard())</c>.
42+
/// </summary>
43+
/// <remarks>
44+
/// <para>
45+
/// Text and HTML round trip with other X11 clients. Images and file drop lists report "not present"
46+
/// rather than pretending, exactly as on macOS - and on X11 a file drop is not even the same protocol,
47+
/// it is XDND.
48+
/// </para>
49+
/// <para>
50+
/// <b>Two backings, one of which is a fallback.</b> X11 has no clipboard storage: the owning client
51+
/// <em>is</em> the clipboard. So while this process owns the selection the answer comes from the
52+
/// strings held here - no round trip, and no way for the answer to differ from what was copied - and
53+
/// when it does not, every read is a conversion request to whoever does. Those same strings are the
54+
/// whole implementation when there is no X display at all, which is what a headless test run gets.
55+
/// </para>
56+
/// <para>
57+
/// <b>Threading.</b> Xlib is single-threaded here, so only the thread that owns the display may speak
58+
/// to the selection. A <em>write</em> from any other thread is not dropped: it is marshalled onto the
59+
/// UI thread with <see cref="UiThread.RunOnIdle(Action)"/> and claimed there. A <em>read</em> cannot do
60+
/// that - it has to answer now - so off-thread it answers from this process's own last copy, and says
61+
/// so once on stderr.
62+
/// </para>
63+
/// <para>
64+
/// <b>Reads are re-entrant, but not to input.</b> Reading another client's clipboard pumps the event
65+
/// loop while it waits, so repaints and window management keep working - but key, button and motion
66+
/// events are held back and replayed after the call unwinds. See <see cref="X11Selection"/>'s remarks:
67+
/// without that, a paste's own write-back silently eats anything typed during it.
68+
/// </para>
69+
/// </remarks>
70+
public class LinuxClipboard : ISystemClipboard
71+
{
72+
/// <summary>Logged at most once per process - see <see cref="SelectionForRead"/>.</summary>
73+
private static bool warnedOffThreadRead;
74+
75+
/// <summary>
76+
/// The last thing written here. Two jobs: the answer while this process owns the selection, and the
77+
/// entire clipboard when there is no X11 to reach.
78+
/// </summary>
79+
private string text;
80+
81+
private string html;
82+
83+
/// <summary>
84+
/// Whether a text flavor is available. Deliberately <c>!= null</c> rather than
85+
/// <c>!string.IsNullOrEmpty</c>, for parity with <c>MacClipboard</c>, whose
86+
/// <c>stringForType: != null</c> distinguishes "the pasteboard holds an empty string" from "the
87+
/// pasteboard holds no string at all". Folding those together would make copying an empty
88+
/// selection behave differently on Linux than on the other two hosts.
89+
/// </summary>
90+
public bool ContainsText
91+
{
92+
get
93+
{
94+
X11Selection selection = SelectionForRead();
95+
if (selection == null || selection.OwnsClipboard)
96+
{
97+
return this.text != null;
98+
}
99+
100+
return selection.RemoteHasText();
101+
}
102+
}
103+
104+
/// <summary>Whether an HTML flavor is available. Same deliberate <c>!= null</c> parity as
105+
/// <see cref="ContainsText"/>.</summary>
106+
public bool ContainsHtml
107+
{
108+
get
109+
{
110+
X11Selection selection = SelectionForRead();
111+
if (selection == null || selection.OwnsClipboard)
112+
{
113+
return this.html != null;
114+
}
115+
116+
return selection.RemoteHasHtml();
117+
}
118+
}
119+
120+
/// <summary>Always false: images are not carried, matching the mac host.</summary>
121+
public bool ContainsImage => false;
122+
123+
/// <summary>Always false: X11 file drops are a separate protocol (XDND), not a selection target.</summary>
124+
public bool ContainsFileDropList => false;
125+
126+
/// <inheritdoc/>
127+
public string GetText()
128+
{
129+
X11Selection selection = SelectionForRead();
130+
if (selection == null || selection.OwnsClipboard)
131+
{
132+
return this.text ?? string.Empty;
133+
}
134+
135+
// The spelling is the owner's choice, not ours: UTF8_STRING if it has one, then the MIME name,
136+
// then TEXT, then STRING. An old client with only STRING still holds text, and asking for
137+
// UTF8_STRING alone would report its clipboard as empty.
138+
return selection.RemoteText() ?? string.Empty;
139+
}
140+
141+
/// <inheritdoc/>
142+
public string GetHtml()
143+
{
144+
X11Selection selection = SelectionForRead();
145+
if (selection == null || selection.OwnsClipboard)
146+
{
147+
return this.html ?? string.Empty;
148+
}
149+
150+
return selection.RemoteHtml() ?? string.Empty;
151+
}
152+
153+
/// <inheritdoc/>
154+
public ImageBuffer GetImage() => null;
155+
156+
/// <inheritdoc/>
157+
public StringCollection GetFileDropList() => new StringCollection();
158+
159+
/// <inheritdoc/>
160+
public void SetText(string text)
161+
{
162+
// Writing plain text clears any HTML flavor, the way clearContents does on the mac: otherwise a
163+
// later GetHtml would answer with HTML from an older, unrelated copy - and here it would also
164+
// keep advertising a text/html target we can no longer honour.
165+
this.text = text;
166+
this.html = null;
167+
this.PublishToSelection();
168+
}
169+
170+
/// <inheritdoc/>
171+
public void SetTextAndHtml(string text, string html)
172+
{
173+
this.text = text;
174+
this.html = html;
175+
this.PublishToSelection();
176+
}
177+
178+
/// <inheritdoc/>
179+
public void SetImage(ImageBuffer imageBuffer)
180+
{
181+
}
182+
183+
/// <summary>
184+
/// The selection to read from, or null to answer from this process's own copy. Logs once when the
185+
/// fallback is taken for a reason a developer would want to know about - a read off the display
186+
/// thread, where the answer is this process's own last copy and not what another application may
187+
/// have copied since.
188+
/// </summary>
189+
private static X11Selection SelectionForRead()
190+
{
191+
X11Selection selection = X11Selection.TryGet();
192+
if (selection != null || !X11SystemWindow.HasDisplay)
193+
{
194+
// Either it worked, or this process is headless - in which case the in-process copy is not
195+
// a fallback at all, it is the whole clipboard, and there is nothing to warn about.
196+
return selection;
197+
}
198+
199+
if (!warnedOffThreadRead)
200+
{
201+
warnedOffThreadRead = true;
202+
Console.Error.WriteLine(
203+
"LinuxClipboard: a clipboard read arrived off the thread that owns the X display, so it "
204+
+ "was answered from this process's own last copy rather than the X11 CLIPBOARD "
205+
+ "selection. Xlib here is single-threaded and a read cannot wait for the UI thread - "
206+
+ "read the clipboard from the UI thread to see what other applications have copied.");
207+
}
208+
209+
return null;
210+
}
211+
212+
/// <summary>
213+
/// Puts the current strings on the X clipboard, from whichever thread called.
214+
/// </summary>
215+
/// <remarks>
216+
/// A write, unlike a read, has nothing to return and so can afford to wait: off the display thread
217+
/// it is handed to <see cref="UiThread.RunOnIdle(Action)"/> rather than dropped. The captured values
218+
/// are re-checked when that runs, so a write already superseded by a newer one does not resurrect
219+
/// itself over the top of it.
220+
/// </remarks>
221+
private void PublishToSelection()
222+
{
223+
X11Selection selection = X11Selection.TryGet();
224+
if (selection != null)
225+
{
226+
Publish(selection, this.text, this.html);
227+
return;
228+
}
229+
230+
if (!X11SystemWindow.HasDisplay)
231+
{
232+
// Headless: the strings are the clipboard, and there is nothing to publish them to.
233+
return;
234+
}
235+
236+
string pendingText = this.text;
237+
string pendingHtml = this.html;
238+
239+
UiThread.RunOnIdle(() =>
240+
{
241+
if (this.text != pendingText || this.html != pendingHtml)
242+
{
243+
// Superseded while this was queued. The newer write has its own turn coming.
244+
return;
245+
}
246+
247+
X11Selection deferred = X11Selection.TryGet();
248+
if (deferred != null)
249+
{
250+
Publish(deferred, pendingText, pendingHtml);
251+
}
252+
});
253+
}
254+
255+
/// <summary>
256+
/// Claims the selection, or gives it up when there is nothing to offer. Releasing rather than
257+
/// serving an empty string matters: "there is no text" and "the text is empty" are different
258+
/// statements, <see cref="ContainsText"/> distinguishes them on this host as it does on the mac,
259+
/// and other clients can only see the difference if the claim is actually dropped.
260+
/// </summary>
261+
private static void Publish(X11Selection selection, string text, string html)
262+
{
263+
if (text == null && html == null)
264+
{
265+
selection.Release();
266+
}
267+
else
268+
{
269+
selection.Claim(text, html);
270+
}
271+
}
272+
}
273+
}

0 commit comments

Comments
 (0)