Browse Source

Wayland protocol support (#21448)

pull/21602/head
Nikita Tsukanov 3 months ago
committed by GitHub
parent
commit
12cefeb74a
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 1
      .github/copilot-instructions.md
  2. 2
      Avalonia.Desktop.slnf
  3. 2
      Avalonia.slnx
  4. 8
      Directory.Packages.props
  5. 12
      api/Avalonia.nupkg.xml
  6. 1
      samples/ControlCatalog.Desktop/ControlCatalog.Desktop.csproj
  7. 1
      src/Avalonia.Base/Avalonia.Base.csproj
  8. 1
      src/Avalonia.Controls/Avalonia.Controls.csproj
  9. 1
      src/Avalonia.Dialogs/Avalonia.Dialogs.csproj
  10. 1
      src/Avalonia.FreeDesktop/Avalonia.FreeDesktop.csproj
  11. 35
      src/Avalonia.OpenGL/Egl/EglConsts.cs
  12. 2
      src/Avalonia.OpenGL/Egl/EglDisplay.cs
  13. 196
      src/Avalonia.OpenGL/Egl/EglGlPlatformImageSurfaceBase.cs
  14. 2
      src/Avalonia.OpenGL/Egl/EglGlPlatformSurface.cs
  15. 13
      src/Avalonia.OpenGL/Egl/EglGlPlatformSurfaceBase.cs
  16. 46
      src/Avalonia.OpenGL/Egl/EglImage.cs
  17. 17
      src/Avalonia.OpenGL/Egl/EglInterface.cs
  18. 6
      src/Avalonia.OpenGL/GlConsts.cs
  19. 5
      src/Avalonia.OpenGL/GlInterface.cs
  20. 29
      src/Avalonia.Wayland/Avalonia.Wayland.csproj
  21. 111
      src/Avalonia.Wayland/AvaloniaWaylandException.cs
  22. 28
      src/Avalonia.Wayland/AvaloniaWaylandPlatformExtensions.cs
  23. 129
      src/Avalonia.Wayland/Clipboard/WaylandClipboardImpl.cs
  24. 265
      src/Avalonia.Wayland/Clipboard/WaylandDataTransfer.cs
  25. 28
      src/Avalonia.Wayland/Clipboard/WaylandDragSource.cs
  26. 68
      src/Avalonia.Wayland/Clipboard/WaylandMimeMapper.cs
  27. 260
      src/Avalonia.Wayland/Clipboard/WaylandOutgoingTransfer.cs
  28. 19
      src/Avalonia.Wayland/IWaylandXdgTopLevelExport.cs
  29. 86
      src/Avalonia.Wayland/PopupImpl.Sink.cs
  30. 118
      src/Avalonia.Wayland/PopupImpl.cs
  31. 100
      src/Avalonia.Wayland/README.md
  32. 21
      src/Avalonia.Wayland/Screens/IWaylandOutputsSink.cs
  33. 105
      src/Avalonia.Wayland/Screens/SnapshotScreensImpl.cs
  34. 37
      src/Avalonia.Wayland/Screens/WaylandOutputSnapshot.cs
  35. 118
      src/Avalonia.Wayland/Server/Interop/DrmGbmUnsafeNativeMethods.cs
  36. 32
      src/Avalonia.Wayland/Server/Interop/Pipe2Stream.cs
  37. 110
      src/Avalonia.Wayland/Server/Interop/UnsafeNativeMethods.cs
  38. 70
      src/Avalonia.Wayland/Server/Interop/WakeupFd.cs
  39. 177
      src/Avalonia.Wayland/Server/Interop/WaylandConnection.cs
  40. 29
      src/Avalonia.Wayland/Server/Interop/WaylandEglNativeMethods.cs
  41. 110
      src/Avalonia.Wayland/Server/Interop/XkbCommonNativeMethods.cs
  42. 15
      src/Avalonia.Wayland/Server/Persistent/DecorationMode.cs
  43. 10
      src/Avalonia.Wayland/Server/Persistent/IPersistentObject.cs
  44. 61
      src/Avalonia.Wayland/Server/Persistent/IWSurface.cs
  45. 105
      src/Avalonia.Wayland/Server/Persistent/IWSurfaceEventSink.cs
  46. 90
      src/Avalonia.Wayland/Server/Persistent/IWXdgTopLevel.cs
  47. 19
      src/Avalonia.Wayland/Server/Persistent/IWaylandCursor.cs
  48. 1033
      src/Avalonia.Wayland/Server/Persistent/WSurface.cs
  49. 96
      src/Avalonia.Wayland/Server/Persistent/WaylandBitmapCursor.cs
  50. 45
      src/Avalonia.Wayland/Server/Persistent/WaylandCursor.cs
  51. 71
      src/Avalonia.Wayland/Server/Persistent/WaylandInputEventCookie.cs
  52. 64
      src/Avalonia.Wayland/Server/Persistent/XdgConfigureBatch.cs
  53. 15
      src/Avalonia.Wayland/Server/Persistent/XdgPopupConfigureBatch.cs
  54. 31
      src/Avalonia.Wayland/Server/Persistent/XdgPopupPositionerParams.cs
  55. 31
      src/Avalonia.Wayland/Server/ServerSignaler.cs
  56. 363
      src/Avalonia.Wayland/Server/Transient/Clipboard/WaylandDataDevice.cs
  57. 131
      src/Avalonia.Wayland/Server/Transient/Clipboard/WaylandDataOffer.cs
  58. 113
      src/Avalonia.Wayland/Server/Transient/Clipboard/WaylandDataSource.cs
  59. 86
      src/Avalonia.Wayland/Server/Transient/Clipboard/WaylandOfferCookie.cs
  60. 50
      src/Avalonia.Wayland/Server/Transient/IWaylandTextInputV3Events.cs
  61. 44
      src/Avalonia.Wayland/Server/Transient/Rendering/IWaylandFramebufferSurface.cs
  62. 131
      src/Avalonia.Wayland/Server/Transient/Rendering/WaylandDmabufFeedback.cs
  63. 26
      src/Avalonia.Wayland/Server/Transient/Rendering/WaylandEglDisplay.cs
  64. 231
      src/Avalonia.Wayland/Server/Transient/Rendering/WaylandEglDmaBufPlatformGraphics.cs
  65. 371
      src/Avalonia.Wayland/Server/Transient/Rendering/WaylandEglDmaBufSurface.cs
  66. 73
      src/Avalonia.Wayland/Server/Transient/Rendering/WaylandEglWsiPlatformGraphics.cs
  67. 135
      src/Avalonia.Wayland/Server/Transient/Rendering/WaylandEglWsiSurface.cs
  68. 113
      src/Avalonia.Wayland/Server/Transient/Rendering/WaylandFramebuffer.cs
  69. 120
      src/Avalonia.Wayland/Server/Transient/WaylandCursorManager.cs
  70. 197
      src/Avalonia.Wayland/Server/Transient/WaylandGlobals.cs
  71. 188
      src/Avalonia.Wayland/Server/Transient/WaylandInputDispatcher.Keyboard.cs
  72. 636
      src/Avalonia.Wayland/Server/Transient/WaylandInputDispatcher.cs
  73. 259
      src/Avalonia.Wayland/Server/Transient/WaylandOutputsTracker.cs
  74. 570
      src/Avalonia.Wayland/Server/Transient/WaylandTextInputV3.cs
  75. 66
      src/Avalonia.Wayland/Server/Transient/XdgToplevelExport.cs
  76. 19
      src/Avalonia.Wayland/Server/WaylandDispatchPriority.cs
  77. 17
      src/Avalonia.Wayland/Server/WaylandMarshallers.cs
  78. 54
      src/Avalonia.Wayland/Server/WaylandPlatformGraphics.cs
  79. 147
      src/Avalonia.Wayland/Server/WaylandWorker.RenderTimer.cs
  80. 316
      src/Avalonia.Wayland/Server/WaylandWorker.cs
  81. 123
      src/Avalonia.Wayland/Server/WaylandWorkerClient.cs
  82. 79
      src/Avalonia.Wayland/TextInputOptionsConverter.cs
  83. 103
      src/Avalonia.Wayland/WaylandConversionExtensions.cs
  84. 52
      src/Avalonia.Wayland/WaylandCursorFactory.cs
  85. 62
      src/Avalonia.Wayland/WaylandPlatform.cs
  86. 75
      src/Avalonia.Wayland/WaylandPlatformOptions.cs
  87. 31
      src/Avalonia.Wayland/WaylandSurfaceCreateResult.cs
  88. 107
      src/Avalonia.Wayland/WaylandTextUtils.cs
  89. 53
      src/Avalonia.Wayland/WaylandTopLevelFactory.cs
  90. 164
      src/Avalonia.Wayland/WindowImpl.Sink.cs
  91. 233
      src/Avalonia.Wayland/WindowImpl.TextInput.cs
  92. 396
      src/Avalonia.Wayland/WindowImpl.cs
  93. 190
      src/Avalonia.Wayland/WindowImplBase.DragDrop.cs
  94. 144
      src/Avalonia.Wayland/WindowImplBase.Keyboard.cs
  95. 86
      src/Avalonia.Wayland/WindowImplBase.Pointer.cs
  96. 262
      src/Avalonia.Wayland/WindowImplBase.cs
  97. 126
      src/Avalonia.Wayland/XkbCommonKeymap.cs
  98. 81
      src/Avalonia.Wayland/XkbComposeState.cs
  99. 42
      src/Avalonia.Wayland/XkbComposeTable.cs
  100. 28
      src/Avalonia.Wayland/XkbContext.cs

1
.github/copilot-instructions.md

@ -1,3 +1,4 @@
When running code review, please refer to the following documents if they are relevant to the changed parts of the codebase:
- `native/Avalonia.Native/**` -> `native/Avalonia.Native/README.md`
- `src/Avalonia.Wayland/**` -> `src/Avalonia.Wayland/README.md`

2
Avalonia.Desktop.slnf

@ -34,6 +34,7 @@
"src\\Avalonia.Themes.Fluent\\Avalonia.Themes.Fluent.csproj",
"src\\Avalonia.Themes.Simple\\Avalonia.Themes.Simple.csproj",
"src\\Avalonia.Vulkan\\Avalonia.Vulkan.csproj",
"src\\Avalonia.Wayland\\Avalonia.Wayland.csproj",
"src\\Avalonia.X11\\Avalonia.X11.csproj",
"src\\HarfBuzz\\Avalonia.HarfBuzz\\Avalonia.HarfBuzz.csproj",
"src\\Headless\\Avalonia.Headless.Vnc\\Avalonia.Headless.Vnc.csproj",
@ -72,6 +73,7 @@
"tests\\Avalonia.Skia.RenderTests\\Avalonia.Skia.RenderTests.csproj",
"tests\\Avalonia.Skia.UnitTests\\Avalonia.Skia.UnitTests.csproj",
"tests\\Avalonia.UnitTests\\Avalonia.UnitTests.csproj",
"tests\\Avalonia.Wayland.UnitTests\\Avalonia.Wayland.UnitTests.csproj",
"tests\\TestFiles\\BuildTasks\\PInvoke\\PInvoke.csproj"
]
}

2
Avalonia.slnx

@ -22,6 +22,7 @@
<Project Path="src/Avalonia.FreeDesktop/Avalonia.FreeDesktop.csproj" />
<Project Path="src/Avalonia.X11/Avalonia.X11.csproj" />
<Project Path="src/Linux/Avalonia.LinuxFramebuffer/Avalonia.LinuxFramebuffer.csproj" />
<Project Path="src/Avalonia.Wayland/Avalonia.Wayland.csproj" />
</Folder>
<Folder Name="/Markup/">
<Project Path="src/Markup/Avalonia.Markup.Xaml.Loader/Avalonia.Markup.Xaml.Loader.csproj" />
@ -156,6 +157,7 @@
<Project Path="tests/Avalonia.Skia.RenderTests/Avalonia.Skia.RenderTests.csproj" />
<Project Path="tests/Avalonia.Skia.UnitTests/Avalonia.Skia.UnitTests.csproj" />
<Project Path="tests/Avalonia.UnitTests/Avalonia.UnitTests.csproj" />
<Project Path="tests/Avalonia.Wayland.UnitTests/Avalonia.Wayland.UnitTests.csproj" />
</Folder>
<Folder Name="/Tests/TestFiles/" />
<Folder Name="/Tests/TestFiles/BuildTasks/">

8
Directory.Packages.props

@ -23,9 +23,9 @@
<PackageVersion Include="Microsoft.Build.Framework" Version="18.0.2" />
<PackageVersion Include="Microsoft.Build.Utilities.Core" Version="15.1.548" />
<PackageVersion Include="Microsoft.CodeAnalysis.Analyzers" Version="3.11.0" />
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="4.5.0" />
<PackageVersion Include="Microsoft.CodeAnalysis.VisualBasic" Version="4.5.0" />
<PackageVersion Include="Microsoft.CodeAnalysis.Workspaces.Common" Version="4.5.0" />
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="4.14.0" />
<PackageVersion Include="Microsoft.CodeAnalysis.VisualBasic" Version="4.14.0" />
<PackageVersion Include="Microsoft.CodeAnalysis.Workspaces.Common" Version="4.14.0" />
<PackageVersion Include="Microsoft.CSharp" Version="4.5.0" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
<PackageVersion Include="Microsoft.Reactive.Testing" Version="6.1.0" />
@ -42,6 +42,8 @@
<PackageVersion Include="Numerge" Version="1.0.0" />
<PackageVersion Include="NUnit" Version="4.5.1" />
<PackageVersion Include="NUnit3TestAdapter" Version="6.2.0" />
<PackageVersion Include="NWayland" Version="0.11.0" />
<PackageVersion Include="NWayland.Server" Version="0.11.0" />
<PackageVersion Include="Quamotion.RemoteViewing" Version="1.1.211" />
<PackageVersion Include="SharpCompress" Version="0.48.0" />
<PackageVersion Include="Silk.NET.Direct3D.Compilers" Version="2.22.0" />

12
api/Avalonia.nupkg.xml

@ -25,6 +25,12 @@
<Left>baseline/Avalonia/lib/net10.0/Avalonia.Base.dll</Left>
<Right>current/Avalonia/lib/net10.0/Avalonia.Base.dll</Right>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Avalonia.OpenGL.Egl.EglPlatformSurfaceRenderTargetBase.BeginDraw(Avalonia.OpenGL.Egl.EglSurface,Avalonia.PixelSize,System.Double,System.Action,System.Boolean)</Target>
<Left>baseline/Avalonia/lib/net10.0/Avalonia.OpenGL.dll</Left>
<Right>current/Avalonia/lib/net10.0/Avalonia.OpenGL.dll</Right>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Avalonia.Input.PointerEventArgs.#ctor(Avalonia.Interactivity.RoutedEvent,System.Object,Avalonia.Input.IPointer,Avalonia.Visual,Avalonia.Point,System.UInt64,Avalonia.Input.PointerPointProperties,Avalonia.Input.KeyModifiers,System.Lazy{System.Collections.Generic.IReadOnlyList{Avalonia.Input.Raw.RawPointerPoint}})</Target>
@ -49,4 +55,10 @@
<Left>baseline/Avalonia/lib/net8.0/Avalonia.Base.dll</Left>
<Right>current/Avalonia/lib/net8.0/Avalonia.Base.dll</Right>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Avalonia.OpenGL.Egl.EglPlatformSurfaceRenderTargetBase.BeginDraw(Avalonia.OpenGL.Egl.EglSurface,Avalonia.PixelSize,System.Double,System.Action,System.Boolean)</Target>
<Left>baseline/Avalonia/lib/net8.0/Avalonia.OpenGL.dll</Left>
<Right>current/Avalonia/lib/net8.0/Avalonia.OpenGL.dll</Right>
</Suppression>
</Suppressions>

1
samples/ControlCatalog.Desktop/ControlCatalog.Desktop.csproj

@ -21,6 +21,7 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Avalonia.Wayland\Avalonia.Wayland.csproj" />
<ProjectReference Include="..\..\src\Headless\Avalonia.Headless.Vnc\Avalonia.Headless.Vnc.csproj" />
<ProjectReference Include="..\..\src\Avalonia.Dialogs\Avalonia.Dialogs.csproj" />
<ProjectReference Include="..\..\src\Linux\Avalonia.LinuxFramebuffer\Avalonia.LinuxFramebuffer.csproj" />

1
src/Avalonia.Base/Avalonia.Base.csproj

@ -40,6 +40,7 @@
<InternalsVisibleTo Include="Avalonia.Native, PublicKey=$(AvaloniaPublicKey)" />
<InternalsVisibleTo Include="Avalonia.FreeDesktop, PublicKey=$(AvaloniaPublicKey)" />
<InternalsVisibleTo Include="Avalonia.X11, PublicKey=$(AvaloniaPublicKey)" />
<InternalsVisibleTo Include="Avalonia.Wayland, PublicKey=$(AvaloniaPublicKey)" />
<InternalsVisibleTo Include="Avalonia.Browser, PublicKey=$(AvaloniaPublicKey)" />
<InternalsVisibleTo Include="Avalonia.Controls.UnitTests, PublicKey=$(AvaloniaPublicKey)" />
<InternalsVisibleTo Include="Avalonia.DesignerSupport, PublicKey=$(AvaloniaPublicKey)" />

1
src/Avalonia.Controls/Avalonia.Controls.csproj

@ -26,6 +26,7 @@
<InternalsVisibleTo Include="Avalonia.Win32, PublicKey=$(AvaloniaPublicKey)" />
<InternalsVisibleTo Include="Avalonia.Win32.Automation, PublicKey=$(AvaloniaPublicKey)" />
<InternalsVisibleTo Include="Avalonia.X11, PublicKey=$(AvaloniaPublicKey)" />
<InternalsVisibleTo Include="Avalonia.Wayland, PublicKey=$(AvaloniaPublicKey)" />
<InternalsVisibleTo Include="Avalonia.LinuxFramebuffer, PublicKey=$(AvaloniaPublicKey)" />
<InternalsVisibleTo Include="Avalonia.DesignerSupport.Remote, PublicKey=$(AvaloniaPublicKey)" />
<InternalsVisibleTo Include="Avalonia.Browser, PublicKey=$(AvaloniaPublicKey)" />

1
src/Avalonia.Dialogs/Avalonia.Dialogs.csproj

@ -18,6 +18,7 @@
<!-- For managed dialogs dev testing -->
<InternalsVisibleTo Include="ControlCatalog, PublicKey=$(AvaloniaPublicKey)" />
<InternalsVisibleTo Include="Avalonia.X11, PublicKey=$(AvaloniaPublicKey)" />
<InternalsVisibleTo Include="Avalonia.Wayland, PublicKey=$(AvaloniaPublicKey)" />
</ItemGroup>
<Import Project="..\..\build\DevAnalyzers.props" />

1
src/Avalonia.FreeDesktop/Avalonia.FreeDesktop.csproj

@ -24,6 +24,7 @@
<ItemGroup Label="InternalsVisibleTo">
<InternalsVisibleTo Include="Avalonia.X11, PublicKey=$(AvaloniaPublicKey)" />
<InternalsVisibleTo Include="Avalonia.Wayland, PublicKey=$(AvaloniaPublicKey)" />
</ItemGroup>
<ItemGroup>

35
src/Avalonia.OpenGL/Egl/EglConsts.cs

@ -170,9 +170,40 @@ namespace Avalonia.OpenGL.Egl
// public const int EGL_GL_TEXTURE_CUBE_MAP_NEGATIVE_Y = 0x30B6;
// public const int EGL_GL_TEXTURE_CUBE_MAP_POSITIVE_Z = 0x30B7;
// public const int EGL_GL_TEXTURE_CUBE_MAP_NEGATIVE_Z = 0x30B8;
// public const int EGL_IMAGE_PRESERVED = 0x30D2;
// public const int EGL_NO_IMAGE = 0;
public const int EGL_IMAGE_PRESERVED = 0x30D2;
public const int EGL_NO_IMAGE = 0;
// EGL_KHR_image_base
public const int EGL_IMAGE_PRESERVED_KHR = 0x30D2;
// EGL_EXT_image_dma_buf_import
public const int EGL_LINUX_DMA_BUF_EXT = 0x3270;
public const int EGL_LINUX_DRM_FOURCC_EXT = 0x3271;
public const int EGL_DMA_BUF_PLANE0_FD_EXT = 0x3272;
public const int EGL_DMA_BUF_PLANE0_OFFSET_EXT = 0x3273;
public const int EGL_DMA_BUF_PLANE0_PITCH_EXT = 0x3274;
public const int EGL_DMA_BUF_PLANE1_FD_EXT = 0x3275;
public const int EGL_DMA_BUF_PLANE1_OFFSET_EXT = 0x3276;
public const int EGL_DMA_BUF_PLANE1_PITCH_EXT = 0x3277;
public const int EGL_DMA_BUF_PLANE2_FD_EXT = 0x3278;
public const int EGL_DMA_BUF_PLANE2_OFFSET_EXT = 0x3279;
public const int EGL_DMA_BUF_PLANE2_PITCH_EXT = 0x327A;
// EGL_EXT_image_dma_buf_import_modifiers
public const int EGL_DMA_BUF_PLANE0_MODIFIER_LO_EXT = 0x3443;
public const int EGL_DMA_BUF_PLANE0_MODIFIER_HI_EXT = 0x3444;
public const int EGL_DMA_BUF_PLANE1_MODIFIER_LO_EXT = 0x3445;
public const int EGL_DMA_BUF_PLANE1_MODIFIER_HI_EXT = 0x3446;
public const int EGL_DMA_BUF_PLANE2_MODIFIER_LO_EXT = 0x3447;
public const int EGL_DMA_BUF_PLANE2_MODIFIER_HI_EXT = 0x3448;
public const int EGL_DMA_BUF_PLANE3_FD_EXT = 0x3440;
public const int EGL_DMA_BUF_PLANE3_OFFSET_EXT = 0x3441;
public const int EGL_DMA_BUF_PLANE3_PITCH_EXT = 0x3442;
public const int EGL_DMA_BUF_PLANE3_MODIFIER_LO_EXT = 0x3449;
public const int EGL_DMA_BUF_PLANE3_MODIFIER_HI_EXT = 0x344A;
// EGL_KHR_platform_gbm / EGL_MESA_platform_gbm
public const int EGL_PLATFORM_GBM_KHR = 0x31D7;
public const int EGL_PLATFORM_ANGLE_TYPE_D3D9_ANGLE = 0x3207;
public const int EGL_PLATFORM_ANGLE_TYPE_D3D11_ANGLE = 0x3208;

2
src/Avalonia.OpenGL/Egl/EglDisplay.cs

@ -185,7 +185,7 @@ namespace Avalonia.OpenGL.Egl
{
lock (_lock)
{
foreach(var ctx in _contexts)
foreach(var ctx in _contexts.ToList())
ctx.Dispose();
_contexts.Clear();
if (_display != IntPtr.Zero)

196
src/Avalonia.OpenGL/Egl/EglGlPlatformImageSurfaceBase.cs

@ -0,0 +1,196 @@
using System;
using Avalonia.OpenGL.Surfaces;
using Avalonia.Platform;
using static Avalonia.OpenGL.GlConsts;
namespace Avalonia.OpenGL.Egl
{
/// <summary>
/// Base class for EGL-backed <see cref="IGlPlatformSurface"/> implementations that render into
/// an EGLImage. Derived types create a concrete render target bound to a given GL context.
/// </summary>
public abstract class EglGlPlatformImageSurfaceBase : IGlPlatformSurface
{
/// <summary>
/// Creates a render target for the given GL context.
/// </summary>
/// <param name="context">The GL context the render target will be used with.</param>
public abstract IGlPlatformSurfaceRenderTarget CreateGlRenderTarget(IGlContext context);
}
/// <summary>
/// Base class for EGLImage render targets. Manages a reusable framebuffer/texture pair and
/// binds an EGLImage as the color attachment for each draw.
/// </summary>
public abstract class EglPlatformImageSurfaceRenderTargetBase : IGlPlatformSurfaceRenderTarget
{
/// <summary>Gets the EGL context this render target draws with.</summary>
protected EglContext Context { get; }
private int _fbo;
private int _texture;
private PixelSize _fboSize;
/// <summary>
/// Initializes the render target for the given EGL context.
/// </summary>
/// <param name="context">The EGL context to render with.</param>
protected EglPlatformImageSurfaceRenderTargetBase(EglContext context)
{
Context = context;
}
/// <summary>Destroys the cached framebuffer and texture, if any.</summary>
public virtual void Dispose()
{
DestroyFbo();
}
/// <summary>
/// Begins a drawing session, throwing <see cref="RenderTargetCorruptedException"/> if the
/// context has been lost.
/// </summary>
/// <param name="sceneInfo">Scene information for the frame being drawn.</param>
public IGlPlatformSurfaceRenderingSession BeginDraw(IRenderTarget.RenderTargetSceneInfo sceneInfo)
{
if (Context.IsLost)
throw new RenderTargetCorruptedException();
return BeginDrawCore(sceneInfo);
}
/// <summary>
/// When overridden in a derived class, performs the actual begin-draw logic (typically by
/// resolving the EGLImage to render into and calling the protected
/// <see cref="BeginDraw(IntPtr,PixelSize,double,Action)"/> overload).
/// </summary>
/// <param name="sceneInfo">Scene information for the frame being drawn.</param>
public abstract IGlPlatformSurfaceRenderingSession BeginDrawCore(IRenderTarget.RenderTargetSceneInfo sceneInfo);
/// <summary>
/// Binds the given EGLImage as the framebuffer color attachment and begins a drawing session.
/// </summary>
/// <param name="eglImage">The EGLImage handle to render into.</param>
/// <param name="size">The target size in pixels.</param>
/// <param name="scaling">The render scaling factor.</param>
/// <param name="onFinishDraw">Callback invoked when the session is disposed (after flush).</param>
protected IGlPlatformSurfaceRenderingSession BeginDraw(IntPtr eglImage,
PixelSize size, double scaling, Action onFinishDraw)
{
var restoreContext = Context.MakeCurrent(null);
var success = false;
try
{
var gl = Context.GlInterface;
EnsureFbo(gl, size);
gl.BindFramebuffer(GL_FRAMEBUFFER, _fbo);
gl.BindTexture(GL_TEXTURE_2D, _texture);
gl.EGLImageTargetTexture2DOES(GL_TEXTURE_2D, eglImage);
gl.FramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0,
GL_TEXTURE_2D, _texture, 0);
var status = gl.CheckFramebufferStatus(GL_FRAMEBUFFER);
if (status != GL_FRAMEBUFFER_COMPLETE)
throw new OpenGlException($"Framebuffer incomplete: 0x{status:X}");
gl.Viewport(0, 0, size.Width, size.Height);
success = true;
return new Session(Context, size, scaling, restoreContext, onFinishDraw);
}
finally
{
if (!success)
restoreContext.Dispose();
}
}
private void EnsureFbo(GlInterface gl, PixelSize size)
{
if (_fbo != 0 && _fboSize == size)
return;
DestroyFboCore(gl);
_fbo = gl.GenFramebuffer();
_texture = gl.GenTexture();
_fboSize = size;
gl.BindTexture(GL_TEXTURE_2D, _texture);
gl.TexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
gl.TexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
gl.TexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
gl.TexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
}
private void DestroyFbo()
{
if (_fbo == 0)
return;
try
{
using (Context.MakeCurrent(null))
DestroyFboCore(Context.GlInterface);
}
catch
{
// Context may already be lost
}
}
private void DestroyFboCore(GlInterface gl)
{
if (_fbo != 0)
{
gl.DeleteFramebuffer(_fbo);
_fbo = 0;
}
if (_texture != 0)
{
gl.DeleteTexture(_texture);
_texture = 0;
}
_fboSize = default;
}
private class Session : IGlPlatformSurfaceRenderingSession
{
private readonly EglContext _context;
private readonly IDisposable _restoreContext;
private readonly Action _onFinishDraw;
public Session(EglContext context, PixelSize size, double scaling,
IDisposable restoreContext, Action onFinishDraw)
{
_context = context;
Size = size;
Scaling = scaling;
_restoreContext = restoreContext;
_onFinishDraw = onFinishDraw;
}
public void Dispose()
{
_context.GlInterface.Flush();
_restoreContext.Dispose();
_onFinishDraw();
}
public IGlContext Context => _context;
public PixelSize Size { get; }
public double Scaling { get; }
public bool IsYFlipped => true;
}
/// <summary>
/// Gets the render target state: <see cref="PlatformRenderTargetState.Corrupted"/> when
/// <see cref="IsCorrupted"/> is <c>true</c>, otherwise <see cref="PlatformRenderTargetState.Ready"/>.
/// </summary>
public virtual PlatformRenderTargetState State =>
IsCorrupted ? PlatformRenderTargetState.Corrupted : PlatformRenderTargetState.Ready;
/// <summary>Gets a value indicating whether the underlying GL context has been lost.</summary>
public virtual bool IsCorrupted => Context.IsLost;
}
}

2
src/Avalonia.OpenGL/Egl/EglGlPlatformSurface.cs

@ -51,7 +51,7 @@ namespace Avalonia.OpenGL.Egl
SkipWaits = info is IEglWindowGlPlatformSurfaceInfoWithWaitPolicy { SkipWaits: true };
}
private protected override bool SkipWaits { get; }
protected override bool SkipWaits { get; }
public override void Dispose() => _glSurface?.Dispose();

13
src/Avalonia.OpenGL/Egl/EglGlPlatformSurfaceBase.cs

@ -31,12 +31,13 @@ namespace Avalonia.OpenGL.Egl
return BeginDrawCore(sceneInfo);
}
private protected virtual bool SkipWaits => false;
protected virtual bool SkipWaits => false;
public abstract IGlPlatformSurfaceRenderingSession BeginDrawCore(IRenderTarget.RenderTargetSceneInfo sceneInfo);
protected IGlPlatformSurfaceRenderingSession BeginDraw(EglSurface surface,
PixelSize size, double scaling, Action? onFinish = null, bool isYFlipped = false)
PixelSize size, double scaling, Action? onFinish = null, bool isYFlipped = false,
Action? beforeSwap = null)
{
var restoreContext = Context.MakeCurrent(surface);
@ -69,7 +70,7 @@ namespace Avalonia.OpenGL.Egl
success = true;
return new Session(Context.Display, Context, surface, size, scaling, restoreContext, onFinish, isYFlipped, SkipWaits);
return new Session(Context.Display, Context, surface, size, scaling, restoreContext, onFinish, isYFlipped, SkipWaits, beforeSwap);
}
finally
{
@ -85,11 +86,13 @@ namespace Avalonia.OpenGL.Egl
private readonly EglDisplay _display;
private readonly IDisposable _restoreContext;
private readonly Action? _onFinish;
private readonly Action? _beforeSwap;
private readonly bool _skipWaits;
public Session(EglDisplay display, EglContext context,
EglSurface glSurface, PixelSize size, double scaling,
IDisposable restoreContext, Action? onFinish, bool isYFlipped, bool skipWaits)
IDisposable restoreContext, Action? onFinish, bool isYFlipped, bool skipWaits,
Action? beforeSwap = null)
{
Size = size;
Scaling = scaling;
@ -99,6 +102,7 @@ namespace Avalonia.OpenGL.Egl
_glSurface = glSurface;
_restoreContext = restoreContext;
_onFinish = onFinish;
_beforeSwap = beforeSwap;
_skipWaits = skipWaits;
}
@ -107,6 +111,7 @@ namespace Avalonia.OpenGL.Egl
_context.GlInterface.Flush();
if (!_skipWaits)
_display.EglInterface.WaitGL();
_beforeSwap?.Invoke();
_glSurface.SwapBuffers();
if (!_skipWaits)
{

46
src/Avalonia.OpenGL/Egl/EglImage.cs

@ -0,0 +1,46 @@
using System;
namespace Avalonia.OpenGL.Egl
{
/// <summary>
/// Wraps a native <c>EGLImageKHR</c> handle and destroys it via <c>eglDestroyImageKHR</c> on disposal.
/// </summary>
public sealed class EglImage : IDisposable
{
private readonly EglDisplay _display;
private IntPtr _handle;
/// <summary>
/// Gets the native <c>EGLImageKHR</c> handle, or <see cref="IntPtr.Zero"/> once disposed.
/// </summary>
public IntPtr Handle => _handle;
/// <summary>
/// Initializes a new <see cref="EglImage"/> wrapping an existing <c>EGLImageKHR</c> handle.
/// </summary>
/// <param name="display">The EGL display that owns the image.</param>
/// <param name="handle">A valid (non-zero) <c>EGLImageKHR</c> handle.</param>
/// <exception cref="ArgumentNullException"><paramref name="display"/> is <c>null</c>.</exception>
/// <exception cref="ArgumentException"><paramref name="handle"/> is <see cref="IntPtr.Zero"/>.</exception>
public EglImage(EglDisplay display, IntPtr handle)
{
_display = display ?? throw new ArgumentNullException(nameof(display));
_handle = handle;
if (handle == IntPtr.Zero)
throw new ArgumentException("Invalid EGLImage handle", nameof(handle));
}
/// <summary>
/// Destroys the underlying <c>EGLImageKHR</c>. Safe to call more than once.
/// </summary>
public void Dispose()
{
if (_handle != IntPtr.Zero)
{
using (_display.Lock())
_display.EglInterface.DestroyImageKHR(_display.Handle, _handle);
_handle = IntPtr.Zero;
}
}
}
}

17
src/Avalonia.OpenGL/Egl/EglInterface.cs

@ -111,6 +111,9 @@ namespace Avalonia.OpenGL.Egl
[GetProcAddress("eglSwapBuffers")]
public partial void SwapBuffers(IntPtr display, IntPtr surface);
[GetProcAddress("eglSwapInterval")]
public partial bool SwapInterval(IntPtr display, int interval);
[GetProcAddress("eglCreateWindowSurface")]
public partial IntPtr CreateWindowSurface(IntPtr display, IntPtr config, IntPtr window, int[]? attrs);
@ -152,5 +155,19 @@ namespace Avalonia.OpenGL.Egl
[GetProcAddress("eglQueryDeviceAttribEXT", true)]
public partial bool QueryDeviceAttribExt(IntPtr display, int attr, out IntPtr res);
// EGL_KHR_image_base
[GetProcAddress("eglCreateImageKHR", true)]
public partial IntPtr CreateImageKHR(IntPtr display, IntPtr context, int target, IntPtr clientBuffer, int[] attribs);
[GetProcAddress("eglDestroyImageKHR", true)]
public partial bool DestroyImageKHR(IntPtr display, IntPtr image);
// EGL_EXT_image_dma_buf_import_modifiers
[GetProcAddress("eglQueryDmaBufFormatsEXT", true)]
public partial bool QueryDmaBufFormatsEXT(IntPtr display, int maxFormats, int* formats, out int numFormats);
[GetProcAddress("eglQueryDmaBufModifiersEXT", true)]
public partial bool QueryDmaBufModifiersEXT(IntPtr display, int format, int maxModifiers, ulong* modifiers, bool* externalOnly, out int numModifiers);
}
}

6
src/Avalonia.OpenGL/GlConsts.cs

@ -412,8 +412,8 @@ namespace Avalonia.OpenGL
// public const int GL_TEXTURE_ENV_MODE = 0x2200;
// public const int GL_TEXTURE_1D = 0x0DE0;
public const int GL_TEXTURE_2D = 0x0DE1;
// public const int GL_TEXTURE_WRAP_S = 0x2802;
// public const int GL_TEXTURE_WRAP_T = 0x2803;
public const int GL_TEXTURE_WRAP_S = 0x2802;
public const int GL_TEXTURE_WRAP_T = 0x2803;
public const int GL_TEXTURE_MAG_FILTER = 0x2800;
public const int GL_TEXTURE_MIN_FILTER = 0x2801;
// public const int GL_TEXTURE_ENV_COLOR = 0x2201;
@ -530,7 +530,7 @@ namespace Avalonia.OpenGL
// public const int GL_ALL_CLIENT_ATTRIB_BITS = -1;
// public const int GL_CLIENT_ALL_ATTRIB_BITS = -1;
// public const int GL_RESCALE_NORMAL = 0x803A;
// public const int GL_CLAMP_TO_EDGE = 0x812F;
public const int GL_CLAMP_TO_EDGE = 0x812F;
// public const int GL_MAX_ELEMENTS_VERTICES = 0x80E8;
// public const int GL_MAX_ELEMENTS_INDICES = 0x80E9;
// public const int GL_BGR = 0x80E0;

5
src/Avalonia.OpenGL/GlInterface.cs

@ -404,6 +404,11 @@ namespace Avalonia.OpenGL
return rv;
}
// GL_OES_EGL_image
[GetProcAddress(true)]
[GlExtensionEntryPoint("glEGLImageTargetTexture2DOES", "GL_OES_EGL_image")]
public partial void EGLImageTargetTexture2DOES(int target, IntPtr image);
public static GlInterface FromNativeUtf8GetProcAddress(GlVersion version, Func<IntPtr, IntPtr> getProcAddress)
{
return new GlInterface(version, s =>

29
src/Avalonia.Wayland/Avalonia.Wayland.csproj

@ -0,0 +1,29 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>$(AvsCurrentTargetFramework);$(AvsLegacyTargetFrameworks)</TargetFrameworks>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<EnableRuntimeMarshalling>true</EnableRuntimeMarshalling>
</PropertyGroup>
<ItemGroup Label="InternalsVisibleTo">
<InternalsVisibleTo Include="Avalonia.Wayland.UnitTests, PublicKey=$(AvaloniaPublicKey)" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="NWayland" />
<ProjectReference Include="..\..\packages\Avalonia\Avalonia.csproj" />
<ProjectReference Include="..\Avalonia.Dialogs\Avalonia.Dialogs.csproj" />
<ProjectReference Include="..\Avalonia.FreeDesktop\Avalonia.FreeDesktop.csproj" />
<Compile Include="..\Shared\RawEventGrouping.cs" />
<Compile Include="..\Avalonia.X11\X11IconLoader.cs" />
<Compile Include="..\Avalonia.X11\Clipboard\ClipboardUriListHelper.cs" />
</ItemGroup>
<Import Project="..\..\build\SourceGenerators.props" />
<Import Project="..\..\build\TrimmingEnable.props" />
<Import Project="..\..\build\NullableEnable.props" />
<ItemGroup>
<Compile Remove="..\Shared\SourceGeneratorAttributes.cs"/>
<None Include="..\Shared\SourceGeneratorAttributes.cs" Visible="false"/>
</ItemGroup>
</Project>

111
src/Avalonia.Wayland/AvaloniaWaylandException.cs

@ -0,0 +1,111 @@
using System;
using Avalonia.Wayland.Server.Interop;
namespace Avalonia.Wayland;
public class AvaloniaWaylandException : Exception
{
public AvaloniaWaylandException()
{
}
public AvaloniaWaylandException(string? message) : base(message)
{
}
public AvaloniaWaylandException(string? message, Exception? innerException) : base(message, innerException)
{
}
}
public class AvaloniaWaylandPollException : AvaloniaWaylandException
{
public AvaloniaWaylandPollException() : base("poll failed")
{
}
public AvaloniaWaylandPollException(string? message) : base(message)
{
}
public AvaloniaWaylandPollException(string? message, Exception? innerException) : base(message, innerException)
{
}
}
public class AvaloniaWaylandNetworkException : AvaloniaWaylandException
{
public AvaloniaWaylandNetworkException()
{
}
public AvaloniaWaylandNetworkException(string? message) : base(message)
{
}
public AvaloniaWaylandNetworkException(string? message, Exception? innerException) : base(message, innerException)
{
}
}
public class AvaloniaWaylandFlushException : AvaloniaWaylandNetworkException
{
public AvaloniaWaylandFlushException()
{
}
public AvaloniaWaylandFlushException(string? message) : base(message)
{
}
public AvaloniaWaylandFlushException(string? message, Exception? innerException) : base(message, innerException)
{
}
internal AvaloniaWaylandFlushException(UnsafeNativeMethods.Errno errno) : base("wl_display_flush failed, errno: " + errno)
{
}
}
public class AvaloniaWaylandReadException : AvaloniaWaylandNetworkException
{
public AvaloniaWaylandReadException()
{
}
public AvaloniaWaylandReadException(string? message) : base(message)
{
}
public AvaloniaWaylandReadException(string? message, Exception? innerException) : base(message, innerException)
{
}
internal AvaloniaWaylandReadException(UnsafeNativeMethods.Errno errno) : base("wl_display_read_events failed, errno: " + errno)
{
}
}
public class AvaloniaWaylandProtocolErrorException : AvaloniaWaylandException
{
public AvaloniaWaylandProtocolErrorException() : base("protocol error")
{
}
public AvaloniaWaylandProtocolErrorException(string? message) : base(message)
{
}
public AvaloniaWaylandProtocolErrorException(string? message, Exception? innerException) : base(message, innerException)
{
}
internal AvaloniaWaylandProtocolErrorException(uint errorCode, string errorMessage) : base($"protocol error: {errorCode} {errorMessage}")
{
ErrorCode = errorCode;
ErrorMessage = errorMessage;
}
public uint ErrorCode { get; }
public string? ErrorMessage { get; }
}

28
src/Avalonia.Wayland/AvaloniaWaylandPlatformExtensions.cs

@ -0,0 +1,28 @@
using Avalonia.Wayland;
// UseXxx are deliberately in global Avalonia namespace
// ReSharper disable once CheckNamespace
namespace Avalonia;
/// <summary>
/// <see cref="AppBuilder"/> extensions for enabling the Wayland windowing backend.
/// </summary>
public static class AvaloniaWaylandPlatformExtensions
{
/// <summary>
/// Configures the application to use the Wayland windowing backend. Options can be supplied by
/// registering a <see cref="WaylandPlatformOptions"/> instance with <see cref="AvaloniaLocator"/>.
/// </summary>
/// <param name="builder">The application builder.</param>
/// <returns>The same <paramref name="builder"/> for chaining.</returns>
public static AppBuilder UseWayland(this AppBuilder builder)
{
builder
.UseStandardRuntimePlatformSubsystem()
.UseWindowingSubsystem(() =>
WaylandPlatform.Initialize(AvaloniaLocator.Current.GetService<WaylandPlatformOptions>() ??
new WaylandPlatformOptions()));
return builder;
}
}

129
src/Avalonia.Wayland/Clipboard/WaylandClipboardImpl.cs

@ -0,0 +1,129 @@
using System.Collections.Generic;
using System.IO;
using System.Text;
using System.Threading.Tasks;
using Avalonia.Input;
using Avalonia.Input.Platform;
using Avalonia.Media.Imaging;
using Avalonia.Platform.Storage;
using Avalonia.Wayland.Server;
using Avalonia.Wayland.Server.Transient.Clipboard;
using Avalonia.X11.Clipboard;
namespace Avalonia.Wayland.Clipboard;
/// <summary>
/// Implements <see cref="IOwnedClipboardImpl"/> for the Wayland backend.
/// All Wayland protocol calls happen on the Wayland thread via <see cref="WaylandWorker"/>.
/// Data I/O (pipe reads/writes) happens on pool threads using .NET async APIs.
/// No shared mutable state between threads — all cross-thread communication via messaging.
/// </summary>
class WaylandClipboardImpl : IOwnedClipboardImpl
{
private readonly WaylandWorker _worker;
public WaylandClipboardImpl(WaylandWorker worker)
{
_worker = worker;
}
public async Task<IAsyncDataTransfer?> TryGetDataAsync()
{
var (cookie, mimeTypes) = await _worker.InvokeOobAsync(() =>
{
var device = _worker.Globals?.InputDispatcher.GetDataDevice();
if (device == null)
return ((WaylandOfferCookie?)null, (string[]?)null);
return device.GetSelectionInfo();
}).ConfigureAwait(false);
if (cookie == null || mimeTypes == null || mimeTypes.Length == 0)
return null;
// Build the item layout up front (one item per file + a shared item for the rest), reading
// the file URI list off the calling thread so item access later doesn't block.
var transfer = new WaylandDataTransfer(mimeTypes, cookie);
await transfer.PreloadAsync().ConfigureAwait(false);
return transfer;
}
public Task SetDataAsync(IAsyncDataTransfer dataTransfer)
{
var outgoing = new WaylandOutgoingTransfer(dataTransfer);
return outgoing.SetAsSelection(_worker);
}
public Task ClearAsync() => SetDataAsync(new DataTransfer());
public Task<bool> IsCurrentOwnerAsync()
{
return _worker.InvokeOobAsync(() =>
{
var device = _worker.Globals?.InputDispatcher.GetDataDevice();
return device?.ActiveSource != null;
});
}
/// <summary>
/// Serializes data from an <see cref="IAsyncDataTransfer"/> for a specific MIME type directly
/// into <paramref name="stream"/>. Streaming avoids buffering the whole payload in memory, which
/// matters for large formats such as bitmaps. Writes nothing if no item provides the format.
/// </summary>
internal static async Task SerializeForMimeAsync(IAsyncDataTransfer transfer, string mimeType, Stream stream)
{
var format = WaylandMimeMapper.FromMimeType(mimeType);
if (format == null)
return;
// Files span multiple items; gather them all and serialize as a text/uri-list.
if (DataFormat.File.Equals(format))
{
if (await transfer.TryGetFilesAsync().ConfigureAwait(false) is { Length: > 0 } files)
await stream.WriteAsync(ClipboardUriListHelper.FileUriListToUtf8Bytes(files)).ConfigureAwait(false);
return;
}
foreach (var item in transfer.Items)
{
var value = await item.TryGetRawAsync(format);
if (value == null)
continue;
if (DataFormat.Text.Equals(format))
{
if (value is string text)
{
await stream.WriteAsync(Encoding.UTF8.GetBytes(text)).ConfigureAwait(false);
return;
}
}
else if (DataFormat.Bitmap.Equals(format))
{
if (value is Bitmap bitmap)
{
// Clone the platform bitmap ref so it outlives the UI-thread retrieval, then
// encode the PNG straight into the pipe from a pool thread. This avoids both
// buffering the whole image as a byte[] and stalling the UI thread on pipe
// backpressure during the encode.
var bitmapRef = bitmap.PlatformImpl.Clone();
await Task.Run(() =>
{
using (bitmapRef)
bitmapRef.Item.Save(stream);
}).ConfigureAwait(false);
return;
}
}
else if (value is byte[] bytes)
{
await stream.WriteAsync(bytes).ConfigureAwait(false);
return;
}
else if (value is string strValue)
{
await stream.WriteAsync(Encoding.UTF8.GetBytes(strValue)).ConfigureAwait(false);
return;
}
}
}
}

265
src/Avalonia.Wayland/Clipboard/WaylandDataTransfer.cs

@ -0,0 +1,265 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.IO.Pipes;
using System.Text;
using System.Threading.Tasks;
using Avalonia.Input;
using Avalonia.Input.Platform;
using Avalonia.Media.Imaging;
using Avalonia.Platform.Storage;
using Avalonia.Platform.Storage.FileIO;
using Avalonia.Wayland.Server.Interop;
using Avalonia.Wayland.Server.Transient.Clipboard;
using Avalonia.X11.Clipboard;
namespace Avalonia.Wayland.Clipboard;
/// <summary>
/// Data transfer backed by a Wayland <c>wl_data_offer</c>, implementing both sync and async interfaces.
/// The offer cookie is validated on each read to detect stale offers.
/// Reused by both clipboard and DnD.
/// </summary>
class WaylandDataTransfer : IDataTransfer, IAsyncDataTransfer
{
private readonly string[] _mimeTypes;
private readonly WaylandOfferCookie _offerCookie;
private DataFormat[]? _formats;
private Dictionary<DataFormat, string>? _formatToMime;
private IReadOnlyList<IDataTransferItem>? _syncItems;
private IReadOnlyList<IAsyncDataTransferItem>? _asyncItems;
public WaylandDataTransfer(string[] mimeTypes, WaylandOfferCookie offerCookie)
{
_mimeTypes = mimeTypes;
_offerCookie = offerCookie;
}
private (DataFormat[] formats, Dictionary<DataFormat, string> formatToMime) BuildFormats()
{
if (_formats != null)
return (_formats, _formatToMime!);
var formatToMime = new Dictionary<DataFormat, string>();
var formats = new List<DataFormat>();
foreach (var mime in _mimeTypes)
{
var format = WaylandMimeMapper.FromMimeType(mime);
if (format != null && formatToMime.TryAdd(format, mime))
formats.Add(format);
}
_formats = formats.ToArray();
_formatToMime = formatToMime;
return (_formats, _formatToMime);
}
private DataFormat[] GetFormats() => BuildFormats().formats;
/// <summary>
/// Pre-reads the file list asynchronously and builds the item layout. Used by the clipboard read
/// path so the (cross-process) pipe read doesn't block the caller's thread.
/// </summary>
internal async Task PreloadAsync()
{
if (_syncItems != null)
return;
SetItems(BuildItems(await ReadFilesAsync().ConfigureAwait(false)));
}
private void EnsureItemsBlocking()
{
if (_syncItems != null)
return;
// Synchronous IDataTransfer contract — blocking here is allowed (DnD reads this path at drop
// on the UI thread). The offer read happens on a pool thread (the cookie posts to the Wayland
// thread, continuations use ConfigureAwait(false)), so it can't deadlock. wl_data_offer
// transfers are always cross-process — in-process drags use the original IDataTransfer.
SetItems(BuildItems(ReadFilesAsync().GetAwaiter().GetResult()));
}
private void SetItems(List<IDataTransferItem> items)
{
_syncItems = items;
_asyncItems = items.ConvertAll(static i => (IAsyncDataTransferItem)i);
}
/// <summary>
/// Builds the item layout: one item per file (each carrying a single <see cref="IStorageItem"/>,
/// matching <see cref="DataFormat.File"/>'s single-value contract) plus one shared item for all
/// remaining formats. Mirrors the other platforms so file copy/paste and drag-drop work.
/// </summary>
private List<IDataTransferItem> BuildItems(IReadOnlyList<IStorageItem>? files)
{
var (formats, formatToMime) = BuildFormats();
var items = new List<IDataTransferItem>();
List<DataFormat>? nonFileFormats = null;
foreach (var format in formats)
{
if (DataFormat.File.Equals(format))
{
if (files != null)
foreach (var file in files)
items.Add(PlatformDataTransferItem.Create(DataFormat.File, file));
}
else
(nonFileFormats ??= []).Add(format);
}
if (nonFileFormats is not null)
items.Add(new WaylandDataTransferItem(nonFileFormats.ToArray(), formatToMime, _offerCookie));
return items;
}
/// <summary>Reads and parses the <c>text/uri-list</c> into storage items, or null if no File format.</summary>
private async Task<IStorageItem[]?> ReadFilesAsync()
{
if (!BuildFormats().formatToMime.TryGetValue(DataFormat.File, out var mime))
return null;
var bytes = await WaylandDataTransferItem.ReadRawBytesAsync(_offerCookie, mime).ConfigureAwait(false);
return bytes is { Length: > 0 } ? ClipboardUriListHelper.Utf8BytesToFileUriList(bytes) : null;
}
IReadOnlyList<DataFormat> IDataTransfer.Formats => GetFormats();
IReadOnlyList<DataFormat> IAsyncDataTransfer.Formats => GetFormats();
// Synchronous interface: may block to resolve the file list (allowed by the IDataTransfer contract).
IReadOnlyList<IDataTransferItem> IDataTransfer.Items
{
get { EnsureItemsBlocking(); return _syncItems!; }
}
// The fallback here isn't supposed to be called since clipboard calls PreloadAsync, but we do a non-blocking
// fallback just in case
IReadOnlyList<IAsyncDataTransferItem> IAsyncDataTransfer.Items
=> _asyncItems ?? BuildItems(null).ConvertAll(static i => (IAsyncDataTransferItem)i);
public void Dispose()
{
// The offer is owned by WaylandDataDevice — nothing to dispose here
}
}
/// <summary>
/// A single data transfer item that reads from a Wayland data offer via pipe.
/// The offer cookie is sent back to the Wayland thread on each read;
/// the cookie validates the offer is still live and performs the receive internally.
/// Implements both <see cref="IDataTransferItem"/> (sync, blocks) and
/// <see cref="IAsyncDataTransferItem"/> (true async via pipe).
/// </summary>
class WaylandDataTransferItem : IDataTransferItem, IAsyncDataTransferItem
{
private readonly DataFormat[] _formats;
private readonly Dictionary<DataFormat, string> _formatToMime;
private readonly WaylandOfferCookie _offerCookie;
public WaylandDataTransferItem(DataFormat[] formats,
Dictionary<DataFormat, string> formatToMime, WaylandOfferCookie offerCookie)
{
_formats = formats;
_formatToMime = formatToMime;
_offerCookie = offerCookie;
}
IReadOnlyList<DataFormat> IDataTransferItem.Formats => _formats;
IReadOnlyList<DataFormat> IAsyncDataTransferItem.Formats => _formats;
/// <summary>
/// Synchronous read — blocks the calling thread while async I/O completes on pool thread.
/// Used for DnD (sync data access during event handling).
/// </summary>
public object? TryGetRaw(DataFormat format)
{
if (Array.IndexOf(_formats, format) < 0)
return null;
var data = TryGetRawCoreAsync(format).GetAwaiter().GetResult();
return data != null ? ConvertData(format, data) : null;
}
/// <summary>
/// Asynchronous read — sends the offer cookie to the Wayland thread,
/// which validates and creates a pipe; data is read on the current/pool thread.
/// ConvertData runs on the dispatcher thread after resuming from the await.
/// </summary>
public async Task<object?> TryGetRawAsync(DataFormat format)
{
if (Array.IndexOf(_formats, format) < 0)
return null;
var data = await TryGetRawCoreAsync(format);
return data != null ? ConvertData(format, data) : null;
}
/// <summary>
/// Core async implementation shared by sync and async paths. Returns raw bytes; callers are
/// responsible for calling <see cref="ConvertData"/> on the correct thread.
/// </summary>
private Task<byte[]?> TryGetRawCoreAsync(DataFormat format)
{
var mimeType = FindMimeType(format);
return mimeType == null ? Task.FromResult<byte[]?>(null) : ReadRawBytesAsync(_offerCookie, mimeType);
}
/// <summary>
/// Reads the raw bytes offered for a MIME type through a pipe. Uses ConfigureAwait(false) so a
/// synchronous caller (<see cref="TryGetRaw"/>) doesn't deadlock on the dispatcher context.
/// </summary>
internal static async Task<byte[]?> ReadRawBytesAsync(WaylandOfferCookie offerCookie, string mimeType)
{
// Send cookie + MIME to Wayland thread; cookie.TryReceiveAsync posts and returns fd.
var readFd = await offerCookie.TryReceiveAsync(mimeType).ConfigureAwait(false);
if (readFd < 0)
return null;
try
{
await using var stream = new Pipe2Stream(readFd, PipeDirection.In);
using var ms = new MemoryStream();
await stream.CopyToAsync(ms).ConfigureAwait(false);
return ms.ToArray();
}
catch
{
return null;
}
}
/// <summary>
/// Finds the MIME type to use for the given format.
/// Uses the preserved mapping from the original offer first (round-trippable),
/// then falls back to the mapper helper.
/// </summary>
private string? FindMimeType(DataFormat format)
{
if (_formatToMime.TryGetValue(format, out var mime))
return mime;
return null;
}
internal static object? ConvertData(DataFormat format, byte[] data)
{
if (data.Length == 0)
return null;
if (DataFormat.Text.Equals(format))
return Encoding.UTF8.GetString(data);
// Files are never read through a WaylandDataTransferItem: WaylandDataTransfer splits them
// into one PlatformDataTransferItem per file (DataFormat.File is a single-value format).
if (DataFormat.Bitmap.Equals(format))
{
using var ms = new MemoryStream(data);
return new Bitmap(ms);
}
if (format is DataFormat<string>)
return Encoding.UTF8.GetString(data);
if (format is DataFormat<byte[]>)
return data;
return null;
}
}

28
src/Avalonia.Wayland/Clipboard/WaylandDragSource.cs

@ -0,0 +1,28 @@
using System.Threading.Tasks;
using Avalonia.Input;
using Avalonia.Input.Platform;
using Avalonia.Wayland.Server.Persistent;
namespace Avalonia.Wayland.Clipboard;
/// <summary>
/// Implements <see cref="IPlatformDragSource"/> for the Wayland backend.
/// Delegates to <see cref="WaylandOutgoingTransfer.SetAsDrag"/> to create a
/// <c>wl_data_source</c> and call <c>wl_data_device.start_drag</c>.
/// </summary>
class WaylandDragSource : IPlatformDragSource
{
public Task<DragDropEffects> DoDragDropAsync(
PointerPressedEventArgs triggerEvent,
IDataTransfer dataTransfer,
DragDropEffects allowedEffects)
{
triggerEvent.Pointer.Capture(null);
if (dataTransfer is not IAsyncDataTransfer asyncTransfer)
return Task.FromResult(DragDropEffects.None);
if (triggerEvent.PlatformInputEventCookie is not WaylandInputEventCookie inputCookie)
return Task.FromResult(DragDropEffects.None);
var outgoing = new WaylandOutgoingTransfer(asyncTransfer);
return outgoing.SetAsDrag(inputCookie, allowedEffects);
}
}

68
src/Avalonia.Wayland/Clipboard/WaylandMimeMapper.cs

@ -0,0 +1,68 @@
using Avalonia.Input;
namespace Avalonia.Wayland.Clipboard;
/// <summary>
/// Maps between Avalonia <see cref="DataFormat"/> and MIME type strings used by Wayland's
/// wl_data_device protocol.
/// </summary>
static class WaylandMimeMapper
{
public const string MimeTextPlainUtf8 = "text/plain;charset=utf-8";
public const string MimeTextPlain = "text/plain";
public const string MimeTextUriList = "text/uri-list";
public const string MimeImagePng = "image/png";
public const string AppPrefix = "application/x-avalonia-";
/// <summary>
/// Returns the MIME type strings that should be advertised for a given <see cref="DataFormat"/>.
/// For <see cref="DataFormat.Text"/>, both <c>text/plain;charset=utf-8</c> and <c>text/plain</c> are returned.
/// </summary>
public static string[] ToMimeTypes(DataFormat format)
{
if (DataFormat.Text.Equals(format))
return [MimeTextPlainUtf8, MimeTextPlain];
if (DataFormat.File.Equals(format))
return [MimeTextUriList];
if (DataFormat.Bitmap.Equals(format))
return [MimeImagePng];
return [format.ToSystemName(AppPrefix)];
}
/// <summary>
/// Returns the single preferred MIME type for reading a given <see cref="DataFormat"/>.
/// </summary>
public static string ToPreferredMimeType(DataFormat format)
{
if (DataFormat.Text.Equals(format))
return MimeTextPlainUtf8;
if (DataFormat.File.Equals(format))
return MimeTextUriList;
if (DataFormat.Bitmap.Equals(format))
return MimeImagePng;
return format.ToSystemName(AppPrefix);
}
/// <summary>
/// Converts a MIME type string to a <see cref="DataFormat"/>, or null if not mappable.
/// </summary>
public static DataFormat? FromMimeType(string mimeType)
{
if (mimeType is MimeTextPlainUtf8 or MimeTextPlain)
return DataFormat.Text;
if (mimeType is MimeTextUriList)
return DataFormat.File;
if (mimeType is MimeImagePng)
return DataFormat.Bitmap;
return DataFormat.FromSystemName<byte[]>(mimeType, AppPrefix);
}
}

260
src/Avalonia.Wayland/Clipboard/WaylandOutgoingTransfer.cs

@ -0,0 +1,260 @@
using System;
using System.Collections.Generic;
using System.IO.Pipes;
using System.Threading.Tasks;
using Avalonia.Input;
using Avalonia.Input.Platform;
using Avalonia.Threading;
using Avalonia.Wayland.Server;
using Avalonia.Wayland.Server.Interop;
using Avalonia.Wayland.Server.Persistent;
using Avalonia.Wayland.Server.Transient;
using Avalonia.Wayland.Server.Transient.Clipboard;
using NWayland.Protocols.Wayland;
namespace Avalonia.Wayland.Clipboard;
/// <summary>
/// Manages the outgoing side of a data transfer (clipboard set or DnD initiate).
/// Created on the UI thread; coordinates between UI-thread <see cref="IAsyncDataTransfer"/>
/// and Wayland-thread <see cref="WaylandDataSource"/>.
/// <para>
/// The <c>send</c> callback dispatches to the UI thread via <see cref="Dispatcher.UIThread"/>
/// so that user-provided data transfer methods run on the correct thread.
/// </para>
/// </summary>
class WaylandOutgoingTransfer
{
private const string InProcessMimePrefix = "application/x-avalonia-inproc-dnd-";
/// <summary>
/// Unique identifier for this application instance, used as part of the sentinel MIME type
/// to distinguish our own drags from drags originating in other Avalonia processes.
/// </summary>
private static readonly string s_instanceId = Guid.NewGuid().ToString("N");
/// <summary>
/// Full MIME prefix for this app instance: <c>application/x-avalonia-inproc-dnd-{instance}-</c>.
/// </summary>
private static readonly string s_instancePrefix = InProcessMimePrefix + s_instanceId + "-";
/// <summary>
/// Maps in-process DnD operation keys to the original <see cref="IDataTransfer"/>.
/// Accessed exclusively from the Wayland thread.
/// When the receive side sees a sentinel MIME type matching this instance's prefix,
/// it uses the original transfer directly instead of reading through Wayland pipes,
/// which would deadlock (the source's <c>send</c> handler dispatches to the UI thread).
/// </summary>
private static readonly Dictionary<string, IDataTransfer> s_inProcessDrags = new();
private readonly IAsyncDataTransfer _transfer;
private readonly List<string> _mimeTypes;
/// <summary>
/// Called on the UI thread when the data source is cancelled (lost ownership or DnD ended).
/// </summary>
public Action? Cancelled { get; set; }
public WaylandOutgoingTransfer(IAsyncDataTransfer transfer)
{
_transfer = transfer;
_mimeTypes = new List<string>();
foreach (var format in transfer.Formats)
{
// In-process formats are only meaningful within this process and must never be
// advertised to (or serialized for) other Wayland clients.
if (format.Kind == DataFormatKind.InProcess)
continue;
foreach (var mime in WaylandMimeMapper.ToMimeTypes(format))
_mimeTypes.Add(mime);
}
}
/// <summary>
/// Creates a <see cref="WaylandDataSource"/> on the Wayland thread, configures it,
/// and sets it as the clipboard selection.
/// Returns a task that completes when the selection has been set.
/// </summary>
public Task SetAsSelection(WaylandWorker worker)
{
return worker.InvokeOobAsync(() =>
{
var globals = worker.Globals;
if (globals == null)
return;
var device = globals.InputDispatcher.GetDataDevice();
if (device == null)
return;
var source = CreateSource(globals);
if (source == null)
return;
source.OnCancelled = () =>
{
device.ActiveSource = null;
source.Dispose();
Dispatcher.UIThread.Post(() => Cancelled?.Invoke());
};
device.SetSelection(source);
});
}
/// <summary>
/// Creates a <see cref="WaylandDataSource"/> on the Wayland thread, configures it,
/// and starts a drag-and-drop operation using <c>wl_data_device.start_drag</c>.
/// The <paramref name="inputCookie"/> carries the globals captured at input-event time,
/// ensuring reconnect-safe access to transient state.
/// Returns a task that resolves to the resulting <see cref="DragDropEffects"/> when
/// the DnD operation completes (finished, cancelled, or failed to start).
/// </summary>
public Task<DragDropEffects> SetAsDrag(WaylandInputEventCookie inputCookie, DragDropEffects allowedEffects)
{
var tcs = new TaskCompletionSource<DragDropEffects>();
DragDropEffects negotiatedEffects = DragDropEffects.None;
inputCookie.PostOob(globals =>
{
var device = globals.InputDispatcher.GetDataDevice();
if (device == null)
{
tcs.TrySetResult(DragDropEffects.None);
return;
}
var source = CreateSource(globals);
if (source == null)
{
tcs.TrySetResult(DragDropEffects.None);
return;
}
// Register for in-process detection so the receive side can use
// the original IDataTransfer directly instead of reading through
// Wayland pipes (which would deadlock).
var operationKey = Guid.NewGuid().ToString("N");
source.Offer(s_instancePrefix + operationKey);
s_inProcessDrags[operationKey] = (IDataTransfer)_transfer;
var allowedActions = WaylandDataDevice.EffectsToActions(allowedEffects);
source.OnAction = action =>
{
negotiatedEffects = WaylandDataDevice.ActionsToEffects(action);
var cursorType = action switch
{
WlDataDeviceManager.DndActionEnum.Copy => StandardCursorType.DragCopy,
WlDataDeviceManager.DndActionEnum.Move => StandardCursorType.DragMove,
WlDataDeviceManager.DndActionEnum.Ask => StandardCursorType.DragLink,
_ => StandardCursorType.No
};
globals.InputDispatcher.SetDndCursor(cursorType);
};
source.OnCancelled = () =>
{
s_inProcessDrags.Remove(operationKey);
source.Dispose();
Dispatcher.UIThread.Post(() =>
{
Cancelled?.Invoke();
tcs.TrySetResult(DragDropEffects.None);
});
};
source.OnDndFinished = () =>
{
s_inProcessDrags.Remove(operationKey);
source.Dispose();
var result = negotiatedEffects;
Dispatcher.UIThread.Post(() => tcs.TrySetResult(result));
};
source.OnDndDropPerformed = () =>
{
// Drop performed by user but destination may still reject —
// wait for DndFinished or Cancelled to resolve the TCS.
};
if (!device.StartDrag(source, inputCookie, allowedActions))
{
s_inProcessDrags.Remove(operationKey);
source.Dispose();
tcs.TrySetResult(DragDropEffects.None);
}
});
return tcs.Task;
}
/// <summary>
/// Creates and configures a <see cref="WaylandDataSource"/> with MIME type offers
/// and a send handler that dispatches to the UI thread.
/// </summary>
private WaylandDataSource? CreateSource(WaylandGlobals globals)
{
var manager = globals.DataDeviceManager;
if (manager == null)
return null;
var sourceListener = new WaylandDataSourceListener();
var wlSource = manager.CreateDataSource(sourceListener);
var source = new WaylandDataSource(wlSource);
sourceListener.SetWrapper(source);
foreach (var mime in _mimeTypes)
source.Offer(mime);
source.OnSend = HandleSend;
return source;
}
/// <summary>
/// Called on the Wayland thread when the compositor requests data.
/// Dispatches to the UI thread to read from the user's data transfer and write to the pipe fd.
/// </summary>
private void HandleSend(string mime, int fd)
{
var transfer = _transfer;
Dispatcher.UIThread.Post(async () =>
{
await using var stream = new Pipe2Stream(fd, PipeDirection.Out);
try
{
await WaylandClipboardImpl.SerializeForMimeAsync(transfer, mime, stream);
}
catch
{
// Stream disposal closes the fd
}
});
}
/// <summary>
/// Checks whether the offered MIME types contain an in-process sentinel from this app instance.
/// If so, returns the original <see cref="IDataTransfer"/> directly, avoiding
/// pipe-based reads that would deadlock on self-drags.
/// If the sentinel matches our instance but the operation key is not found
/// (e.g., source already cleaned up), returns an empty <see cref="DataTransfer"/>
/// as a safe dummy — we cannot interact with our own data offer via pipes.
/// Called from the Wayland thread during <c>data_device.enter</c>.
/// </summary>
internal static IDataTransfer? TryGetInProcessTransfer(string[] mimeTypes)
{
foreach (var mime in mimeTypes)
{
if (!mime.StartsWith(s_instancePrefix, StringComparison.Ordinal))
continue;
var operationKey = mime.Substring(s_instancePrefix.Length);
// Found our instance prefix — either return the real transfer or a safe dummy
return s_inProcessDrags.TryGetValue(operationKey, out var transfer)
? transfer
: new DataTransfer();
}
return null;
}
}

19
src/Avalonia.Wayland/IWaylandXdgTopLevelExport.cs

@ -0,0 +1,19 @@
using System;
using System.Threading.Tasks;
namespace Avalonia.Wayland;
/// <summary>
/// UI-thread façade over a worker-thread <c>zxdg_exported_v2</c> handle. Restricts
/// the UI side to the only two operations it should care about: awaiting the
/// compositor-issued handle string and disposing the export. The underlying worker
/// object is intentionally not exposed.
/// </summary>
internal interface IWaylandXdgTopLevelExport : IDisposable
{
/// <summary>
/// Resolves to the raw compositor-issued handle string (no protocol prefix), or
/// <c>null</c> if the wayland connection died before the handle was delivered.
/// </summary>
Task<string?> HandleTask { get; }
}

86
src/Avalonia.Wayland/PopupImpl.Sink.cs

@ -0,0 +1,86 @@
using System;
using Avalonia.Controls;
using Avalonia.Wayland.Server;
using Avalonia.Wayland.Server.Persistent;
namespace Avalonia.Wayland;
partial class PopupImpl
{
private new sealed class Sink : WindowBaseImpl.Sink, IWXdgPopupEventSink
{
private new PopupImpl Parent => (PopupImpl)base.Parent;
private WaylandSurfaceCreateResult<WXdgPopupProxy>? _handle;
private WXdgPopupProxy? _surfaceProxy;
public Sink(PopupImpl parent) : base(parent)
{
// Worker→UI sink calls (OnPointerEnter, OnPopupConfigure, etc.)
// are invoked from the worker thread; wrap ourselves in a UI-
// thread proxy so they marshal onto the dispatcher.
var sinkProxy = new WXdgPopupEventSinkProxy(this, WaylandMarshallers.UIThread);
// The xdg_popup parent is the direct parent surface (toplevel
// or another popup). The compositor uses parent-relative
// coordinates and is free to reposition the popup; chaining
// through the real visual hierarchy lets it constrain each
// link's anchor rect against its direct parent.
var parentProxy = Parent._parent.SurfaceProxy
?? throw new InvalidOperationException(
"Cannot create popup: parent surface has not been mapped yet.");
_handle = Parent._workerClient.CreatePopupHandle(sinkProxy, parentProxy);
_surfaceProxy = _handle.Proxy;
Parent._handle = _handle;
Parent._surfaceProxy = _surfaceProxy;
// Replay the cached positioner (if Show was called after
// UpdatePositioner — common Avalonia ordering) so the worker
// can attach the popup as soon as the parent is mapped.
if (Parent._lastPositioner is { } pos)
_surfaceProxy.UpdatePositioner(pos);
// Re-apply cursor (defaults to Arrow on a fresh worker WSurface).
if (Parent.CurrentCursor is not null)
Parent.ApplyCurrentCursor(_surfaceProxy);
}
protected override void DisconnectFromSurface()
{
var proxy = Parent._surfaceProxy;
Parent._handle = null;
Parent._surfaceProxy = null;
proxy?.Disconnect();
}
public void OnPopupConfigure(XdgPopupConfigureBatch batch)
{
// Marshalled to UI thread by WXdgPopupEventSinkProxy.
if (IsDisposed)
return;
// The compositor's suggested size is intentionally IGNORED
// for now: per xdg_surface.configure, this is a *suggested*
// surface change, and the framework's positioner-supplied
// size is the authoritative one we use for layout. The
// worker→UI plumbing is kept because we may want to revisit
// this for compositor-driven shrink-to-fit behaviour.
_ = batch.Width;
_ = batch.Height;
// Ack the configure on next commit.
_surfaceProxy?.SetPendingAckSerial(batch.Serial);
}
public void OnPopupDone()
{
// Compositor dismissed the popup (click outside, parent
// unmapped, etc.). xdg-shell forbids any further use of the
// xdg_popup object after popup_done; treat this as a close
// and let Dispose tear it down.
if (IsDisposed)
return;
Parent.Dispose();
}
}
}

118
src/Avalonia.Wayland/PopupImpl.cs

@ -0,0 +1,118 @@
using System;
using Avalonia.Controls;
using Avalonia.Controls.Primitives.PopupPositioning;
using Avalonia.Input;
using Avalonia.Input.Raw;
using Avalonia.Platform;
using Avalonia.Platform.Surfaces;
using Avalonia.Wayland.Server;
using Avalonia.Wayland.Server.Persistent;
namespace Avalonia.Wayland;
/// <summary>
/// Wayland xdg_popup window implementation. Created via
/// <see cref="WindowImpl.CreatePopup"/> (or another <see cref="PopupImpl.CreatePopup"/>
/// for nested popups) and parented to either a top-level or another popup.
///
/// <para>We deliberately do NOT call <c>xdg_popup.grab()</c>. Dismissal is
/// driven by: (1) framework light-dismiss, (2) the compositor's
/// <c>popup_done</c> event, (3) focus-leave on the parent toplevel.</para>
///
/// <para>Coordinate convention: the <see cref="PopupPositioner"/>'s
/// <see cref="IPopupPositioner.Update"/> receives anchor-rect coordinates
/// in the parent's client-area logical pixels (i.e. parent
/// buffer-relative). We forward them straight through to the worker via
/// <see cref="WaylandConversionExtensions.ToWayland(PopupPositionerParameters)"/>;
/// the worker handles the buffer→geometry origin shift and clamping.</para>
/// </summary>
internal partial class PopupImpl : WindowBaseImpl, IPopupImpl
{
private readonly WindowBaseImpl _parent;
private readonly WaylandWorkerClient _workerClient;
private WaylandSurfaceCreateResult<WXdgPopupProxy>? _handle;
private WXdgPopupProxy? _surfaceProxy;
private XdgPopupPositionerParams? _lastPositioner;
public PopupImpl(WaylandWorkerClient client, WindowBaseImpl parent) : base(client)
{
_parent = parent;
_workerClient = client;
// Inherit the parent's render scale at construction time so the
// first layout pass uses something sensible. The compositor will
// re-issue scale events via OnScaleChanged once the popup is
// mapped — at which point we'll converge on the authoritative value.
RenderScaling = parent.RenderScaling;
PopupPositioner = new WaylandPopupPositioner(this);
}
public override IPlatformRenderSurface[] Surfaces => _handle?.GetRenderSurfaces() ?? [];
/// <summary>
/// Inherit the parent's auto-size hint (which on Wayland comes from
/// <c>xdg_toplevel.configure_bounds</c> — i.e. the usable area of
/// the output the parent is on). xdg_popup is constrained by the
/// compositor to fit on-screen, so the same bound applies.
/// </summary>
public override Size MaxAutoSizeHint => _parent.MaxAutoSizeHint;
internal override WXdgShellSurfaceProxy? SurfaceProxy => _surfaceProxy;
public IPopupPositioner PopupPositioner { get; }
public override void Show(bool activate, bool isDialog)
{
if (IsDisposed)
throw new ObjectDisposedException(nameof(PopupImpl));
if (CurrentSink == null)
CurrentSink = new Sink(this);
Client.AnyThreadWakeupRenderLoop();
}
public override IPopupImpl CreatePopup() => new PopupImpl(_workerClient, this);
/// <summary>
/// Avalonia drives popup positioning through this entry. We translate
/// the framework's bitfield enums into the protocol's dense enums and
/// hand the bundle to the worker, which (re-)builds an
/// <c>xdg_positioner</c> on every connect.
/// </summary>
internal void UpdatePositioner(PopupPositionerParameters parameters)
{
var translated = parameters.ToWayland();
_lastPositioner = translated;
// Adopt the requested popup size as our client-size up-front so
// the renderer paints at the right dimensions before the
// compositor's first popup configure event arrives. ClientSize's
// setter only stores + wakes the render loop, so fire Resized
// explicitly to drive Avalonia's layout pass.
if (translated.Size.Width > 0 && translated.Size.Height > 0
&& translated.Size != ClientSize)
{
ClientSize = translated.Size;
Resized?.Invoke(translated.Size, WindowResizeReason.Layout);
}
_surfaceProxy?.UpdatePositioner(translated);
}
public void SetWindowManagerAddShadowHint(bool enabled)
{
// No-op on Wayland — the compositor doesn't draw popup shadows;
// CSD shadows (if any) are baked into the buffer by the framework.
}
public void TakeFocus()
{
}
/// <summary>
/// Bridges Avalonia's positioner contract to <see cref="UpdatePositioner"/>.
/// </summary>
private sealed class WaylandPopupPositioner(PopupImpl owner) : IPopupPositioner
{
public void Update(PopupPositionerParameters parameters) => owner.UpdatePositioner(parameters);
}
}

100
src/Avalonia.Wayland/README.md

@ -0,0 +1,100 @@
# Wayland platform
## Special considerations
Unlike other platforms where the OS provided window server is considered to be stable, with Wayland it's EXPECTED for
compositor to crash and restart during normal usage. This means that applications need to be able to handle this situation
gracefully. So Avalonia.Wayland platform maintains its own state of the wayland surfaces in a way that can be re-uploaded
to the compositor on restart. It also means that all kinds of resources are considered to be transient and are a subject
to be re-created as needed.
Another difference with our other platform implementations, is that Wayland can't have the same render timer concept:
the compositor is controlling when the application should render next frame for EACH toplevel by sending frame callbacks.
This means that we need to react fast to said callbacks to provide acceptable frame rate. So our wayland event loop
runs on a dedicated thread that also serves as our render thread. Yes, Wayland worker thread == Avalonia's render thread.
NOTE: crash recovery is not yet supported when using an externally created wl_display.
## Protocol docs
Do NOT assume things about Wayland protocols. Those could be rather non-intuitive. Always check what the protocol says
about a particular event.
LLMs should expect to find protocol docs in `<solution_dir>/../NWayland/external/{wayland|wayland-protocols|plasma-wayland-protocols|wlr-protocols}`
Humans using LLM agents are expected to clone NWayland (https://github.com/AvaloniaUI/NWayland) there.
NWayland generally comments protocol docs as C# XML comments in generated bindings, so those can be used as the source of information too.
LLMs should NEVER attempt to disassemble NWayland.dll or attempt to extract strings from it or use similar silly practices to extract protocol binding information, use .xml files from nuget cache instead.
NWayland converts snake_case to CamelCase, but XML docs might still be mentioning things by snake_case naming.
## Architecture
We are using NWayland as wayland bindings. We are always using a dedicated wayland queue for all of our wayland interactions so
Avalonia can potentially be embedded into another toolkit (if said toolkit actually verifies that it gets events from objects it owns).
Wayland interactions are running on a dedicated thread that also serves as our render thread.
We are sending most of our UI->Wayland commands as a part of our composition batches, so they arrive alongside with
the information required to render frame, so the wayland thread always works with a consistent view of the UI state.
There are also OOB commands for special cases that bypass regular Compositor's commit cycle.
## "Persistence"
To handle compositor restarts, we maintain a "persistent" state of our surfaces and resources (see Server/Persistent dir).
Entities bound to a connection defined in Server/Transient dir, they should be considered to be ephemeral.
## Render timer
Since wayland asks us nicely to NOT render when we want to, but instead tells us when to, the render timer is not an actual
timer, but something that gets triggered by frame callbacks. So if we expect the render timer to do something useful
for e. g. new surface, we need to wake it up explicitly.
For inevitable oversights there is currently a "fallback" timer that ticks at 20FPS, but it should be removed once
we are sure that all the cases are covered (this will likely require some refactoring of UI thread's animation engine).
## Threading rules
**NO CROSS-THREAD VARIABLE ACCESS. MESSAGING ONLY.**
- Wayland interactions run on a dedicated thread (the wayland worker thread).
- UI thread objects must NEVER directly access fields of wayland-thread objects (aside from initial creation / passing to constructors).
- Wayland thread objects must NEVER directly read fields of UI-thread objects. No volatile fields, no locks, no "safe" shared state.
- All cross-thread communication from UI to wayland goes through code-generated proxies that internally route calls through `WaylandWorker.PostOob()` / `PostWithCommit()` messages (dnd/clipboard is currently an exception to this rule that we'll probably refactor later)
- ~~Input (mouse/touch/keyboard) events from wayland to UI flow through `AutomaticRawEventGrouperDispatchQueue` (enqueued on wayland thread, drained by UI thread dispatcher).~~ (this was the plan, but we need an input root to issue events from non-UI tread, so for now we simply make calls with default priority and let the auto-grouper to dispatch them, will think what to do about it later).
- Server objects (in `Server/`) should not have their internal state modified from UI thread code. The UI thread may only call `Post` and pass values into server object constructors.
## Wayland protocol rules
### Globals and versioning
- The compositor announces globals via `wl_registry.global` with the **maximum** version it supports.
- The client chooses the version it wants when calling `Bind` (should be ≤ compositor's version, ≤ bindings version).
- Use `Math.Min(compositorVersion, known-supported-version)` as the bind version; skip if below minimum required.
- Once a proxy is bound at a version, all objects created through it (factory methods or arriving as events) inherit that version.
- Events for versions higher than the bound version simply do not arrive (safe degradation).
### Globals can come and go
- Globals like `wl_seat` and `wl_output` can appear and disappear dynamically via `wl_registry.global` / `global_remove`.
- There can be **multiple** instances of the same global type simultaneously (e.g., multiple seats representing different input device groups).
- Track globals by their registry `name` (uint) so they can be properly cleaned up on `global_remove`.
- Do not assume singletons — design data structures to handle multiple instances.
### wl_pointer frame semantics
- `wl_pointer` uses frame-based event delivery: events (enter, leave, motion, button, axis) accumulate, then a `frame` event signals the end of a logical group.
- **All events within a frame must be dispatched in arrival order.** A frame can contain leave from surface A followed by enter on surface B — dispatching in an arbitrary hardcoded order will route events to the wrong surface.
- Multiple `wl_pointer.axis` events within the same frame should be combined (e.g., H+V scroll into a single vector), but the combined result must be dispatched at the correct position in the event sequence (not after all other events).
- Frame state (focused sink, position, modifiers, accumulated events) belongs to the **pointer**, not the seat. A seat manages device lifecycle; each `wl_pointer` has its own independent frame grouping.
- Button codes are Linux `input-event-codes.h` constants: `BTN_LEFT=0x110`, `BTN_RIGHT=0x111`, `BTN_MIDDLE=0x112`, `BTN_SIDE=0x113`, `BTN_EXTRA=0x114`.
### NWayland specifics
- Listeners are passed at bind/creation time (e.g., `WlSeat.Bind(..., listener)`). There is no `SetListener` method. This is needed because of known race conditions in libwayland-client.
- `WlFixed` supports explicit cast to double: `(double)surfaceX`.
- Enums like `WlSeat.CapabilityEnum` support `.HasFlag()` and `==` comparison. Do not access `.value__` (those are actually enums it's just reference API file got generated by MSFT tool that turned them into classes for some reason)
- Do NOT attempt to re-declare wayland protocol enums even if generated method accepts int/uint. This is due to XML protocol specs not actually providing machine-readable information about a particular enum being expected by the request/event. The enums themselves are still generated.
- ALWAYS specify the version range of the protocol that's supported by Avalonia. NWayland supporting a particular protocol version binding-wise does NOT mean that we are ready to support it. Even if protocol says "stable" it does't mean that there aren't new requirements or invariants for the protocol clients to support.
- Proxy version is available from Version property on all proxies. If some request is only available from version X, NWayland generates Is<Name>Available property (e. g. `public bool IsSetReactiveAvailable => Version >= 3;`) that can be used instead of `Version` checks.

21
src/Avalonia.Wayland/Screens/IWaylandOutputsSink.cs

@ -0,0 +1,21 @@
using Avalonia.SourceGenerator;
using Avalonia.Threading;
using Avalonia.Wayland.Server;
namespace Avalonia.Wayland.Screens;
/// <summary>
/// Worker → UI sink delivering a full <see cref="WaylandOutputsSnapshot"/>
/// whenever the compositor adds, removes, or finishes updating an output.
/// Implemented on the UI thread by <see cref="SnapshotScreensImpl"/>; the
/// worker holds the generated <c>WaylandOutputsSinkProxy</c> which marshals
/// every call onto the UI dispatcher.
/// </summary>
[GenerateCrossThreadProxy(
typeof(DispatcherPriority),
"default",
GeneratedClassName = "WaylandOutputsSinkProxy")]
internal interface IWaylandOutputsSink
{
void OnOutputsChanged(WaylandOutputsSnapshot snapshot);
}

105
src/Avalonia.Wayland/Screens/SnapshotScreensImpl.cs

@ -0,0 +1,105 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Avalonia.Platform;
namespace Avalonia.Wayland.Screens;
/// <summary>
/// UI-thread <see cref="IScreenImpl"/> driven entirely by snapshots
/// pushed from the wayland worker via <see cref="IWaylandOutputsSink"/>.
/// Per <c>Avalonia.Wayland</c> screen design, <see cref="Screen.Scaling"/>
/// is always <c>1.0</c>: fractional / integer scaling is a per-window
/// concern (matching the macOS model).
/// </summary>
internal sealed class SnapshotScreensImpl
: ScreensBase<object, WaylandSnapshotScreen>, IWaylandOutputsSink
{
private WaylandOutputsSnapshot _latest = new(Array.Empty<WaylandOutputSnapshot>());
public void OnOutputsChanged(WaylandOutputsSnapshot snapshot)
{
_latest = snapshot;
OnChanged();
}
internal WaylandOutputSnapshot? Lookup(object id) =>
_latest.Outputs.FirstOrDefault(o => ReferenceEquals(o.Id, id));
protected override IReadOnlyList<object> GetAllScreenKeys() =>
_latest.Outputs.Select(o => o.Id).ToList();
protected override WaylandSnapshotScreen CreateScreenFromKey(object key) =>
new(this, key);
protected override void ScreenAdded(WaylandSnapshotScreen screen)
{
screen.Refresh();
base.ScreenAdded(screen);
}
protected override void ScreenChanged(WaylandSnapshotScreen screen) =>
screen.Refresh();
protected override Screen? ScreenFromTopLevelCore(ITopLevelImpl topLevel)
{
if (topLevel is WindowBaseImpl wb)
{
// Last-entered output is the "current" one. Walk in reverse
// so we prefer the freshest enter.
var ids = wb.CurrentOutputIds;
for (var i = ids.Count - 1; i >= 0; i--)
{
if (TryGetScreen(ids[i], out var s))
return s;
}
}
return base.ScreenFromTopLevelCore(topLevel);
}
}
/// <summary>
/// One screen view-model backed by the most recent snapshot. Reads its
/// data on every <see cref="Refresh"/> from the owning
/// <see cref="SnapshotScreensImpl"/>. Identity (the platform handle) is
/// the worker-side opaque <c>Output</c> instance.
/// </summary>
internal sealed class WaylandSnapshotScreen(SnapshotScreensImpl owner, object id)
: PlatformScreen(new WaylandScreenHandle(id))
{
internal object Id => id;
public void Refresh()
{
var snap = owner.Lookup(id);
if (snap is null)
return;
DisplayName = snap.Name ?? snap.Description ?? snap.Model;
// Per design: screen-level scale is always 1; per-window scaling
// is owned by WSurface (preferred_buffer_scale + wp_fractional_scale).
Scaling = 1;
Bounds = new PixelRect(snap.LogicalPosition, snap.LogicalSize);
// Wayland gives us no panel/strut info; treat the full bounds as
// working area (matches Mutter / KWin client expectations).
WorkingArea = Bounds;
}
}
/// <summary>
/// Opaque platform handle for a Wayland screen. The handle's
/// <see cref="IPlatformHandle.Handle"/> is unused; identity is carried
/// by the wrapped <see cref="Id"/> reference equality.
/// </summary>
internal sealed class WaylandScreenHandle(object id) : IPlatformHandle, IEquatable<WaylandScreenHandle>
{
public object Id { get; } = id;
public IntPtr Handle => IntPtr.Zero;
public string? HandleDescriptor => "WaylandOutput";
public bool Equals(WaylandScreenHandle? other) =>
other is not null && ReferenceEquals(Id, other.Id);
public override bool Equals(object? obj) => obj is WaylandScreenHandle h && Equals(h);
public override int GetHashCode() => System.Runtime.CompilerServices.RuntimeHelpers.GetHashCode(Id);
}

37
src/Avalonia.Wayland/Screens/WaylandOutputSnapshot.cs

@ -0,0 +1,37 @@
using System.Collections.Generic;
using Avalonia.SourceGenerator;
using NWayland.Protocols.Wayland;
namespace Avalonia.Wayland.Screens;
/// <summary>
/// Immutable per-output snapshot pushed worker → UI on every
/// add / remove / property change. <see cref="Id"/> is a process-unique
/// opaque token whose identity is stable for the lifetime of the
/// underlying wayland <c>wl_output</c> on the worker side, and is reused
/// in <c>IWSurfaceEventSink.OnSurfaceOutputsChanged</c> snapshots so the
/// UI thread can correlate per-surface output lists with screens.
/// </summary>
[DefinitelyNotARecord]
internal sealed partial class WaylandOutputSnapshot(
object Id,
string? Name,
string? Description,
string? Manufacturer,
string? Model,
PixelPoint LogicalPosition,
PixelSize LogicalSize,
int IntegerScale,
double RefreshRateHz,
WlOutput.SubpixelEnum Subpixel,
WlOutput.TransformEnum Transform,
PixelSize PhysicalSizeMm);
/// <summary>
/// Full snapshot of all outputs the compositor currently has bound and
/// fully initialised (i.e. at least their first <c>done</c> batch — and
/// matching xdg_output done if applicable — has been received). Pushed
/// to the UI thread by the worker.
/// </summary>
[DefinitelyNotARecord]
internal sealed partial class WaylandOutputsSnapshot(IReadOnlyList<WaylandOutputSnapshot> Outputs);

118
src/Avalonia.Wayland/Server/Interop/DrmGbmUnsafeNativeMethods.cs

@ -0,0 +1,118 @@
using System;
using System.Runtime.InteropServices;
namespace Avalonia.Wayland.Server.Interop
{
[Flags]
internal enum GbmBoFlags
{
GBM_BO_USE_SCANOUT = (1 << 0),
GBM_BO_USE_RENDERING = (1 << 2),
GBM_BO_USE_LINEAR = (1 << 4),
}
[StructLayout(LayoutKind.Explicit)]
internal struct GbmBoHandle
{
[FieldOffset(0)]
public IntPtr Ptr;
[FieldOffset(0)]
public int S32;
[FieldOffset(0)]
public uint U32;
[FieldOffset(0)]
public long S64;
[FieldOffset(0)]
public ulong U64;
}
[StructLayout(LayoutKind.Sequential)]
internal unsafe struct DrmDevice
{
public IntPtr* Nodes; // char** — DRM_NODE_MAX sized array
public int AvailableNodes; // DRM_NODE_* bitmask
public int BusType;
public IntPtr BusInfo; // union of pointers
public IntPtr DeviceInfo; // union of pointers
}
internal static unsafe class DrmGbmUnsafeNativeMethods
{
private const string LibGbm = "libgbm.so.1";
private const string LibDrm = "libdrm.so.2";
private const string LibC = "libc";
// DRM format constants
public static uint DrmFormatCode(char a, char b, char c, char d) =>
(uint)a | ((uint)b << 8) | ((uint)c << 16) | ((uint)d << 24);
public static readonly uint DRM_FORMAT_ARGB8888 = DrmFormatCode('A', 'R', '2', '4');
public static readonly uint DRM_FORMAT_XRGB8888 = DrmFormatCode('X', 'R', '2', '4');
public const ulong DRM_FORMAT_MOD_INVALID = 0x00ffffffffffffffUL;
// GBM device
[DllImport(LibGbm)]
public static extern IntPtr gbm_create_device(int fd);
[DllImport(LibGbm)]
public static extern void gbm_device_destroy(IntPtr gbm);
// GBM buffer object - basic
[DllImport(LibGbm)]
public static extern IntPtr gbm_bo_create(IntPtr gbm, uint width, uint height,
uint format, GbmBoFlags flags);
[DllImport(LibGbm)]
public static extern IntPtr gbm_bo_create_with_modifiers2(IntPtr gbm, uint width, uint height,
uint format, ulong* modifiers, uint count, GbmBoFlags flags);
[DllImport(LibGbm)]
public static extern void gbm_bo_destroy(IntPtr bo);
// GBM buffer object - properties
[DllImport(LibGbm)]
public static extern uint gbm_bo_get_width(IntPtr bo);
[DllImport(LibGbm)]
public static extern uint gbm_bo_get_height(IntPtr bo);
[DllImport(LibGbm)]
public static extern uint gbm_bo_get_format(IntPtr bo);
[DllImport(LibGbm)]
public static extern ulong gbm_bo_get_modifier(IntPtr bo);
[DllImport(LibGbm)]
public static extern int gbm_bo_get_plane_count(IntPtr bo);
[DllImport(LibGbm)]
public static extern GbmBoHandle gbm_bo_get_handle_for_plane(IntPtr bo, int plane);
[DllImport(LibGbm)]
public static extern uint gbm_bo_get_stride_for_plane(IntPtr bo, int plane);
[DllImport(LibGbm)]
public static extern uint gbm_bo_get_offset(IntPtr bo, int plane);
// DRM
[DllImport(LibDrm)]
public static extern int drmPrimeHandleToFD(int fd, uint handle, uint flags, out int prime_fd);
[DllImport(LibDrm)]
public static extern int drmGetDeviceFromDevId(ulong devId, uint flags, out DrmDevice* device);
[DllImport(LibDrm)]
public static extern void drmFreeDevice(DrmDevice** device);
public const int DRM_NODE_RENDER = 2;
// libc
[DllImport(LibC)]
public static extern int open(string pathname, int flags);
[DllImport(LibC)]
public static extern int close(int fd);
public const int O_RDWR = 2;
}
}

32
src/Avalonia.Wayland/Server/Interop/Pipe2Stream.cs

@ -0,0 +1,32 @@
using System;
using System.IO.Pipes;
using System.Runtime.InteropServices;
using Microsoft.Win32.SafeHandles;
namespace Avalonia.Wayland.Server.Interop;
/// <summary>
/// A <see cref="PipeStream"/> backed by a file descriptor from <c>pipe2()</c>.
/// Sets <c>O_NONBLOCK</c> to enable async I/O via the .NET pipe infrastructure.
/// </summary>
class Pipe2Stream : PipeStream
{
[DllImport("libc", SetLastError = true)]
private static extern int fcntl(int fd, int cmd, int arg);
private const int F_GETFL = 3;
private const int F_SETFL = 4;
private const int O_NONBLOCK = 0x800; // Linux x86_64
public Pipe2Stream(int fd, PipeDirection direction)
: base(direction, 0)
{
int flags = fcntl(fd, F_GETFL, 0);
if (flags != -1)
fcntl(fd, F_SETFL, flags | O_NONBLOCK);
var handle = new SafePipeHandle((IntPtr)fd, ownsHandle: true);
InitializeHandle(handle, isExposed: false, isAsync: true);
IsConnected = true;
}
}

110
src/Avalonia.Wayland/Server/Interop/UnsafeNativeMethods.cs

@ -0,0 +1,110 @@
using System;
using System.Runtime.InteropServices;
#pragma warning disable CS8981 // The type name only contains lower-cased ascii characters. Such names may become reserved for the language.
namespace Avalonia.Wayland.Server.Interop;
unsafe class UnsafeNativeMethods
{
public struct pollfd
{
public int fd;
public PollEvents events;
public PollEvents revents;
}
public const int O_NONBLOCK = 2048;
public const int O_CLOEXEC = 0x80000;
public const uint MFD_CLOEXEC = 1;
public const int PROT_READ = 1;
public const int PROT_WRITE = 2;
public const int MAP_SHARED = 1;
public const int MAP_PRIVATE = 2;
[Flags]
public enum PollEvents : short
{
POLLIN = 0x0001,
POLLPRI = 0x0002,
POLLOUT = 0x0004,
POLLERR = 0x0008,
POLLHUP = 0x0010,
POLLNVAL = 0x0020
}
public enum Errno
{
EINTR = 4,
EAGAIN = 11,
EPIPE = 32,
ECONNRESET = 104,
EPROTO = 71,
}
[DllImport("libc", SetLastError = true)]
public static extern int poll(pollfd* fds, IntPtr nfds, int timeout);
[DllImport("libc")]
public static extern int pipe2(int* fds, int flags);
[DllImport("libc")]
public static extern IntPtr write(int fd, void* buf, IntPtr count);
[DllImport("libc")]
public static extern IntPtr read(int fd, void* buf, IntPtr count);
[DllImport("libc")]
public static extern int memfd_create(string name, uint flags);
[DllImport("libc", SetLastError = true)]
public static extern int ftruncate(int fd, IntPtr length);
[DllImport("libc")]
public static extern void close(int fd);
[DllImport("libc", EntryPoint = "mmap", SetLastError = true)]
public static extern IntPtr mmap(IntPtr addr, IntPtr length, int prot, int flags,
int fd, IntPtr offset);
[DllImport("libc", EntryPoint = "munmap", SetLastError = true)]
public static extern int munmap(IntPtr addr, IntPtr length);
// libwayland-cursor.so.0
[StructLayout(LayoutKind.Sequential)]
public struct wl_cursor_image
{
public uint width;
public uint height;
public uint hotspot_x;
public uint hotspot_y;
public uint delay;
}
[StructLayout(LayoutKind.Sequential)]
public struct wl_cursor
{
public uint image_count;
public wl_cursor_image** images;
public byte* name;
}
[DllImport("libwayland-cursor.so.0")]
public static extern IntPtr wl_cursor_theme_load(string? name, int size, IntPtr shm);
[DllImport("libwayland-cursor.so.0")]
public static extern void wl_cursor_theme_destroy(IntPtr theme);
[DllImport("libwayland-cursor.so.0")]
public static extern wl_cursor* wl_cursor_theme_get_cursor(IntPtr theme, string name);
[DllImport("libwayland-cursor.so.0")]
public static extern IntPtr wl_cursor_image_get_buffer(wl_cursor_image* image);
}

70
src/Avalonia.Wayland/Server/Interop/WakeupFd.cs

@ -0,0 +1,70 @@
using System.Diagnostics;
using System.Runtime.InteropServices;
namespace Avalonia.Wayland.Server.Interop;
using static UnsafeNativeMethods;
unsafe class WakeupFd : System.IDisposable
{
private readonly int _read;
private readonly int _write;
private readonly object _lock = new();
private bool _signaled;
private bool _disposed;
public int PollFd => _read;
public WakeupFd()
{
int* fds = stackalloc int[2];
if (pipe2(fds, O_NONBLOCK | O_CLOEXEC) != 0)
throw new System.ComponentModel.Win32Exception(System.Runtime.InteropServices.Marshal.GetLastWin32Error());
_read = fds[0];
_write = fds[1];
}
private static readonly void* s_readBuf = (void*)Marshal.AllocHGlobal(1024);
public void Clear()
{
lock (_lock)
{
if(!_signaled)
return;
var readNow = read(_read, s_readBuf, 1);
Debug.Assert(readNow <= 1);
_signaled = false;
}
}
public void Set()
{
lock (_lock)
{
if(_signaled)
return;
byte b = 0;
write(_write, &b, 1);
_signaled = true;
}
}
~WakeupFd() => DisposeCore();
public void Dispose()
{
DisposeCore();
System.GC.SuppressFinalize(this);
}
private void DisposeCore()
{
lock (_lock)
{
if (_disposed)
return;
_disposed = true;
close(_read);
close(_write);
}
}
}

177
src/Avalonia.Wayland/Server/Interop/WaylandConnection.cs

@ -0,0 +1,177 @@
using System;
using System.Runtime.InteropServices;
using NWayland;
using NWayland.Protocols.Wayland;
using static Avalonia.Wayland.Server.Interop.UnsafeNativeMethods;
namespace Avalonia.Wayland.Server.Interop;
class WaylandConnection : IDisposable
{
public WlDisplay Display { get; private set; }
public WlEventQueue Queue { get; private set; }
public bool IsConnected { get; private set; } = true;
private readonly bool _ownsDisplay;
private readonly int _fd;
public WaylandConnection(string? path)
{
_ownsDisplay = true;
Display = WlDisplay.Connect(path);
Queue = Display.CreateEventQueue();
_fd = Display.GetFd();
}
public WaylandConnection(int fd)
{
_ownsDisplay = true;
Display = WlDisplay.ConnectToFd(fd);
Queue = Display.CreateEventQueue();
_fd = Display.GetFd();
}
public WaylandConnection(WlDisplay foreignDisplay)
{
Display = foreignDisplay;
Queue = Display.CreateEventQueue();
_fd = Display.GetFd();
}
public void Dispose()
{
IsConnected = false;
Queue.Dispose();
if (_ownsDisplay)
Display.Dispose();
}
public void DispatchQueueOrWakeup(int wakeupFd)
{
DispatchQueueOrWakeup(Queue, wakeupFd);
}
unsafe void DoPoll(Span<pollfd> fds)
{
while (true)
{
int pollRet;
fixed (pollfd* fdsPtr = fds)
pollRet = poll(fdsPtr, new IntPtr(fds.Length), -1);
if (pollRet <= 0)
{
var errno = Marshal.GetLastPInvokeError();
// we need to check for this because poll ignores SA_RESTART set by .NET runtime
if (errno == (int)Errno.EINTR)
continue;
// Release the display read lock
Display.CancelRead();
// This is quite likely a non-recoverable error
throw new AvaloniaWaylandPollException();
}
return;
}
}
bool FlushDisplay(out Errno flushError)
{
var flushRet = Display.Flush();
if (flushRet >= 0) // Successful flush
{
flushError = default;
return true;
}
flushError = (Errno)Marshal.GetLastPInvokeError();
if (flushError == Errno.EAGAIN)
{
// Not an error, just network buffers being full. We need to read our side of the socket and/or wait
// for the compositor to process the previous requests, so report success
flushError = default;
return true;
}
return false;
}
public enum DispatchResult
{
Dispatched,
Wakeup,
ConnectionReset
}
bool IsConnectionReset(Errno errno) => errno is Errno.EPIPE or Errno.ECONNRESET;
bool ClassifyErrorOrThrow(Errno errno, out DispatchResult result)
{
if (IsConnectionReset(errno))
{
result = DispatchResult.ConnectionReset;
return true;
}
if (errno == Errno.EPROTO)
{
throw new AvaloniaWaylandProtocolErrorException();
}
result = default;
return false;
}
public unsafe DispatchResult DispatchQueueOrWakeup(WlEventQueue queue, int wakeupFd)
{
// This initiates the race of sorts for who will have to use the wayland socket
if (Queue.PrepareRead() == -1)
{
// Other thread has won the race and read from the display, we can safely dispatch pending events
queue.DispatchPending();
return DispatchResult.Dispatched;
}
// We won the race and are now responsible to drive libwayland's flush/poll/read cycle
// First flush any pending data to the compositor
if (!FlushDisplay(out var flushError))
{
// If somebody wants to read errors they should call Display.Dispatch()
Display.CancelRead();
if (ClassifyErrorOrThrow(flushError, out var dispatchResult))
return dispatchResult;
throw new AvaloniaWaylandFlushException(flushError);
}
Span<pollfd> fds = stackalloc pollfd[]
{
new pollfd() { fd = _fd, events = PollEvents.POLLIN },
new pollfd() { fd = wakeupFd, events = PollEvents.POLLIN },
};
DoPoll(fds);
if (fds[0].revents == default)
{
// ppoll got woken up by the wakeup fd
Display.CancelRead();
return DispatchResult.Wakeup;
}
// Actual read call
if(Display.ReadEvents() == -1)
{
var errno = (Errno)Marshal.GetLastPInvokeError();
if (ClassifyErrorOrThrow(errno, out var dispatchResult))
return dispatchResult;
throw new AvaloniaWaylandReadException(errno);
}
Queue.DispatchPending();
return DispatchResult.Dispatched;
}
}

29
src/Avalonia.Wayland/Server/Interop/WaylandEglNativeMethods.cs

@ -0,0 +1,29 @@
using System;
using System.Runtime.InteropServices;
namespace Avalonia.Wayland.Server.Interop;
internal static class WaylandEglNativeMethods
{
private const string LibWaylandEgl = "libwayland-egl.so.1";
/// <summary>
/// Allocates a <c>wl_egl_window</c> wrapper around <paramref name="surface"/>
/// (a <c>wl_surface*</c>) at the given pixel size. The returned handle is
/// passed to <c>eglCreateWindowSurface</c> as the native window argument and
/// is owned by the caller.
/// </summary>
[DllImport(LibWaylandEgl)]
public static extern IntPtr wl_egl_window_create(IntPtr surface, int width, int height);
[DllImport(LibWaylandEgl)]
public static extern void wl_egl_window_destroy(IntPtr eglWindow);
/// <summary>
/// Resizes the <c>wl_egl_window</c>. The driver picks the new size up on the
/// next <c>eglSwapBuffers</c>; <paramref name="dx"/>/<paramref name="dy"/>
/// are reserved for sub-surface positioning and are 0 for our toplevel use.
/// </summary>
[DllImport(LibWaylandEgl)]
public static extern void wl_egl_window_resize(IntPtr eglWindow, int width, int height, int dx, int dy);
}

110
src/Avalonia.Wayland/Server/Interop/XkbCommonNativeMethods.cs

@ -0,0 +1,110 @@
using System;
using System.Runtime.InteropServices;
namespace Avalonia.Wayland.Server.Interop;
static unsafe class XkbCommonNativeMethods
{
private const string LibXkbCommon = "libxkbcommon.so.0";
// xkb_context
[DllImport(LibXkbCommon)]
public static extern IntPtr xkb_context_new(int flags);
[DllImport(LibXkbCommon)]
public static extern void xkb_context_unref(IntPtr context);
// xkb_keymap
[DllImport(LibXkbCommon)]
public static extern IntPtr xkb_keymap_new_from_buffer(
IntPtr context, byte* buffer, IntPtr length, int format, int flags);
[DllImport(LibXkbCommon)]
public static extern void xkb_keymap_unref(IntPtr keymap);
[DllImport(LibXkbCommon)]
public static extern uint xkb_keymap_num_layouts(IntPtr keymap);
[DllImport(LibXkbCommon)]
public static extern int xkb_keymap_key_get_syms_by_level(
IntPtr keymap, uint key, uint layout, uint level, out uint* symsOut);
// xkb_state
[DllImport(LibXkbCommon)]
public static extern IntPtr xkb_state_new(IntPtr keymap);
[DllImport(LibXkbCommon)]
public static extern void xkb_state_unref(IntPtr state);
[DllImport(LibXkbCommon)]
public static extern uint xkb_state_key_get_one_sym(IntPtr state, uint key);
[DllImport(LibXkbCommon)]
public static extern int xkb_state_key_get_utf8(
IntPtr state, uint key, byte* buffer, IntPtr size);
[DllImport(LibXkbCommon)]
public static extern int xkb_state_update_mask(
IntPtr state,
uint modsDepressed, uint modsLatched, uint modsLocked,
uint layoutDepressed, uint layoutLatched, uint layoutLocked);
// Constants
public const int XKB_CONTEXT_NO_FLAGS = 0;
public const int XKB_KEYMAP_FORMAT_TEXT_V1 = 1;
public const int XKB_KEYMAP_COMPILE_NO_FLAGS = 0;
// mmap constants
public const int PROT_READ = 1;
public const int MAP_PRIVATE = 2;
// xkb_compose
[DllImport(LibXkbCommon)]
public static extern IntPtr xkb_compose_table_new_from_locale(
IntPtr context,
[MarshalAs(UnmanagedType.LPUTF8Str)] string locale,
int flags);
[DllImport(LibXkbCommon)]
public static extern void xkb_compose_table_unref(IntPtr table);
[DllImport(LibXkbCommon)]
public static extern IntPtr xkb_compose_state_new(IntPtr table, int flags);
[DllImport(LibXkbCommon)]
public static extern void xkb_compose_state_unref(IntPtr state);
[DllImport(LibXkbCommon)]
public static extern int xkb_compose_state_feed(IntPtr state, uint keysym);
[DllImport(LibXkbCommon)]
public static extern int xkb_compose_state_get_status(IntPtr state);
[DllImport(LibXkbCommon)]
public static extern uint xkb_compose_state_get_one_sym(IntPtr state);
[DllImport(LibXkbCommon)]
public static extern int xkb_compose_state_get_utf8(
IntPtr state, byte* buffer, IntPtr size);
[DllImport(LibXkbCommon)]
public static extern void xkb_compose_state_reset(IntPtr state);
// Compose constants
public const int XKB_COMPOSE_COMPILE_NO_FLAGS = 0;
public const int XKB_COMPOSE_STATE_NO_FLAGS = 0;
// xkb_compose_status
public const int XKB_COMPOSE_NOTHING = 0;
public const int XKB_COMPOSE_COMPOSING = 1;
public const int XKB_COMPOSE_COMPOSED = 2;
public const int XKB_COMPOSE_CANCELLED = 3;
// xkb_compose_feed_result
public const int XKB_COMPOSE_FEED_IGNORED = 0;
public const int XKB_COMPOSE_FEED_ACCEPTED = 1;
}

15
src/Avalonia.Wayland/Server/Persistent/DecorationMode.cs

@ -0,0 +1,15 @@
namespace Avalonia.Wayland.Server.Persistent;
/// <summary>
/// Effective xdg-decoration-v1 mode for a toplevel. Mirrors
/// <c>zxdg_toplevel_decoration_v1.mode</c> but is decoupled from the
/// NWayland type so the cross-thread sink interface doesn't pull in a
/// protocol-bindings reference on the UI side.
/// </summary>
enum DecorationMode
{
/// <summary>Client draws its own chrome.</summary>
ClientSide ,
/// <summary>Compositor draws the chrome.</summary>
ServerSide
}

10
src/Avalonia.Wayland/Server/Persistent/IPersistentObject.cs

@ -0,0 +1,10 @@
using Avalonia.Wayland.Server.Interop;
using Avalonia.Wayland.Server.Transient;
namespace Avalonia.Wayland.Server.Persistent;
interface IPersistentWaylandObject
{
void OnConnected(WaylandConnection connection, WaylandGlobals globals);
void OnDisconnected();
}

61
src/Avalonia.Wayland/Server/Persistent/IWSurface.cs

@ -0,0 +1,61 @@
using Avalonia.Input;
using Avalonia.Input.TextInput;
using Avalonia.SourceGenerator;
using Avalonia.Wayland.Server.Transient;
namespace Avalonia.Wayland.Server.Persistent;
/// <summary>
/// UI→worker proxy interface for the cross-thread surface API common to all WSurfaces.
/// </summary>
[GenerateCrossThreadProxy(
typeof(WaylandDispatchPriority),
"Avalonia.Wayland.Server.WaylandDispatchPriority.Normal",
GeneratedClassName = "WSurfaceProxy")]
internal interface IWSurface
{
/// <summary>Tears down all wayland-side resources and unregisters this surface.</summary>
void Disconnect();
/// <summary>
/// Sets the cursor and refreshes the pointer cursor if this surface is focused.
/// <c>null</c> falls back to the default arrow cursor.
/// </summary>
void SetCursor(IWaylandCursor? cursor);
/// <summary>
/// Registers the UI-thread sink to receive worker→UI text-input v3 events
/// (preedit/commit/delete-surrounding). Idempotent. Does nothing on
/// compositors that lack zwp_text_input_manager_v3.
/// </summary>
void RegisterTextInputSink(Avalonia.Wayland.Server.Transient.WaylandTextInputV3EventsProxy sink);
/// <summary>
/// Toggles the text-input client. Sets the compose-key gate
/// (XKB-compose runs only when a client is attached) and, when v3 is
/// available, drives enable/disable on all entered seats. The
/// <paramref name="sessionToken"/> stamps every subsequent worker→UI
/// IME callback so the UI broker can drop callbacks emitted for a
/// previous client.
/// </summary>
void SetTextInputActive(bool hasClient, bool supportsPreedit, bool supportsSurroundingText, int sessionToken);
/// <summary>Aborts any in-flight IME composition (disable+commit on entered seats).</summary>
void AbortTextInputComposition();
/// <summary>Updates the IME cursor rectangle (logical, surface-local coordinates).</summary>
void SetTextInputCursorRect(Rect rect);
/// <summary>Updates the IME content-type (hint+purpose).</summary>
void SetTextInputOptions(TextInputOptions options);
/// <summary>
/// Updates surrounding text + selection. <paramref name="cursorChar"/> /
/// <paramref name="anchorChar"/> are UTF-16 code-unit offsets into the
/// supplied string (.NET-native indexing).
/// </summary>
void SetTextInputSurroundingText(string text, int cursorChar, int anchorChar);
/// <summary>Resets the IME state (clears any pending preedit/commit).</summary>
void ResetTextInput();
}

105
src/Avalonia.Wayland/Server/Persistent/IWSurfaceEventSink.cs

@ -0,0 +1,105 @@
using Avalonia.Input;
using Avalonia.Input.Raw;
using Avalonia.SourceGenerator;
using Avalonia.Threading;
using Avalonia.Wayland.Server;
using Avalonia.Wayland.Server.Transient.Clipboard;
namespace Avalonia.Wayland.Server.Persistent;
/// <summary>
/// Worker → UI sink for surface-role-agnostic events (input, scale changes,
/// keyboard focus loss, drag-and-drop). Both top-levels and popups receive
/// these. Surface-role-specific events (xdg_toplevel configure/close,
/// xdg_popup configure/done) live on the derived sink interfaces.
/// </summary>
[GenerateCrossThreadProxy(
typeof(DispatcherPriority),
"default",
GeneratedClassName = "WSurfaceEventSinkProxy")]
internal interface IWSurfaceEventSink
{
void OnPointerEnter(ulong timestamp, uint serial, Point position);
void OnPointerLeave(uint serial);
void OnPointerMotion(ulong timestamp, Point position, RawInputModifiers modifiers);
void OnPointerButton(ulong timestamp, uint serial, RawPointerEventType type, RawInputModifiers modifiers, Point position, object? platformCookie);
void OnPointerAxis(ulong timestamp, Vector delta, RawInputModifiers modifiers, Point position);
void OnTouchDown(ulong timestamp, int touchId, Point position, object? platformCookie);
void OnTouchMove(ulong timestamp, int touchId, Point position);
void OnTouchUp(ulong timestamp, int touchId, Point position);
void OnTouchCancel(int touchId, Point position);
void OnKeyDown(ulong timestamp, Key key, RawInputModifiers modifiers, PhysicalKey physicalKey, string? keySymbol);
void OnKeyUp(ulong timestamp, Key key, RawInputModifiers modifiers, PhysicalKey physicalKey, string? keySymbol);
void OnKeyboardLeave();
void OnKeyRepeatInfo(int rate, int delay);
void OnDragEnter(Point position, string[] mimeTypes, WaylandOfferCookie offerCookie, DragDropEffects sourceEffects, RawInputModifiers modifiers);
void OnDragMotion(Point position, RawInputModifiers modifiers);
void OnDragLeave();
void OnDrop(Point position, RawInputModifiers modifiers);
void OnScaleChanged(double scale);
/// <summary>
/// Pushed from the worker whenever this surface's set of
/// <c>wl_output</c> enters / leaves changes (also on disconnect).
/// The list contains the opaque identity objects used as
/// <see cref="Avalonia.Wayland.Screens.WaylandOutputSnapshot.Id"/>;
/// ordering is insertion (last-entered last), which the UI side uses
/// to pick "the" screen for the window.
/// </summary>
void OnSurfaceOutputsChanged(System.Collections.Generic.IReadOnlyList<object> outputIds);
}
/// <summary>
/// Worker → UI sink for xdg_toplevel-specific events. Implemented on the UI
/// thread by <see cref="WindowImpl.Sink"/>; the worker holds a generated
/// <see cref="WXdgTopLevelEventSinkProxy"/> that marshals every call to the
/// UI dispatcher.
/// </summary>
[GenerateCrossThreadProxy(
typeof(DispatcherPriority),
"default",
GeneratedClassName = "WXdgTopLevelEventSinkProxy")]
internal interface IWXdgTopLevelEventSink : IWSurfaceEventSink
{
void OnConfigure(XdgConfigureBatch batch);
void OnClose();
/// <summary>
/// The compositor changed the effective decoration mode for this
/// toplevel via <c>zxdg_toplevel_decoration_v1.configure</c>.
/// Delivered <em>after</em> the initial-handshake mode (which the
/// UI already sees via <see cref="XdgConfigureBatch.InitialDecorationMode"/>),
/// for every subsequent <c>decoration.configure</c> event. The
/// client is required by the protocol to obey the new mode.
/// </summary>
void OnDecorationModeChanged(DecorationMode mode);
}
/// <summary>
/// Worker → UI sink for xdg_popup-specific events. Implemented on the UI
/// thread by <c>PopupImpl.Sink</c>; the worker holds a generated
/// <c>WXdgPopupEventSinkProxy</c> that marshals every call to the UI
/// dispatcher.
/// </summary>
[GenerateCrossThreadProxy(
typeof(DispatcherPriority),
"default",
GeneratedClassName = "WXdgPopupEventSinkProxy")]
internal interface IWXdgPopupEventSink : IWSurfaceEventSink
{
/// <summary>
/// Compositor delivered a new popup configure (x, y, width, height),
/// sealed by the wrapping xdg_surface.configure(serial).
/// </summary>
void OnPopupConfigure(XdgPopupConfigureBatch batch);
/// <summary>
/// Compositor dismissed the popup (xdg_popup.popup_done). The popup
/// must be torn down; after this no further events arrive.
/// </summary>
void OnPopupDone();
}

90
src/Avalonia.Wayland/Server/Persistent/IWXdgTopLevel.cs

@ -0,0 +1,90 @@
using Avalonia.SourceGenerator;
using NWayland.Protocols.XdgShell;
namespace Avalonia.Wayland.Server.Persistent;
/// <summary>
/// UI→worker proxy interface for an xdg_surface (the part used from the UI thread).
/// </summary>
[GenerateCrossThreadProxy(
typeof(WaylandDispatchPriority),
"Avalonia.Wayland.Server.WaylandDispatchPriority.Normal",
GeneratedClassName = "WXdgShellSurfaceProxy")]
internal interface IWXdgShellSurface : IWSurface
{
void SetShadowExtents(Thickness extents);
void SetPendingAckSerial(uint serial);
}
/// <summary>
/// UI→worker proxy interface for an xdg_toplevel.
/// </summary>
[GenerateCrossThreadProxy(
typeof(WaylandDispatchPriority),
"Avalonia.Wayland.Server.WaylandDispatchPriority.Normal",
GeneratedClassName = "WXdgTopLevelProxy")]
internal interface IWXdgTopLevel : IWXdgShellSurface
{
void SetMaximized();
void UnsetMaximized();
void SetFullscreen();
void UnsetFullscreen();
void SetMinimized();
void SetParent(IWXdgTopLevel? parent);
void Move(object? platformCookie);
void Resize(object? platformCookie, XdgToplevel.ResizeEdgeEnum edge);
/// <summary>
/// Sets the toplevel's preferred minimum and maximum size.
/// A <c>null</c> in either argument means "no constraint" for that
/// bound (the worker forwards 0 to the compositor, which is the
/// protocol-defined "unconstrained" value). Values are clamped
/// to ≥1.
/// </summary>
void SetMinMaxSize(Size? minSize, Size? maxSize);
/// <summary>
/// Sets the toplevel's title. The compositor may display this in
/// window decorations, task bars, etc. A <c>null</c> or empty value
/// clears the title. The worker caches the value so it survives
/// compositor reconnects.
/// </summary>
void SetTitle(string? title);
/// <summary>
/// Tear down the worker's <c>zxdg_toplevel_decoration_v1</c> object
/// (if any). Switches the compositor back to "client-side
/// decorations on next commit" per the v1 spec. Also latches the
/// worker into sticky-CSD mode: on reconnect, no new decoration
/// object will be created for this toplevel — matches Qt's v1
/// strategy where SSD cannot be re-enabled on a mapped toplevel
/// (v1 forbids creating a decoration object after the toplevel has
/// committed a buffer). Idempotent.
/// </summary>
void DestroyDecoration();
/// <summary>
/// Synchronously creates an xdg-foreign-v2 export of this toplevel; the returned
/// façade's HandleTask completes asynchronously when the compositor delivers the
/// handle event. Returns null if the exporter global isn't bound or the toplevel
/// isn't currently mapped. Generated proxy auto-wraps the result into Task&lt;...&gt;.
/// </summary>
IWaylandXdgTopLevelExport? ExportToplevel();
}
/// <summary>
/// UI→worker proxy interface for an xdg_popup. The current
/// <see cref="XdgPopupPositionerParams"/> are configured before the surface
/// connects (or via re-positioner reconfigure later); the worker rebuilds
/// the protocol-level <c>xdg_positioner</c> from these on every connect.
/// </summary>
[GenerateCrossThreadProxy(
typeof(WaylandDispatchPriority),
"Avalonia.Wayland.Server.WaylandDispatchPriority.Normal",
GeneratedClassName = "WXdgPopupProxy")]
internal interface IWXdgPopup : IWXdgShellSurface
{
/// <summary>
/// Updates the cached positioner parameters used to (re-)create the
/// xdg_popup on connect. Called from the UI thread before the popup is
/// shown for the first time, and when reposition is invoked.
/// </summary>
void UpdatePositioner(XdgPopupPositionerParams positioner);
}

19
src/Avalonia.Wayland/Server/Persistent/IWaylandCursor.cs

@ -0,0 +1,19 @@
using Avalonia.SourceGenerator;
namespace Avalonia.Wayland.Server.Persistent;
/// <summary>
/// UI→worker proxy interface for a pointer cursor. The UI thread only ever holds the generated
/// <c>WaylandCursorProxy</c> wrapper and never the worker-side <see cref="WaylandCursor"/>.
/// Passing the wrapper to <see cref="IWSurface.SetCursor(IWaylandCursor)"/> auto-unwraps it to the
/// real worker object on the worker thread.
/// </summary>
[GenerateCrossThreadProxy(
typeof(WaylandDispatchPriority),
"Avalonia.Wayland.Server.WaylandDispatchPriority.Normal",
GeneratedClassName = "WaylandCursorProxy")]
internal interface IWaylandCursor
{
/// <summary>Releases any worker-side resources held by this cursor.</summary>
void Destroy();
}

1033
src/Avalonia.Wayland/Server/Persistent/WSurface.cs

File diff suppressed because it is too large

96
src/Avalonia.Wayland/Server/Persistent/WaylandBitmapCursor.cs

@ -0,0 +1,96 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.InteropServices;
using Avalonia;
using Avalonia.Platform;
using Avalonia.Wayland.Server.Interop;
using Avalonia.Wayland.Server.Transient;
using Avalonia.Wayland.Server.Transient.Rendering;
using NWayland.Protocols.Wayland;
namespace Avalonia.Wayland.Server.Persistent;
/// <summary>
/// A custom bitmap cursor. A persistent worker-thread entity that owns a bare <c>wl_surface</c>
/// holding the cursor image. Unlike <see cref="WSurface"/> it has no xdg-shell role, no configure
/// handshake and no frame-callback throttling: the image is rendered once per connection and the
/// surface is only ever referenced by <c>wl_pointer.set_cursor</c>. Rendering reuses
/// <see cref="WaylandFramebuffer"/> via <see cref="IWaylandFramebufferSurface"/>.
/// </summary>
sealed class WaylandBitmapCursor : WaylandCursor, IPersistentWaylandObject, IWaylandFramebufferSurface
{
private readonly WaylandWorker _worker;
private readonly byte[] _pixels; // tightly packed Bgra8888 premultiplied, stride = Width * 4
private readonly PixelSize _size;
private readonly int _hotspotX;
private readonly int _hotspotY;
private WaylandConnection? _connection;
private WaylandGlobals? _globals;
private WlSurface? _wlSurface;
private readonly List<IDisposable> _activeRenderTargets = new();
public WaylandBitmapCursor(WaylandWorker worker, byte[] pixels, PixelSize size, int hotspotX, int hotspotY)
{
_worker = worker;
_pixels = pixels;
_size = size;
_hotspotX = hotspotX;
_hotspotY = hotspotY;
worker.PostOob(() => worker.RegisterPersistentObject(this));
}
public WaylandGlobals? Globals => _globals;
public WlSurface? WlSurface => _wlSurface;
public PlatformRenderTargetState State =>
_globals != null && _wlSurface != null ? PlatformRenderTargetState.Ready : default;
public void RegisterRenderTarget(IDisposable renderTarget) => _activeRenderTargets.Add(renderTarget);
public void UnregisterRenderTarget(IDisposable renderTarget) => _activeRenderTargets.Remove(renderTarget);
// No xdg role and no frame-callback throttling: nothing to stage before a buffer attach.
public void OnBeforeNewBufferAttached(IRenderTarget.RenderTargetSceneInfo sceneInfo)
{
}
// Cursor buffers are created outside the throttled render loop, so flush their fds eagerly.
public bool EnforceBufferCreationRoundtrip => true;
public override WaylandCursorImage? Resolve(WaylandGlobals globals)
=> _wlSurface is { } surface ? new WaylandCursorImage(surface, _hotspotX, _hotspotY) : null;
public void OnConnected(WaylandConnection connection, WaylandGlobals globals)
{
_connection = connection;
_globals = globals;
_wlSurface = globals.WlCompositor.CreateSurface(null);
Render();
}
private void Render()
{
if (_size.Width <= 0 || _size.Height <= 0)
return;
var framebuffer = new WaylandFramebuffer(this);
using var renderTarget = framebuffer.CreateFramebufferRenderTarget();
using var locked = renderTarget.Lock(new IRenderTarget.RenderTargetSceneInfo(_size, 1), out _);
// The locked framebuffer is Bgra8888 premultiplied with stride == Width * 4, matching the
// packed pixel data we captured on the UI thread. Disposing the lock attaches + commits.
Marshal.Copy(_pixels, 0, locked.Address, _pixels.Length);
}
public void OnDisconnected()
{
foreach (var rt in _activeRenderTargets.ToList())
rt.Dispose();
_wlSurface?.Destroy();
_wlSurface = null;
_globals = null;
_connection = null;
}
// Invoked on the worker thread via WaylandCursorProxy when the owning UI-thread cursor is disposed.
public override void Destroy() => _worker.UnregisterPersistentObject(this);
}

45
src/Avalonia.Wayland/Server/Persistent/WaylandCursor.cs

@ -0,0 +1,45 @@
using Avalonia.Input;
using Avalonia.SourceGenerator;
using Avalonia.Wayland.Server.Transient;
using NWayland.Protocols.Wayland;
namespace Avalonia.Wayland.Server.Persistent;
/// <summary>The surface + hotspot to hand to <c>wl_pointer.set_cursor</c>.</summary>
[DefinitelyNotARecord]
readonly partial struct WaylandCursorImage(WlSurface Surface, int HotspotX, int HotspotY);
/// <summary>
/// Worker-side representation of a pointer cursor. The UI thread only ever holds the generated
/// <c>WaylandCursorProxy</c> wrapper (see <see cref="IWaylandCursor"/>); resolving the actual
/// <c>wl_surface</c> happens here, on the worker thread.
/// </summary>
abstract class WaylandCursor : IWaylandCursor
{
/// <summary>
/// Resolves the surface + hotspot for <c>wl_pointer.set_cursor</c> on the worker thread, or
/// <c>null</c> to hide the pointer.
/// </summary>
public abstract WaylandCursorImage? Resolve(WaylandGlobals globals);
/// <inheritdoc/>
public abstract void Destroy();
}
/// <summary>
/// A standard themed cursor. Stateless apart from the requested type — it owns no wl_surface and
/// resolves the themed surface on demand from <see cref="WaylandCursorManager"/>, so it doesn't
/// need to be a persistent object.
/// </summary>
sealed class WaylandStandardCursor(StandardCursorType cursorType) : WaylandCursor
{
public override WaylandCursorImage? Resolve(WaylandGlobals globals)
=> globals.CursorManager.GetCursor(cursorType) is { } c
? new WaylandCursorImage(c.Surface, c.HotspotX, c.HotspotY)
: null;
// Nothing to release — the themed surfaces are owned by WaylandCursorManager.
public override void Destroy()
{
}
}

71
src/Avalonia.Wayland/Server/Persistent/WaylandInputEventCookie.cs

@ -0,0 +1,71 @@
using System;
using Avalonia.Wayland.Server.Transient;
using NWayland.Protocols.Wayland;
namespace Avalonia.Wayland.Server.Persistent;
/// <summary>
/// Platform-specific cookie passed through Avalonia's input pipeline.
/// Carries the Wayland seat and serial from the triggering input event,
/// consumed by BeginMoveDrag/BeginResizeDrag to issue xdg_toplevel.move/resize.
/// Single-use: TryConsume returns false on subsequent calls or if the display changed.
/// Both TryConsume and Invalidate are called on the wayland thread.
/// <para>
/// Also captures the <see cref="WaylandGlobals"/> and <see cref="WaylandWorker"/> from the
/// moment the input event was triggered, providing <see cref="PostOob"/> for reconnect-safe
/// access to transient state (e.g. initiating DnD from a pointer press).
/// </para>
/// </summary>
class WaylandInputEventCookie
{
private WlSeat? _seat;
private readonly uint _serial;
private readonly WlDisplay _display;
private readonly WaylandWorker _worker;
private readonly WaylandGlobals _globals;
public WaylandInputEventCookie(WlSeat seat, uint serial, WlDisplay display,
WaylandWorker worker, WaylandGlobals globals)
{
_seat = seat;
_serial = serial;
_display = display;
_worker = worker;
_globals = globals;
}
/// <summary>
/// Attempts to consume the cookie for a surface that uses the given display.
/// Returns false if already consumed or if the display doesn't match (reconnection).
/// Must be called on the wayland thread.
/// </summary>
public bool TryConsume(WlDisplay currentDisplay, out WlSeat seat, out uint serial)
{
var s = _seat;
_seat = null;
if (s != null && ReferenceEquals(_display, currentDisplay))
{
seat = s;
serial = _serial;
return true;
}
seat = default!;
serial = 0;
return false;
}
/// <summary>
/// Posts a callback to the wayland thread that receives the <see cref="WaylandGlobals"/>
/// captured at input-event time. If a compositor reconnect occurred since then
/// (i.e. the current globals differ from the captured ones), the callback is not invoked.
/// </summary>
public void PostOob(Action<WaylandGlobals> cb)
{
_worker.PostOob(() =>
{
if (ReferenceEquals(_worker.Globals, _globals))
cb(_globals);
});
}
}

64
src/Avalonia.Wayland/Server/Persistent/XdgConfigureBatch.cs

@ -0,0 +1,64 @@
using System;
using System.Runtime.InteropServices;
namespace Avalonia.Wayland.Server.Persistent;
[Flags]
enum XdgToplevelStates
{
None = 0,
Maximized = 1 << 0,
Fullscreen = 1 << 1,
Resizing = 1 << 2,
Activated = 1 << 3,
TiledLeft = 1 << 4,
TiledRight = 1 << 5,
TiledTop = 1 << 6,
TiledBottom = 1 << 7,
Suspended = 1 << 8,
}
class XdgConfigureBatch
{
public PixelSize? Bounds;
public PixelSize Size;
public XdgToplevelStates States;
public uint Serial;
/// <summary>
/// Maximum usable surface size, computed from Bounds or output geometry.
/// </summary>
public Size MaxSize;
/// <summary>
/// Effective decoration mode after the initial handshake.
/// <c>null</c> = no decoration object was created (compositor doesn't
/// support zxdg_decoration_manager_v1, or sticky-CSD is on, or the
/// <c>ForceDrawnDecorations</c> option suppressed binding) — UI side
/// must draw client-side chrome.
/// </summary>
public DecorationMode? InitialDecorationMode;
public static XdgToplevelStates ParseStates(ReadOnlySpan<byte> data)
{
var states = XdgToplevelStates.None;
var uint32s = MemoryMarshal.Cast<byte, uint>(data);
foreach (var value in uint32s)
{
states |= value switch
{
1 => XdgToplevelStates.Maximized,
2 => XdgToplevelStates.Fullscreen,
3 => XdgToplevelStates.Resizing,
4 => XdgToplevelStates.Activated,
5 => XdgToplevelStates.TiledLeft,
6 => XdgToplevelStates.TiledRight,
7 => XdgToplevelStates.TiledTop,
8 => XdgToplevelStates.TiledBottom,
9 => XdgToplevelStates.Suspended,
_ => XdgToplevelStates.None,
};
}
return states;
}
}

15
src/Avalonia.Wayland/Server/Persistent/XdgPopupConfigureBatch.cs

@ -0,0 +1,15 @@
namespace Avalonia.Wayland.Server.Persistent;
/// <summary>
/// Worker-side accumulator for a single xdg_popup configure batch, sealed by
/// the wrapping xdg_surface.configure(serial). Mirrors <see cref="XdgConfigureBatch"/>
/// but carries the popup-specific (x, y, width, height) payload.
/// </summary>
internal class XdgPopupConfigureBatch
{
public int X;
public int Y;
public int Width;
public int Height;
public uint Serial;
}

31
src/Avalonia.Wayland/Server/Persistent/XdgPopupPositionerParams.cs

@ -0,0 +1,31 @@
using Avalonia.SourceGenerator;
using NWayland.Protocols.XdgShell;
namespace Avalonia.Wayland.Server.Persistent;
/// <summary>
/// Flat, immutable description of an xdg_positioner request. Built UI-side
/// in the parent's <b>buffer-relative</b> logical coordinate system —
/// i.e. relative to the true top-left of the parent surface, including any
/// CSD shadow margins. The worker is responsible for translating into the
/// parent's xdg_surface window-geometry coordinate system (subtracting the
/// parent's shadow extents) and clamping the anchor rectangle to that
/// geometry, since the protocol requires that <c>"the anchor rectangle may
/// not extend outside the window geometry of the positioned child's parent
/// surface"</c>. Doing the translation worker-side keeps the UI oblivious
/// to the wayland buffer-vs-geometry distinction.
/// </summary>
/// <param name="Size">The size of the popup, in logical surface-local coordinates. Both width and height must be positive.</param>
/// <param name="AnchorRect">The anchor rectangle in the parent's <b>buffer-relative</b> logical coordinates (true top-left, includes shadow).</param>
/// <param name="Anchor">The edge/corner of the anchor rect that the popup is anchored to. Uses the NWayland-provided wire enum directly — Avalonia's bitfield <see cref="Avalonia.Controls.Primitives.PopupPositioning.PopupAnchor"/> must be translated explicitly UI-side.</param>
/// <param name="Gravity">The direction the popup expands from the anchor point. Uses the NWayland-provided wire enum directly — same translation note as <paramref name="Anchor"/>.</param>
/// <param name="ConstraintAdjustment">How the compositor may adjust the popup if it doesn't fit on screen. NWayland-provided <c>[Flags]</c> wire enum; Avalonia's <see cref="Avalonia.Controls.Primitives.PopupPositioning.PopupPositionerConstraintAdjustment"/> must be translated flag-by-flag UI-side, never reinterpret-cast.</param>
/// <param name="Offset">Additional offset applied to the resolved popup position (after anchor + gravity have been resolved). Already relative to the popup itself, not the parent — no shadow translation applies.</param>
[DefinitelyNotARecord]
internal readonly partial struct XdgPopupPositionerParams(
Size Size,
Rect AnchorRect,
XdgPositioner.AnchorEnum Anchor,
XdgPositioner.GravityEnum Gravity,
XdgPositioner.ConstraintAdjustmentEnum ConstraintAdjustment,
Point Offset);

31
src/Avalonia.Wayland/Server/ServerSignaler.cs

@ -0,0 +1,31 @@
using System;
namespace Avalonia.Wayland.Server;
class ServerSignaler(WaylandWorker worker, Action callback)
{
private object _lock = new object();
private bool _signaled;
public void Signal()
{
lock (_lock)
{
if (_signaled)
return;
_signaled = true;
worker.PostOob(Callback);
}
}
void Callback()
{
lock (_lock)
{
_signaled = false;
}
callback();
}
}

363
src/Avalonia.Wayland/Server/Transient/Clipboard/WaylandDataDevice.cs

@ -0,0 +1,363 @@
using System;
using System.Collections.Generic;
using Avalonia.Input;
using Avalonia.Input.Raw;
using Avalonia.SourceGenerator;
using Avalonia.Wayland.Server.Persistent;
using NWayland;
using NWayland.Protocols.Wayland;
namespace Avalonia.Wayland.Server.Transient.Clipboard;
/// <summary>
/// Per-seat <see cref="WlDataDevice"/> wrapper. Handles clipboard selection events and DnD events.
/// Lives on the Wayland thread.
/// </summary>
partial class WaylandDataDevice : IDisposable
{
private readonly WlDataDevice _device;
private readonly WaylandInputDispatcher _dispatcher;
private readonly WlDisplay _display;
private readonly WaylandWorker _worker;
private readonly Func<RawInputModifiers> _getKeyboardModifiers;
/// <summary>
/// The most recently announced data offer (not yet assigned to selection or DnD).
/// Set by the <c>data_offer</c> event, consumed by <c>selection</c> or <c>enter</c>.
/// </summary>
private WaylandDataOffer? _pendingOffer;
/// <summary>
/// Cookie for the current selection offer.
/// Encapsulates the offer and validates staleness on reads.
/// </summary>
private WaylandOfferCookie? _selectionCookie;
// DnD session state
private WaylandDataOffer? _dndOffer;
private WaylandOfferCookie? _dndCookie;
private uint _dndSerial;
private WSurfaceEventSinkProxy? _dndSink;
private Point _dndPosition;
// Pending drops: offer+cookie moved here on drop(), consumed by FinishDnd/CancelDnd from UI thread
[DefinitelyNotARecord]
private readonly partial struct PendingDrop(WaylandDataOffer Offer, WaylandOfferCookie Cookie);
private readonly List<PendingDrop> _pendingDropOperations = new();
/// <summary>
/// The current clipboard selection offer.
/// Valid until a new <c>selection</c> event replaces it or keyboard focus is lost.
/// </summary>
public WaylandDataOffer? SelectionOffer { get; private set; }
/// <summary>
/// The currently active source we set via <c>set_selection</c>.
/// Null if we don't own the clipboard.
/// </summary>
public WaylandDataSource? ActiveSource { get; set; }
/// <summary>
/// The serial from the most recent input event that can authorize <c>set_selection</c>
/// (keyboard enter/key, pointer button, touch down).
/// </summary>
public uint LastInputSerial { get; set; }
public WaylandDataDevice(WlDataDevice device, WaylandInputDispatcher dispatcher,
WlSeat wlSeat, WlDisplay display, WaylandWorker worker, Func<RawInputModifiers> getKeyboardModifiers)
{
_device = device;
_dispatcher = dispatcher;
_display = display;
_worker = worker;
_getKeyboardModifiers = getKeyboardModifiers;
}
/// <summary>
/// Sets the clipboard selection to the given source.
/// </summary>
public void SetSelection(WaylandDataSource? source)
{
ActiveSource?.Dispose();
ActiveSource = source;
_device.SetSelection(source?.WlSource!, LastInputSerial);
}
/// <summary>
/// Gets the cookie and MIME type list for the current selection offer.
/// Returns <c>(null, null)</c> if there is no valid selection.
/// Called from the Wayland thread on behalf of UI-thread queries.
/// </summary>
public (WaylandOfferCookie? Cookie, string[]? MimeTypes) GetSelectionInfo()
{
if (SelectionOffer == null || SelectionOffer.MimeTypes.Count == 0)
return (null, null);
var mimeTypes = new string[SelectionOffer.MimeTypes.Count];
for (var i = 0; i < SelectionOffer.MimeTypes.Count; i++)
mimeTypes[i] = SelectionOffer.MimeTypes[i];
return (_selectionCookie, mimeTypes);
}
internal void OnDataOffer(WlDataOffer wlOffer, WaylandDataOfferListener listener)
{
var offer = new WaylandDataOffer(wlOffer);
listener.SetWrapper(offer);
_pendingOffer = offer;
}
internal void OnSelection(WlDataOffer? wlOffer)
{
_selectionCookie?.Invalidate();
SelectionOffer?.Dispose();
_selectionCookie = null;
if (wlOffer != null && _pendingOffer != null)
{
SelectionOffer = _pendingOffer;
_selectionCookie = new WaylandOfferCookie(this, _pendingOffer, _worker);
_pendingOffer = null;
}
else
{
SelectionOffer = null;
_pendingOffer?.Dispose();
_pendingOffer = null;
}
}
internal void OnDndEnter(uint serial, WlSurface? surface, double x, double y)
{
var offer = _pendingOffer;
_pendingOffer = null;
if (offer == null)
return;
var shellSurface = WaylandInputDispatcher.FindSurfaceForWlSurface(surface);
if (shellSurface == null)
{
offer.Dispose();
return;
}
// Clean up any lingering DnD session
CleanupDndState();
_dndOffer = offer;
_dndCookie = new WaylandOfferCookie(this, offer, _worker);
_dndSerial = serial;
_dndSink = shellSurface.EventSink;
_dndPosition = new Point(x, y);
var sourceEffects = ActionsToEffects(offer.SourceActions);
var mimeTypes = new string[offer.MimeTypes.Count];
for (var i = 0; i < offer.MimeTypes.Count; i++)
mimeTypes[i] = offer.MimeTypes[i];
var modifiers = _getKeyboardModifiers();
_dndSink.OnDragEnter(_dndPosition, mimeTypes, _dndCookie!, sourceEffects, modifiers);
}
internal void OnDndMotion(double x, double y)
{
_dndPosition = new Point(x, y);
_dndSink?.OnDragMotion(_dndPosition, _getKeyboardModifiers());
}
internal void OnDndLeave()
{
// If active session exists (no preceding drop), notify and clean up.
// After drop(), _dndSink is already null so this is a no-op.
_dndSink?.OnDragLeave();
CleanupDndState();
}
internal void OnDndDrop()
{
// Move offer+cookie to pending list so they survive CleanupDndState.
// The UI thread will consume them via FinishDnd/CancelDnd.
if (_dndOffer != null && _dndCookie != null)
{
_pendingDropOperations.Add(new PendingDrop(_dndOffer, _dndCookie));
_dndOffer = null;
_dndCookie = null;
}
_dndSink?.OnDrop(_dndPosition, _getKeyboardModifiers());
// Reset the active session state; pending drops keep the device alive.
_dndSink = null;
_dndSerial = 0;
_dndPosition = default;
}
/// <summary>
/// Called from the UI thread (via PostOob) after processing a DragEnter or DragOver event.
/// Updates the offer's accepted action and MIME type based on the UI's response.
/// </summary>
internal void UpdateDndFeedback(DragDropEffects effects)
{
if (_dndOffer == null)
return;
var (actions, preferred) = EffectsToActionsWithPreferred(effects);
_dndOffer.SetActions(actions, preferred);
if (effects == DragDropEffects.None)
_dndOffer.Accept(_dndSerial, null);
else
{
foreach(var mime in _dndOffer.MimeTypes)
_dndOffer.Accept(_dndSerial, mime);
}
}
/// <summary>
/// Called from the UI thread (via PostOob) after a successful drop.
/// Signals to the source that the transfer is complete.
/// </summary>
internal void FinishDnd()
{
if (_pendingDropOperations.Count > 0)
{
var pending = _pendingDropOperations[0];
_pendingDropOperations.RemoveAt(0);
pending.Offer.Finish();
pending.Cookie.Invalidate();
pending.Offer.Dispose();
}
}
private void CleanupDndState()
{
_dndCookie?.Invalidate();
_dndCookie = null;
_dndOffer?.Dispose();
_dndOffer = null;
_dndSink = null;
_dndSerial = 0;
_dndPosition = default;
}
/// <summary>
/// Starts a drag-and-drop operation from the given origin surface using the
/// trigger event's serial. Called from the Wayland thread.
/// </summary>
internal bool StartDrag(WaylandDataSource source, object? platformCookie,
WlDataDeviceManager.DndActionEnum allowedActions)
{
if (platformCookie is not WaylandInputEventCookie cookie
|| !cookie.TryConsume(_display, out _, out var serial))
return false;
source.SetActions(allowedActions);
// Find the origin surface — the surface that has the implicit pointer grab
var originSurface = _dispatcher.FindOriginSurface();
if (originSurface == null)
return false;
_device.StartDrag(source.WlSource, originSurface, null!, serial);
return true;
}
internal static DragDropEffects ActionsToEffects(WlDataDeviceManager.DndActionEnum actions)
{
var effects = DragDropEffects.None;
if (actions.HasFlag(WlDataDeviceManager.DndActionEnum.Copy))
effects |= DragDropEffects.Copy;
if (actions.HasFlag(WlDataDeviceManager.DndActionEnum.Move))
effects |= DragDropEffects.Move;
return effects;
}
internal static WlDataDeviceManager.DndActionEnum EffectsToActions(DragDropEffects effects)
{
var actions = WlDataDeviceManager.DndActionEnum.None;
if (effects.HasFlag(DragDropEffects.Copy))
actions |= WlDataDeviceManager.DndActionEnum.Copy;
if (effects.HasFlag(DragDropEffects.Move))
actions |= WlDataDeviceManager.DndActionEnum.Move;
return actions;
}
private static (WlDataDeviceManager.DndActionEnum Actions, WlDataDeviceManager.DndActionEnum Preferred)
EffectsToActionsWithPreferred(DragDropEffects effects)
{
var actions = WlDataDeviceManager.DndActionEnum.None;
if (effects.HasFlag(DragDropEffects.Copy))
actions |= WlDataDeviceManager.DndActionEnum.Copy;
if (effects.HasFlag(DragDropEffects.Move))
actions |= WlDataDeviceManager.DndActionEnum.Move;
// Prefer copy if both are available
var preferred = actions.HasFlag(WlDataDeviceManager.DndActionEnum.Copy)
? WlDataDeviceManager.DndActionEnum.Copy
: actions;
return (actions, preferred);
}
public void Dispose()
{
_selectionCookie?.Invalidate();
_selectionCookie = null;
SelectionOffer?.Dispose();
SelectionOffer = null;
CleanupDndState();
foreach (var pending in _pendingDropOperations)
{
pending.Cookie.Invalidate();
pending.Offer.Dispose();
}
_pendingDropOperations.Clear();
_pendingOffer?.Dispose();
_pendingOffer = null;
ActiveSource?.Dispose();
ActiveSource = null;
_device.Release();
}
}
class WaylandDataDeviceListener : WlDataDevice.Listener
{
private WaylandDataDevice? _wrapper;
internal void SetWrapper(WaylandDataDevice wrapper) => _wrapper = wrapper;
protected override void DataOffer(WlDataDevice eventSender, NewId<WlDataOffer, WlDataOffer.Listener> id)
{
var listener = new WaylandDataOfferListener();
var wlOffer = id.GetAndConsume(listener);
_wrapper?.OnDataOffer(wlOffer, listener);
}
protected override void Enter(WlDataDevice eventSender, uint serial, WlSurface? surface, WlFixed x, WlFixed y, WlDataOffer? offer)
{
_wrapper?.OnDndEnter(serial, surface, (double)x, (double)y);
}
protected override void Leave(WlDataDevice eventSender)
{
_wrapper?.OnDndLeave();
}
protected override void Motion(WlDataDevice eventSender, uint time, WlFixed x, WlFixed y)
{
_wrapper?.OnDndMotion((double)x, (double)y);
}
protected override void Drop(WlDataDevice eventSender)
{
_wrapper?.OnDndDrop();
}
protected override void Selection(WlDataDevice eventSender, WlDataOffer? offer)
{
_wrapper?.OnSelection(offer);
}
}

131
src/Avalonia.Wayland/Server/Transient/Clipboard/WaylandDataOffer.cs

@ -0,0 +1,131 @@
using System;
using System.Collections.Generic;
using Avalonia.Input;
using NWayland.Protocols.Wayland;
namespace Avalonia.Wayland.Server.Transient.Clipboard;
/// <summary>
/// Wraps a <see cref="WlDataOffer"/> received from the compositor.
/// Collects offered MIME types as they arrive and provides access to the offer's data.
/// Lives on the Wayland thread.
/// </summary>
class WaylandDataOffer : IDisposable
{
private readonly WlDataOffer _offer;
private readonly List<string> _mimeTypes = new();
public IReadOnlyList<string> MimeTypes => _mimeTypes;
/// <summary>
/// Source-advertised DnD actions (set by <c>source_actions</c> event).
/// </summary>
public WlDataDeviceManager.DndActionEnum SourceActions { get; internal set; }
/// <summary>
/// Compositor-negotiated DnD action (set by <c>action</c> event).
/// </summary>
public WlDataDeviceManager.DndActionEnum NegotiatedAction { get; internal set; }
public WaylandDataOffer(WlDataOffer offer)
{
_offer = offer;
}
internal void AddMimeType(string mimeType)
{
_mimeTypes.Add(mimeType);
}
/// <summary>
/// Checks whether the offer contains the given MIME type.
/// </summary>
public bool HasMimeType(string mimeType)
{
foreach (var m in _mimeTypes)
{
if (m == mimeType)
return true;
}
return false;
}
/// <summary>
/// Calls <c>wl_data_offer.receive(mimeType, fd)</c> using a pipe.
/// Returns the read-end file descriptor. The caller must close it after reading.
/// Returns -1 on failure.
/// </summary>
public unsafe int Receive(string mimeType)
{
int* fds = stackalloc int[2];
if (Server.Interop.UnsafeNativeMethods.pipe2(fds,
Server.Interop.UnsafeNativeMethods.O_CLOEXEC) != 0)
return -1;
_offer.Receive(mimeType, fds[1]);
Server.Interop.UnsafeNativeMethods.close(fds[1]);
return fds[0];
}
/// <summary>
/// Sets accepted DnD actions on this offer.
/// </summary>
public void SetActions(WlDataDeviceManager.DndActionEnum dndActions,
WlDataDeviceManager.DndActionEnum preferredAction)
{
_offer.SetActions(dndActions, preferredAction);
}
/// <summary>
/// Accepts the given MIME type for DnD.
/// </summary>
public void Accept(uint serial, string? mimeType)
{
_offer.Accept(serial, mimeType!);
}
/// <summary>
/// Signals that the DnD operation is complete.
/// </summary>
public void Finish()
{
_offer.Finish();
}
public void Dispose()
{
_offer.Destroy();
}
}
/// <summary>
/// Listener for <see cref="WlDataOffer"/> events.
/// Collects MIME types and forwards DnD action changes.
/// </summary>
class WaylandDataOfferListener : WlDataOffer.Listener
{
private WaylandDataOffer? _wrapper;
/// <summary>
/// Sets the wrapper that MIME types should be added to.
/// Must be called immediately after the offer is created.
/// </summary>
internal void SetWrapper(WaylandDataOffer wrapper) => _wrapper = wrapper;
protected override void Offer(WlDataOffer eventSender, string mimeType)
{
_wrapper?.AddMimeType(mimeType);
}
protected override void SourceActions(WlDataOffer eventSender, WlDataDeviceManager.DndActionEnum sourceActions)
{
if (_wrapper != null)
_wrapper.SourceActions = sourceActions;
}
protected override void Action(WlDataOffer eventSender, WlDataDeviceManager.DndActionEnum dndAction)
{
if (_wrapper != null)
_wrapper.NegotiatedAction = dndAction;
}
}

113
src/Avalonia.Wayland/Server/Transient/Clipboard/WaylandDataSource.cs

@ -0,0 +1,113 @@
using System;
using NWayland;
using NWayland.Protocols.Wayland;
namespace Avalonia.Wayland.Server.Transient.Clipboard;
/// <summary>
/// Wraps a <see cref="WlDataSource"/> created by us (for clipboard set or DnD start).
/// Handles <c>send</c> (compositor requests data) and <c>cancelled</c> (we lost ownership) events.
/// Lives on the Wayland thread.
/// </summary>
class WaylandDataSource : IDisposable
{
private readonly WlDataSource _source;
private bool _destroyed;
/// <summary>
/// Called on the Wayland thread when the compositor requests data for a MIME type.
/// The callback receives (mimeType, fd) and must write data and close the fd.
/// </summary>
public Action<string, int>? OnSend { get; set; }
/// <summary>
/// Called on the Wayland thread when this source has been cancelled
/// (another client took clipboard ownership, or DnD was cancelled).
/// </summary>
public Action? OnCancelled { get; set; }
/// <summary>
/// Called when DnD drop has been performed by the user.
/// </summary>
public Action? OnDndDropPerformed { get; set; }
/// <summary>
/// Called when the DnD operation has been completed by the destination.
/// </summary>
public Action? OnDndFinished { get; set; }
/// <summary>
/// Called when the compositor negotiates a DnD action.
/// </summary>
public Action<WlDataDeviceManager.DndActionEnum>? OnAction { get; set; }
public WaylandDataSource(WlDataSource source)
{
_source = source;
}
/// <summary>
/// Advertises a MIME type that this source can provide.
/// Must be called before <c>set_selection</c> or <c>start_drag</c>.
/// </summary>
public void Offer(string mimeType)
{
_source.Offer(mimeType);
}
/// <summary>
/// Sets the DnD actions supported by this source.
/// </summary>
public void SetActions(WlDataDeviceManager.DndActionEnum actions)
{
_source.SetActions(actions);
}
public WlDataSource WlSource => _source;
public void Dispose()
{
if (!_destroyed)
{
_destroyed = true;
_source.Destroy();
}
}
}
class WaylandDataSourceListener : WlDataSource.Listener
{
private WaylandDataSource? _wrapper;
internal void SetWrapper(WaylandDataSource wrapper) => _wrapper = wrapper;
protected override void Send(WlDataSource eventSender, string mimeType, WaylandFd fd)
{
_wrapper?.OnSend?.Invoke(mimeType, fd.Consume());
}
protected override void Cancelled(WlDataSource eventSender)
{
_wrapper?.OnCancelled?.Invoke();
}
protected override void Target(WlDataSource eventSender, string? mimeType)
{
// DnD target notification — not needed for clipboard
}
protected override void DndDropPerformed(WlDataSource eventSender)
{
_wrapper?.OnDndDropPerformed?.Invoke();
}
protected override void DndFinished(WlDataSource eventSender)
{
_wrapper?.OnDndFinished?.Invoke();
}
protected override void Action(WlDataSource eventSender, WlDataDeviceManager.DndActionEnum dndAction)
{
_wrapper?.OnAction?.Invoke(dndAction);
}
}

86
src/Avalonia.Wayland/Server/Transient/Clipboard/WaylandOfferCookie.cs

@ -0,0 +1,86 @@
using System.Threading.Tasks;
using Avalonia.Input;
namespace Avalonia.Wayland.Server.Transient.Clipboard;
/// <summary>
/// Opaque token representing a specific <see cref="WaylandDataOffer"/> on the Wayland thread.
/// Passed to UI-thread code as an <c>object</c> reference.
/// <para>
/// For data reads the UI thread sends this cookie back to the Wayland thread
/// via <c>InvokeOobAsync</c>, and the cookie performs the receive internally.
/// For DnD sessions this cookie also carries feedback methods (<see cref="PostUpdateFeedback"/>,
/// <see cref="PostFinish"/> that route through <c>PostOob</c>.
/// </para>
/// All field access happens on the Wayland thread — the UI thread only holds a reference.
/// </summary>
class WaylandOfferCookie
{
private readonly WaylandDataDevice _device;
private readonly WaylandDataOffer _offer;
private readonly WaylandWorker? _worker;
private bool _invalidated;
internal WaylandOfferCookie(WaylandDataDevice device, WaylandDataOffer offer, WaylandWorker? worker)
{
_device = device;
_offer = offer;
_worker = worker;
}
/// <summary>
/// Called from the Wayland thread when this offer is superseded by a new selection
/// or when the device is disposed.
/// </summary>
internal void Invalidate() => _invalidated = true;
/// <summary>
/// Attempts to receive data for a MIME type from the underlying offer.
/// Returns the read-end pipe fd, or -1 if the offer has been invalidated.
/// Must be called on the Wayland thread.
/// </summary>
internal int TryReceive(string mimeType)
{
if (_invalidated)
return -1;
return _offer.Receive(mimeType);
}
/// <summary>
/// Asynchronously receives data for a MIME type by posting to the Wayland thread.
/// Returns the read-end pipe fd, or -1 if the offer has been invalidated.
/// Called from the UI thread.
/// </summary>
internal Task<int> TryReceiveAsync(string mimeType)
{
if (_worker is null)
return Task.FromResult(-1);
return _worker.InvokeOobAsync(() => TryReceive(mimeType));
}
/// <summary>
/// Posts a DnD feedback update to the Wayland thread.
/// Called from UI-thread code after processing a DragEnter or DragOver event.
/// </summary>
internal void PostUpdateFeedback(DragDropEffects effects)
{
_worker?.PostOob(() =>
{
if (!_invalidated)
_device.UpdateDndFeedback(effects);
});
}
/// <summary>
/// Posts a DnD finish signal to the Wayland thread.
/// Called from UI-thread code after a successful drop.
/// </summary>
internal void PostFinish()
{
_worker?.PostOob(() =>
{
if (!_invalidated)
_device.FinishDnd();
});
}
}

50
src/Avalonia.Wayland/Server/Transient/IWaylandTextInputV3Events.cs

@ -0,0 +1,50 @@
using Avalonia.SourceGenerator;
using Avalonia.Threading;
using Avalonia.Wayland.Server;
namespace Avalonia.Wayland.Server.Transient;
/// <summary>
/// Worker → UI callback interface for <c>zwp_text_input_v3</c>.
/// All indices are UTF-16 char positions (i.e. .NET <see cref="string"/> indices),
/// not UTF-8 bytes — the worker performs the conversion before invoking the sink.
/// </summary>
/// <remarks>
/// The worker invokes this method on the worker thread; the generated
/// <c>WaylandTextInputV3EventsProxy</c> marshals each call to the UI thread.
/// A single call corresponds to one wayland <c>done</c> batch — the four
/// operations (clear-preedit, delete-surrounding, commit, new-preedit) are
/// applied atomically by the UI broker so no other UI work can interleave
/// between them.
/// </remarks>
[GenerateCrossThreadProxy(
typeof(DispatcherPriority),
"default",
GeneratedClassName = "WaylandTextInputV3EventsProxy")]
internal interface IWaylandTextInputV3Events
{
/// <summary>
/// Apply one IME update batch. Operations are applied in protocol
/// order: clear current preedit, delete surrounding, insert commit,
/// install new preedit.
/// </summary>
/// <param name="sessionToken">
/// The session token last received via <c>IWSurface.SetTextInputActive</c>;
/// the UI broker drops batches whose token doesn't match its current
/// client epoch.
/// </param>
/// <param name="deleteBeforeChars">UTF-16 chars to delete before cursor (0 = no delete).</param>
/// <param name="deleteAfterChars">UTF-16 chars to delete after cursor (0 = no delete).</param>
/// <param name="commitText">Text to commit; null/empty = no commit.</param>
/// <param name="preeditText">New preedit text; null/empty = clear preedit.</param>
/// <param name="preeditCursorBeginChar">Preedit cursor start (UTF-16 chars; -1 = hidden).</param>
/// <param name="preeditCursorEndChar">Preedit cursor end (UTF-16 chars; -1 = hidden).</param>
void OnImeUpdate(
int sessionToken,
int deleteBeforeChars,
int deleteAfterChars,
string? commitText,
string? preeditText,
int preeditCursorBeginChar,
int preeditCursorEndChar);
}

44
src/Avalonia.Wayland/Server/Transient/Rendering/IWaylandFramebufferSurface.cs

@ -0,0 +1,44 @@
using System;
using Avalonia.Platform;
using NWayland.Protocols.Wayland;
namespace Avalonia.Wayland.Server.Transient.Rendering;
/// <summary>
/// The minimal surface contract <see cref="WaylandFramebuffer"/> needs to attach shm buffers.
/// Implemented by <see cref="Persistent.WSurface"/> (full toplevel/popup lifecycle) and by
/// lightweight entities (e.g. cursor surfaces) that own a bare <c>wl_surface</c> without the
/// xdg-shell configure handshake or frame-callback throttling.
/// </summary>
interface IWaylandFramebufferSurface
{
/// <summary>The bound globals for the current connection, or <c>null</c> when disconnected.</summary>
WaylandGlobals? Globals { get; }
/// <summary>The target <c>wl_surface</c>, or <c>null</c> when disconnected.</summary>
WlSurface? WlSurface { get; }
/// <summary>Current readiness of the surface for buffer attachment.</summary>
PlatformRenderTargetState State { get; }
/// <summary>Tracks an active render target so it can be torn down on disconnect.</summary>
void RegisterRenderTarget(IDisposable renderTarget);
/// <summary>Stops tracking a render target.</summary>
void UnregisterRenderTarget(IDisposable renderTarget);
/// <summary>
/// Invoked right before a new buffer is attached and committed. Implementations stage any
/// per-commit double-buffered state here (frame callback, ack_configure, scale, geometry…).
/// Lightweight surfaces can leave this a no-op.
/// </summary>
void OnBeforeNewBufferAttached(IRenderTarget.RenderTargetSceneInfo sceneInfo);
/// <summary>
/// When <c>true</c>, <see cref="WaylandFramebuffer"/> forces a <c>wl_display</c> roundtrip after
/// creating each <c>wl_buffer</c>. libwayland-client only allows a bounded number of file
/// descriptors per <c>wl_display_flush</c> (~28, undocumented), so surfaces that create buffers
/// outside the throttled render loop (e.g. cursors) must flush eagerly to stay within budget.
/// </summary>
bool EnforceBufferCreationRoundtrip { get; }
}

131
src/Avalonia.Wayland/Server/Transient/Rendering/WaylandDmabufFeedback.cs

@ -0,0 +1,131 @@
using System;
using System.Collections.Generic;
using System.Runtime.InteropServices;
using Avalonia.SourceGenerator;
using NWayland;
using NWayland.Protocols.LinuxDmabufV1;
using static Avalonia.Wayland.Server.Interop.UnsafeNativeMethods;
namespace Avalonia.Wayland.Server.Transient.Rendering;
[DefinitelyNotARecord]
partial struct DmabufFormatModifierPair(uint Format, ulong Modifier);
class DmabufTranche
{
public ulong TargetDevice { get; set; }
public List<DmabufFormatModifierPair> Formats { get; } = new();
public bool Scanout { get; set; }
}
class WaylandDmabufFeedback
{
const int PROT_READ = 1;
const int MAP_PRIVATE = 2;
const int FormatTableEntrySize = 16;
readonly List<DmabufFormatModifierPair> _formatTable = new();
ulong _pendingTargetDevice;
List<DmabufFormatModifierPair> _pendingFormats = new();
bool _pendingScanout;
public ulong MainDevice { get; private set; }
public List<DmabufTranche> Tranches { get; } = new();
public bool IsComplete { get; private set; }
public FeedbackListener Listener { get; }
public WaylandDmabufFeedback()
{
Listener = new FeedbackListener(this);
}
void ResetPendingTranche()
{
_pendingTargetDevice = 0;
_pendingFormats = new List<DmabufFormatModifierPair>();
_pendingScanout = false;
}
internal unsafe class FeedbackListener(WaylandDmabufFeedback feedback) : ZwpLinuxDmabufFeedbackV1.Listener
{
protected override void FormatTable(ZwpLinuxDmabufFeedbackV1 eventSender, WaylandFd fd, uint size)
{
var rawFd = fd.Consume();
var length = (IntPtr)size;
var map = mmap(IntPtr.Zero, length, PROT_READ, MAP_PRIVATE, rawFd, IntPtr.Zero);
if (map == (IntPtr)(-1))
{
close(rawFd);
return;
}
try
{
// The table contains a tightly packed array of consecutive format + modifier
// pairs. Each pair is 16 bytes wide. It contains a format as a 32-bit unsigned
// integer, followed by 4 bytes of unused padding, and a modifier as a 64-bit
// unsigned integer. The native endianness is used.
var count = (int)(size / FormatTableEntrySize);
var ptr = (byte*)map;
feedback._formatTable.Clear();
for (var i = 0; i < count; i++)
{
var entryPtr = ptr + i * FormatTableEntrySize;
var format = *(uint*)entryPtr;
var modifier = *(ulong*)(entryPtr + 8);
feedback._formatTable.Add(new DmabufFormatModifierPair(format, modifier));
}
}
finally
{
munmap(map, length);
close(rawFd);
}
}
protected override void MainDevice(ZwpLinuxDmabufFeedbackV1 eventSender, ReadOnlySpan<byte> device)
{
feedback.MainDevice = MemoryMarshal.Read<ulong>(device);
}
protected override void TrancheTargetDevice(ZwpLinuxDmabufFeedbackV1 eventSender, ReadOnlySpan<byte> device)
{
feedback._pendingTargetDevice = MemoryMarshal.Read<ulong>(device);
}
protected override void TrancheFormats(ZwpLinuxDmabufFeedbackV1 eventSender, ReadOnlySpan<byte> indices)
{
var count = indices.Length / 2;
var span = MemoryMarshal.Cast<byte, ushort>(indices);
for (var i = 0; i < count; i++)
{
var index = span[i];
if (index < feedback._formatTable.Count)
feedback._pendingFormats.Add(feedback._formatTable[index]);
}
}
protected override void TrancheFlags(ZwpLinuxDmabufFeedbackV1 eventSender,
ZwpLinuxDmabufFeedbackV1.TrancheFlagsEnum flags)
{
feedback._pendingScanout = flags.HasFlag(ZwpLinuxDmabufFeedbackV1.TrancheFlagsEnum.Scanout);
}
protected override void TrancheDone(ZwpLinuxDmabufFeedbackV1 eventSender)
{
var tranche = new DmabufTranche
{
TargetDevice = feedback._pendingTargetDevice,
Scanout = feedback._pendingScanout
};
tranche.Formats.AddRange(feedback._pendingFormats);
feedback.Tranches.Add(tranche);
feedback.ResetPendingTranche();
}
protected override void Done(ZwpLinuxDmabufFeedbackV1 eventSender)
{
feedback.IsComplete = true;
}
}
}

26
src/Avalonia.Wayland/Server/Transient/Rendering/WaylandEglDisplay.cs

@ -0,0 +1,26 @@
using System;
using System.Collections.Generic;
using Avalonia.OpenGL.Egl;
using NWayland.Protocols.LinuxDmabufV1;
namespace Avalonia.Wayland.Server.Transient.Rendering;
internal class WaylandEglDisplay : EglDisplay
{
public IntPtr GbmDevice { get; }
public int DrmFd { get; }
public List<DmabufFormatModifierPair> SupportedFormats { get; }
public ZwpLinuxDmabufV1 LinuxDmabuf { get; }
public WaylandEglDisplay(
EglDisplayCreationOptions options,
IntPtr gbmDevice,
int drmFd,
ZwpLinuxDmabufV1 linuxDmabuf) : base(options)
{
GbmDevice = gbmDevice;
DrmFd = drmFd;
SupportedFormats = new List<DmabufFormatModifierPair>();
LinuxDmabuf = linuxDmabuf;
}
}

231
src/Avalonia.Wayland/Server/Transient/Rendering/WaylandEglDmaBufPlatformGraphics.cs

@ -0,0 +1,231 @@
using System;
using System.Collections.Generic;
using System.Runtime.InteropServices;
using Avalonia.Logging;
using Avalonia.OpenGL;
using Avalonia.OpenGL.Egl;
using Avalonia.Platform;
using Avalonia.Platform.Surfaces;
using Avalonia.Wayland.Server.Interop;
using Avalonia.Wayland.Server.Persistent;
using static Avalonia.OpenGL.Egl.EglConsts;
using static Avalonia.Wayland.Server.Interop.DrmGbmUnsafeNativeMethods;
namespace Avalonia.Wayland.Server.Transient.Rendering;
internal sealed unsafe class WaylandEglDmaBufPlatformGraphics : WaylandPlatformGraphics.IWaylandGraphics
{
public WaylandEglDisplay Display { get; }
public IPlatformGraphicsContext CreateContext() => Display.CreateContext(null);
public IPlatformRenderSurface CreateRenderSurface(WSurface surface) => new WaylandEglDmaBufSurface(surface);
private WaylandEglDmaBufPlatformGraphics(WaylandEglDisplay display)
{
Display = display;
}
internal static WaylandEglDmaBufPlatformGraphics? TryCreate(WaylandConnection connection, WaylandGlobals globals,
IList<GlVersion> glProfiles)
{
try
{
return Create(connection, globals, glProfiles);
}
catch (Exception e)
{
Logger.TryGet(LogEventLevel.Error, "OpenGL")?.Log(null,
"Unable to initialize Wayland EGL rendering: {0}", e);
return null;
}
}
// libEGL.so.1 only exports EGL 1.5 core entry points. Extension entry
// points like eglGetPlatformDisplayEXT / eglQueryDmaBufFormatsEXT are
// NOT exported as symbols and can only be resolved via eglGetProcAddress.
[DllImport("libEGL.so.1", CharSet = CharSet.Ansi)]
private static extern IntPtr eglGetProcAddress(string proc);
private static WaylandEglDmaBufPlatformGraphics? Create(WaylandConnection connection, WaylandGlobals globals,
IList<GlVersion> glProfiles)
{
var dmabuf = globals.LinuxDmabuf;
if (dmabuf == null)
{
Logger.TryGet(LogEventLevel.Warning, "OpenGL")?.Log(null,
"zwp_linux_dmabuf_v1 not available");
return null;
}
// Request default feedback from the compositor
var feedback = new WaylandDmabufFeedback();
dmabuf.GetDefaultFeedback(feedback.Listener);
connection.Queue.Roundtrip();
if (!feedback.IsComplete)
{
Logger.TryGet(LogEventLevel.Warning, "OpenGL")?.Log(null,
"Dmabuf feedback not received from compositor");
return null;
}
// Find the DRM render node matching the compositor's main device
var renderNodePath = FindRenderNode(feedback.MainDevice);
if (renderNodePath == null)
{
Logger.TryGet(LogEventLevel.Warning, "OpenGL")?.Log(null,
"No DRM render node found for device {0}", feedback.MainDevice);
return null;
}
// Open the DRM render node and create GBM device
var drmFd = open(renderNodePath, O_RDWR);
if (drmFd < 0)
throw new InvalidOperationException($"Failed to open DRM render node: {renderNodePath}");
var gbmDevice = IntPtr.Zero;
WaylandEglDisplay? display = null;
try
{
gbmDevice = gbm_create_device(drmFd);
if (gbmDevice == IntPtr.Zero)
throw new InvalidOperationException("Failed to create GBM device");
// Capture for the dispose callback — closures capture variables, not values
var capturedDrmFd = drmFd;
var capturedGbm = gbmDevice;
var options = new EglDisplayCreationOptions
{
Egl = new EglInterface(eglGetProcAddress),
PlatformType = EGL_PLATFORM_GBM_KHR,
PlatformDisplay = gbmDevice,
SupportsMultipleContexts = true,
SupportsContextSharing = true,
DisposeCallback = () =>
{
gbm_device_destroy(capturedGbm);
close(capturedDrmFd);
},
GlVersions = glProfiles
};
display = new WaylandEglDisplay(options, gbmDevice, drmFd, dmabuf);
var extensions = display.EglInterface.QueryString(display.Handle, EGL_EXTENSIONS)?.Split(' ') ?? [];
void CheckExtension(string ext)
{
if (!extensions.Contains(ext))
throw new OpenGlException($"Required EGL extension {ext} is not supported");
}
CheckExtension("EGL_KHR_image_base");
CheckExtension("EGL_EXT_image_dma_buf_import");
var supportedFormats = NegotiateFormats(display, feedback);
if (supportedFormats.Count == 0)
supportedFormats.Add(new DmabufFormatModifierPair(DRM_FORMAT_ARGB8888, DRM_FORMAT_MOD_INVALID));
display.SupportedFormats.AddRange(supportedFormats);
return new WaylandEglDmaBufPlatformGraphics(display);
}
catch
{
if (display != null)
{
// EglDisplay.Dispose triggers DisposeCallback which handles GBM + DRM fd
display.Dispose();
}
else
{
if (gbmDevice != IntPtr.Zero)
gbm_device_destroy(gbmDevice);
close(drmFd);
}
throw;
}
}
private static List<DmabufFormatModifierPair> NegotiateFormats(
EglDisplay display, WaylandDmabufFeedback feedback)
{
var egl = display.EglInterface;
var negotiated = new List<DmabufFormatModifierPair>();
// Collect all format/modifier pairs from feedback tranches
var feedbackFormats = new HashSet<(uint format, ulong modifier)>();
foreach (var tranche in feedback.Tranches)
foreach (var fmt in tranche.Formats)
feedbackFormats.Add((fmt.Format, fmt.Modifier));
if (feedbackFormats.Count == 0)
return negotiated;
var uniqueFormats = new HashSet<uint>();
foreach (var (format, _) in feedbackFormats)
uniqueFormats.Add(format);
foreach (var format in uniqueFormats)
{
if (!egl.IsQueryDmaBufModifiersEXTAvailable)
{
// Modifier query unavailable — only accept implicit modifier
foreach (var (fmt, mod) in feedbackFormats)
if (fmt == format && mod == DRM_FORMAT_MOD_INVALID)
negotiated.Add(new DmabufFormatModifierPair(fmt, mod));
continue;
}
// Query how many modifiers EGL supports for this format
if (!egl.QueryDmaBufModifiersEXT(display.Handle, (int)format, 0, null, null, out var count)
|| count == 0)
continue;
// Retrieve the actual modifier list
var eglModifiers = new ulong[count];
fixed (ulong* ptr = eglModifiers)
{
if (!egl.QueryDmaBufModifiersEXT(display.Handle, (int)format, count, ptr, null, out _))
continue;
}
var eglModSet = new HashSet<ulong>(eglModifiers);
// Keep formats whose modifier is supported by EGL or is the implicit modifier
foreach (var (fmt, mod) in feedbackFormats)
{
if (fmt != format)
continue;
if (eglModSet.Contains(mod) || mod == DRM_FORMAT_MOD_INVALID)
negotiated.Add(new DmabufFormatModifierPair(fmt, mod));
}
}
return negotiated;
}
private static unsafe string? FindRenderNode(ulong mainDevice)
{
if (drmGetDeviceFromDevId(mainDevice, 0, out var device) != 0 || device == null)
return null;
try
{
if ((device->AvailableNodes & (1 << DRM_NODE_RENDER)) == 0)
return null;
return Marshal.PtrToStringAnsi(device->Nodes[DRM_NODE_RENDER]);
}
finally
{
drmFreeDevice(&device);
}
}
public void Dispose()
{
Display.Dispose();
}
}

371
src/Avalonia.Wayland/Server/Transient/Rendering/WaylandEglDmaBufSurface.cs

@ -0,0 +1,371 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Avalonia.OpenGL;
using Avalonia.OpenGL.Egl;
using Avalonia.OpenGL.Surfaces;
using Avalonia.Platform;
using Avalonia.Platform.Surfaces;
using Avalonia.Wayland.Server.Interop;
using Avalonia.Wayland.Server.Persistent;
using NWayland;
using NWayland.Protocols.LinuxDmabufV1;
using NWayland.Protocols.Wayland;
using static Avalonia.OpenGL.Egl.EglConsts;
using static Avalonia.Wayland.Server.Interop.DrmGbmUnsafeNativeMethods;
namespace Avalonia.Wayland.Server.Transient.Rendering;
internal class WaylandEglDmaBufSurface : EglGlPlatformImageSurfaceBase, IPlatformRenderSurface
{
private readonly WSurface _surface;
public WaylandEglDmaBufSurface(WSurface surface)
{
_surface = surface;
}
public bool IsReady => _surface.State.IsReady;
public override IGlPlatformSurfaceRenderTarget CreateGlRenderTarget(IGlContext context)
=> new RenderTarget(this, (EglContext)context);
private class RenderTarget : EglPlatformImageSurfaceRenderTargetBase
{
private readonly WaylandEglDmaBufSurface _parent;
private readonly WaylandEglDisplay _waylandDisplay;
// TODO: Reduce to 2 or 3 if explicit sync is supported (once we implement it)
private const int SwapchainSize = 4;
private BufferSlot[]? _slots;
private List<BufferSlot>? _pendingDestroy;
private PixelSize _currentSize;
private ulong _frameCounter;
public RenderTarget(WaylandEglDmaBufSurface parent, EglContext context) : base(context)
{
_parent = parent;
_waylandDisplay = (WaylandEglDisplay)context.Display;
parent._surface.RegisterRenderTarget(this);
}
public override IGlPlatformSurfaceRenderingSession BeginDrawCore(
IRenderTarget.RenderTargetSceneInfo sceneInfo)
{
if (!_parent._surface.State.IsReady)
throw new RenderTargetNotReadyException();
EnsureSwapchain(sceneInfo.Size);
// Select least recently used free slot
BufferSlot? freeSlot = null;
foreach (var slot in _slots!)
{
if (!slot.Busy && (freeSlot == null || slot.LastUsedFrame < freeSlot.LastUsedFrame))
freeSlot = slot;
}
if (freeSlot == null)
throw new InvalidOperationException("No free buffer slots available in swapchain");
var capturedSlot = freeSlot;
capturedSlot.LastUsedFrame = ++_frameCounter;
return BeginDraw(capturedSlot.EglImage!.Handle, sceneInfo.Size, sceneInfo.Scaling, () =>
{
// Stage per-frame state (frame callback + ack_configure +
// geometry + viewport/scale + min/max) into the next
// commit BEFORE binding the buffer, then commit explicitly
// after attach + damage. The base WSurface no longer
// issues the commit itself.
_parent._surface.OnBeforeNewBufferAttached(sceneInfo);
_parent._surface.WlSurface!.Attach(capturedSlot.WlBuffer!, 0, 0);
_parent._surface.WlSurface.DamageBuffer(0, 0, sceneInfo.Size.Width, sceneInfo.Size.Height);
capturedSlot.Busy = true;
_parent._surface.WlSurface.Commit();
});
}
public override PlatformRenderTargetState State =>
IsCorrupted ? PlatformRenderTargetState.Corrupted
: _disposed ? PlatformRenderTargetState.Disposed
: _parent._surface.State;
private bool _disposed;
public override void Dispose()
{
if (_disposed) return;
_disposed = true;
_parent._surface.UnregisterRenderTarget(this);
DestroySwapchain();
if (_pendingDestroy != null)
{
foreach (var slot in _pendingDestroy)
slot.Dispose();
_pendingDestroy = null;
}
base.Dispose();
}
private unsafe void EnsureSwapchain(PixelSize size)
{
if (_slots != null && _currentSize == size)
return;
DestroySwapchain();
_slots = new BufferSlot[SwapchainSize];
_currentSize = size;
var format = DRM_FORMAT_ARGB8888;
// Collect modifiers for the chosen format
var modifiers = _waylandDisplay.SupportedFormats
.Where(f => f.Format == format)
.Select(f => f.Modifier)
.ToArray();
for (var i = 0; i < SwapchainSize; i++)
{
var slot = new BufferSlot { Format = format };
try
{
// Allocate GBM buffer object
IntPtr bo = IntPtr.Zero;
if (modifiers.Length > 0)
{
fixed (ulong* modsPtr = modifiers)
{
bo = gbm_bo_create_with_modifiers2(
_waylandDisplay.GbmDevice, (uint)size.Width, (uint)size.Height,
format, modsPtr, (uint)modifiers.Length, GbmBoFlags.GBM_BO_USE_RENDERING);
}
}
if (bo == IntPtr.Zero)
{
bo = gbm_bo_create(
_waylandDisplay.GbmDevice, (uint)size.Width, (uint)size.Height,
format, GbmBoFlags.GBM_BO_USE_RENDERING);
}
if (bo == IntPtr.Zero)
throw new InvalidOperationException("Failed to create GBM buffer object");
slot.GbmBo = bo;
// Export DMA-BUF planes
var planeCount = gbm_bo_get_plane_count(bo);
slot.PlaneCount = planeCount;
slot.Modifier = gbm_bo_get_modifier(bo);
slot.DmaBufFds = new int[planeCount];
slot.Strides = new uint[planeCount];
slot.Offsets = new uint[planeCount];
for (var p = 0; p < planeCount; p++)
{
var handle = gbm_bo_get_handle_for_plane(bo, p);
if (drmPrimeHandleToFD(_waylandDisplay.DrmFd, handle.U32, 0, out slot.DmaBufFds[p]) != 0)
throw new InvalidOperationException($"drmPrimeHandleToFD failed for plane {p}");
slot.Strides[p] = gbm_bo_get_stride_for_plane(bo, p);
slot.Offsets[p] = gbm_bo_get_offset(bo, p);
}
// Create EGLImage from DMA-BUF
var attribs = new List<int>
{
EGL_WIDTH, (int)gbm_bo_get_width(bo),
EGL_HEIGHT, (int)gbm_bo_get_height(bo),
EGL_LINUX_DRM_FOURCC_EXT, (int)gbm_bo_get_format(bo),
};
for (var p = 0; p < planeCount; p++)
AddPlaneAttribs(attribs, p, slot.DmaBufFds[p], slot.Offsets[p],
slot.Strides[p], slot.Modifier);
attribs.Add(EGL_NONE);
var imageHandle = _waylandDisplay.EglInterface.CreateImageKHR(
_waylandDisplay.Handle, EGL_NO_CONTEXT,
EGL_LINUX_DMA_BUF_EXT, IntPtr.Zero, attribs.ToArray());
if (imageHandle == IntPtr.Zero)
throw new InvalidOperationException("eglCreateImageKHR failed");
slot.EglImage = new EglImage(_waylandDisplay, imageHandle);
// Create temp queue for roundtrip
using var tempQueue = _waylandDisplay.LinuxDmabuf.Display.CreateEventQueue();
// Create wl_buffer via zwp_linux_buffer_params_v1
var result = new DmaBufCreateParamsListener(new BufferReleaseListener(this, slot));
using var args = _waylandDisplay.LinuxDmabuf.CreateParams(result, tempQueue);
for (var p = 0; p < planeCount; p++)
{
args.Add(slot.DmaBufFds[p], (uint)p, slot.Offsets[p], slot.Strides[p],
(uint)(slot.Modifier >> 32), (uint)(slot.Modifier & 0xFFFFFFFF));
}
args.Create(size.Width, size.Height, format, default);
tempQueue.Roundtrip();
if (result.Buffer == null)
throw new OpenGlException("Unable to create DMA-BUF on Wayland server side");
slot.WlBuffer = result.Buffer;
// Put buffer back on our main queue
result.Buffer.SetQueue(_waylandDisplay.LinuxDmabuf.Queue);
_slots[i] = slot;
}
catch
{
slot.Dispose();
throw;
}
}
}
class DmaBufCreateParamsListener(BufferReleaseListener bufferReleaseListener) : ZwpLinuxBufferParamsV1.Listener
{
public WlBuffer? Buffer { get; private set; }
protected override void Created(ZwpLinuxBufferParamsV1 eventSender, NewId<WlBuffer, WlBuffer.Listener> buffer)
{
Buffer = buffer.GetAndConsume(bufferReleaseListener);
}
}
private static void AddPlaneAttribs(List<int> attribs, int plane, int fd,
uint offset, uint stride, ulong modifier)
{
int baseFd, baseOffset, basePitch, baseModLo, baseModHi;
switch (plane)
{
case 0:
baseFd = EGL_DMA_BUF_PLANE0_FD_EXT;
baseOffset = EGL_DMA_BUF_PLANE0_OFFSET_EXT;
basePitch = EGL_DMA_BUF_PLANE0_PITCH_EXT;
baseModLo = EGL_DMA_BUF_PLANE0_MODIFIER_LO_EXT;
baseModHi = EGL_DMA_BUF_PLANE0_MODIFIER_HI_EXT;
break;
case 1:
baseFd = EGL_DMA_BUF_PLANE1_FD_EXT;
baseOffset = EGL_DMA_BUF_PLANE1_OFFSET_EXT;
basePitch = EGL_DMA_BUF_PLANE1_PITCH_EXT;
baseModLo = EGL_DMA_BUF_PLANE1_MODIFIER_LO_EXT;
baseModHi = EGL_DMA_BUF_PLANE1_MODIFIER_HI_EXT;
break;
case 2:
baseFd = EGL_DMA_BUF_PLANE2_FD_EXT;
baseOffset = EGL_DMA_BUF_PLANE2_OFFSET_EXT;
basePitch = EGL_DMA_BUF_PLANE2_PITCH_EXT;
baseModLo = EGL_DMA_BUF_PLANE2_MODIFIER_LO_EXT;
baseModHi = EGL_DMA_BUF_PLANE2_MODIFIER_HI_EXT;
break;
case 3:
baseFd = EGL_DMA_BUF_PLANE3_FD_EXT;
baseOffset = EGL_DMA_BUF_PLANE3_OFFSET_EXT;
basePitch = EGL_DMA_BUF_PLANE3_PITCH_EXT;
baseModLo = EGL_DMA_BUF_PLANE3_MODIFIER_LO_EXT;
baseModHi = EGL_DMA_BUF_PLANE3_MODIFIER_HI_EXT;
break;
default:
throw new ArgumentOutOfRangeException(nameof(plane));
}
attribs.Add(baseFd);
attribs.Add(fd);
attribs.Add(baseOffset);
attribs.Add((int)offset);
attribs.Add(basePitch);
attribs.Add((int)stride);
if (modifier != DRM_FORMAT_MOD_INVALID)
{
attribs.Add(baseModLo);
attribs.Add((int)(modifier & 0xFFFFFFFF));
attribs.Add(baseModHi);
attribs.Add((int)(modifier >> 32));
}
}
private void DestroySwapchain()
{
if (_slots == null)
return;
foreach (var slot in _slots)
{
if (slot == null)
continue;
if (slot.Busy)
{
// Defer destruction of busy buffers until compositor releases them
_pendingDestroy ??= new List<BufferSlot>();
_pendingDestroy.Add(slot);
}
else
{
slot.Dispose();
}
}
_slots = null;
_currentSize = default;
}
private void OnBufferReleased(BufferSlot slot)
{
slot.Busy = false;
if (_pendingDestroy != null && _pendingDestroy.Remove(slot))
slot.Dispose();
}
private class BufferSlot : IDisposable
{
public IntPtr GbmBo;
public int[]? DmaBufFds;
public uint[]? Strides;
public uint[]? Offsets;
public int PlaneCount;
public ulong Modifier;
public uint Format;
public EglImage? EglImage;
public WlBuffer? WlBuffer;
public bool Busy;
public ulong LastUsedFrame;
public void Dispose()
{
EglImage?.Dispose();
EglImage = null;
if (WlBuffer != null)
{
WlBuffer.Destroy();
WlBuffer.Dispose();
WlBuffer = null;
}
if (DmaBufFds != null)
{
foreach (var fd in DmaBufFds)
{
if (fd >= 0)
close(fd);
}
DmaBufFds = null;
}
if (GbmBo != IntPtr.Zero)
{
gbm_bo_destroy(GbmBo);
GbmBo = IntPtr.Zero;
}
}
}
private class BufferReleaseListener(RenderTarget renderTarget, BufferSlot slot) : WlBuffer.Listener
{
protected override void Release(WlBuffer eventSender)
{
renderTarget.OnBufferReleased(slot);
}
}
}
}

73
src/Avalonia.Wayland/Server/Transient/Rendering/WaylandEglWsiPlatformGraphics.cs

@ -0,0 +1,73 @@
using System;
using System.Collections.Generic;
using System.Runtime.InteropServices;
using Avalonia.Logging;
using Avalonia.OpenGL;
using Avalonia.OpenGL.Egl;
using Avalonia.Platform;
using Avalonia.Platform.Surfaces;
using Avalonia.Wayland.Server.Interop;
using Avalonia.Wayland.Server.Persistent;
namespace Avalonia.Wayland.Server.Transient.Rendering;
/// <summary>
/// EGL platform-graphics backend that uses the standard Wayland WSI path:
/// <c>EGL_PLATFORM_WAYLAND_KHR</c> over <c>wl_display*</c>, with per-surface
/// <c>wl_egl_window</c> backing buffers. The driver owns buffer allocation,
/// modifier negotiation, presentation and the implicit <c>wl_surface.commit</c>
/// performed inside <c>eglSwapBuffers</c>.
///
/// This is the default backend; the dmabuf-based
/// <see cref="WaylandEglDmaBufPlatformGraphics"/> path is opt-in via
/// <c>WaylandPlatformOptions.UseDmabufSwapchain</c>.
/// </summary>
internal sealed class WaylandEglWsiPlatformGraphics : WaylandPlatformGraphics.IWaylandGraphics
{
public EglDisplay Display { get; }
public IPlatformGraphicsContext CreateContext() => Display.CreateContext(null);
public IPlatformRenderSurface CreateRenderSurface(WSurface surface) => new WaylandEglWsiSurface(surface);
private WaylandEglWsiPlatformGraphics(EglDisplay display)
{
Display = display;
}
// libEGL.so.1 only exports EGL 1.5 core entry points. Extension entry
// points like eglGetPlatformDisplayEXT are NOT exported as symbols and
// can only be resolved via eglGetProcAddress; without this, EglInterface
// would fail to bind it and EGL_PLATFORM_WAYLAND_KHR display creation
// wouldn't work.
[DllImport("libEGL.so.1", CharSet = CharSet.Ansi)]
private static extern IntPtr eglGetProcAddress(string proc);
private const int EGL_PLATFORM_WAYLAND_KHR = 0x31D8;
public static WaylandEglWsiPlatformGraphics? TryCreate(WaylandConnection connection, IList<GlVersion> glProfiles)
{
try
{
var options = new EglDisplayCreationOptions
{
Egl = new EglInterface(eglGetProcAddress),
PlatformType = EGL_PLATFORM_WAYLAND_KHR,
PlatformDisplay = connection.Display.Handle,
SupportsMultipleContexts = true,
SupportsContextSharing = true,
GlVersions = glProfiles
};
var display = new EglDisplay(options);
return new WaylandEglWsiPlatformGraphics(display);
}
catch (Exception e)
{
Logger.TryGet(LogEventLevel.Error, "OpenGL")?.Log(null,
"Unable to initialize Wayland WSI EGL rendering: {0}", e);
return null;
}
}
public void Dispose() => Display.Dispose();
}

135
src/Avalonia.Wayland/Server/Transient/Rendering/WaylandEglWsiSurface.cs

@ -0,0 +1,135 @@
using System;
using Avalonia.OpenGL;
using Avalonia.OpenGL.Egl;
using Avalonia.OpenGL.Surfaces;
using Avalonia.Platform;
using Avalonia.Platform.Surfaces;
using Avalonia.Wayland.Server.Interop;
using Avalonia.Wayland.Server.Persistent;
using static Avalonia.OpenGL.GlConsts;
namespace Avalonia.Wayland.Server.Transient.Rendering;
/// <summary>
/// WSI-based EGL render surface. Wraps the owning <see cref="WSurface"/>'s
/// <c>wl_surface</c> in a <c>wl_egl_window</c> and lets the EGL driver own
/// buffer allocation and presentation. <c>eglSwapBuffers</c> is the
/// implicit <c>wl_surface.commit</c> point — we never call commit ourselves
/// on this path.
/// </summary>
internal sealed class WaylandEglWsiSurface : EglGlPlatformSurfaceBase, IPlatformRenderSurface
{
private readonly WSurface _surface;
public WaylandEglWsiSurface(WSurface surface)
{
_surface = surface;
}
public bool IsReady => _surface.State.IsReady;
public override IGlPlatformSurfaceRenderTarget CreateGlRenderTarget(IGlContext context)
=> new RenderTarget(_surface, (EglContext)context);
private sealed class RenderTarget : EglPlatformSurfaceRenderTargetBase
{
private readonly WSurface _surface;
private IntPtr _eglWindow;
private EglSurface? _eglSurface;
private PixelSize _currentSize;
public RenderTarget(WSurface surface, EglContext context) : base(context)
{
_surface = surface;
}
protected override bool SkipWaits => true;
public override PlatformRenderTargetState State =>
IsCorrupted ? PlatformRenderTargetState.Corrupted : _surface.State;
public override IGlPlatformSurfaceRenderingSession BeginDrawCore(IRenderTarget.RenderTargetSceneInfo sceneInfo)
{
if (!_surface.State.IsReady)
throw new RenderTargetNotReadyException();
EnsureWindow(sceneInfo.Size);
// OnBeforeNewBufferAttached must run *immediately* before the
// implicit commit performed by eglSwapBuffers; the base class
// invokes beforeSwap right before the swap call.
//
// SwapInterval(0) is set inside beforeSwap (so it runs with our
// surface current); GTK re-asserts it every frame defensively
// and we follow suit — some drivers reset it on resize.
var session = BeginDraw(_eglSurface!, sceneInfo.Size, sceneInfo.Scaling,
beforeSwap: () =>
{
var display = Context.Display;
display.EglInterface.SwapInterval(display.Handle, 0);
_surface.OnBeforeNewBufferAttached(sceneInfo);
});
var err = session.Context.GlInterface.GetError();
// Workaround for driver quirk https://github.com/NVIDIA/egl-wayland2/issues/46
// This is NVIDIA-specific, but setting buffers to GL_BACK won't hurt for other drivers too
if (session.Context.Version.Type == GlProfileType.OpenGL)
{
var gl = session.Context.GlInterface;
gl.Viewport(0, 0, sceneInfo.Size.Width, sceneInfo.Size.Height);
if (gl.IsReadBufferAvailable)
gl.ReadBuffer(GL_BACK);
if (gl.IsWriteBufferAvailable)
gl.WriteBuffer(GL_BACK);
if(gl.IsDrawBufferAvailable)
gl.DrawBuffer(GL_BACK);
}
return session;
}
public override void Dispose()
{
_eglSurface?.Dispose();
_eglSurface = null;
if (_eglWindow != IntPtr.Zero)
{
WaylandEglNativeMethods.wl_egl_window_destroy(_eglWindow);
_eglWindow = IntPtr.Zero;
}
_currentSize = default;
base.Dispose();
}
private void EnsureWindow(PixelSize size)
{
if (_eglWindow != IntPtr.Zero && _currentSize == size)
return;
if (_eglWindow == IntPtr.Zero)
{
_eglWindow = WaylandEglNativeMethods.wl_egl_window_create(
_surface.WlSurface!.Handle, Math.Max(1, size.Width), Math.Max(1, size.Height));
if (_eglWindow == IntPtr.Zero)
throw new OpenGlException("wl_egl_window_create failed");
var display = Context.Display;
var raw = display.EglInterface.CreateWindowSurface(display.Handle, display.Config, _eglWindow, null);
if (raw == IntPtr.Zero)
{
WaylandEglNativeMethods.wl_egl_window_destroy(_eglWindow);
_eglWindow = IntPtr.Zero;
throw OpenGlException.GetFormattedException("eglCreateWindowSurface", display.EglInterface);
}
_eglSurface = new EglSurface(display, raw);
}
else
{
WaylandEglNativeMethods.wl_egl_window_resize(_eglWindow,
Math.Max(1, size.Width), Math.Max(1, size.Height), 0, 0);
}
_currentSize = size;
}
}
}

113
src/Avalonia.Wayland/Server/Transient/Rendering/WaylandFramebuffer.cs

@ -0,0 +1,113 @@
using System;
using System.Runtime.InteropServices;
using Avalonia.Platform;
using Avalonia.Platform.Surfaces;
using NWayland.Protocols.Wayland;
using static Avalonia.Wayland.Server.Interop.UnsafeNativeMethods;
namespace Avalonia.Wayland.Server.Transient.Rendering;
class WaylandFramebuffer(IWaylandFramebufferSurface surface) : IFramebufferPlatformSurface, IPlatformRenderSurface
{
private readonly IWaylandFramebufferSurface _surface = surface;
public IFramebufferRenderTarget CreateFramebufferRenderTarget() => new RenderTarget(this);
class RenderTarget : IFramebufferRenderTarget, IPlatformRenderSurface
{
private readonly WaylandFramebuffer _p;
private bool _disposed;
public RenderTarget(WaylandFramebuffer p)
{
_p = p;
p._surface.RegisterRenderTarget(this);
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_p._surface.UnregisterRenderTarget(this);
}
class BufferListener : WlBuffer.Listener
{
protected override void Release(WlBuffer eventSender)
{
// TODO: Pool buffers
eventSender.Destroy();
base.Release(eventSender);
}
}
public ILockedFramebuffer Lock(IRenderTarget.RenderTargetSceneInfo sceneInfo, out FramebufferLockProperties properties)
{
if (!State.IsReady)
throw new RenderTargetNotReadyException();
var size = sceneInfo.Size;
var bufferLen = sceneInfo.Size.Width * sceneInfo.Size.Height * 4;
var stride = size.Width * 4;
var fd = memfd_create("avalonia-wayland-framebuffer", MFD_CLOEXEC);
IntPtr map;
if (fd == -1
|| ftruncate(fd, bufferLen) != 0
|| new IntPtr(-1) == (map =
mmap(IntPtr.Zero, bufferLen, PROT_READ | PROT_WRITE, MAP_SHARED, fd, IntPtr.Zero)))
{
close(fd);
throw new OutOfMemoryException("Unable to allocate framebuffer");
}
properties = default;
return new LockedFramebuffer(map, size, stride,
new Vector(96 * sceneInfo.Scaling, 96 * sceneInfo.Scaling),
PixelFormats.Bgra8888, AlphaFormat.Premul, () =>
{
munmap(map, bufferLen);
try
{
if (!State.IsReady)
return;
using var pool = _p._surface.Globals!.WlShm.CreatePool(fd, bufferLen);
var listener = new BufferListener();
var buffer = pool.CreateBuffer(0, size.Width, size.Height, stride,
WlShm.FormatEnum.Argb8888, listener);
// Stage per-frame state (frame callback +
// ack_configure + geometry + viewport/scale +
// min/max) into the next commit BEFORE binding the
// buffer, then commit explicitly after attach +
// damage. The base WSurface no longer issues the
// commit itself.
_p._surface.OnBeforeNewBufferAttached(sceneInfo);
_p._surface.WlSurface!.Attach(buffer, 0, 0);
// TODO: Support "damage" regions
_p._surface.WlSurface.DamageBuffer(0, 0, size.Width, size.Height);
_p._surface.WlSurface.Commit();
// Surfaces that allocate buffers outside the throttled render loop must
// flush the buffer's fd immediately: libwayland-client caps the number of
// fds per wl_display_flush (~28, undocumented), and a roundtrip flushes.
if (_p._surface.EnforceBufferCreationRoundtrip)
_p._surface.Globals!.Connection.Queue.Roundtrip();
}
finally
{
close(fd);
}
});
}
public bool RetainsFrameContents => false;
public PlatformRenderTargetState State =>
_disposed ? PlatformRenderTargetState.Disposed : _p._surface.State;
}
public bool IsReady => _surface.State.IsReady;
}

120
src/Avalonia.Wayland/Server/Transient/WaylandCursorManager.cs

@ -0,0 +1,120 @@
using System;
using System.Collections.Generic;
using Avalonia.Input;
using Avalonia.SourceGenerator;
using Avalonia.Wayland.Server.Interop;
using NWayland.Interop;
using NWayland.Protocols.Wayland;
namespace Avalonia.Wayland.Server.Transient;
partial class WaylandCursorManager : IDisposable
{
private readonly IntPtr _theme;
private readonly Dictionary<StandardCursorType, CursorEntry?> _cursors = new();
private readonly WlDisplay _display;
private readonly WlCompositor _compositor;
[DefinitelyNotARecord]
internal readonly partial struct CursorEntry(WlSurface Surface, WlBuffer buffer, int HotspotX, int HotspotY);
private static readonly Dictionary<StandardCursorType, string[]> CursorNames = new()
{
{ StandardCursorType.Arrow, ["default", "left_ptr"] },
{ StandardCursorType.Ibeam, ["text", "xterm"] },
{ StandardCursorType.Wait, ["wait", "watch"] },
{ StandardCursorType.Cross, ["crosshair", "cross"] },
{ StandardCursorType.UpArrow, ["up_arrow", "sb_up_arrow"] },
{ StandardCursorType.SizeWestEast, ["ew-resize", "col-resize", "sb_h_double_arrow"] },
{ StandardCursorType.SizeNorthSouth, ["ns-resize", "row-resize", "sb_v_double_arrow"] },
{ StandardCursorType.SizeAll, ["all-scroll", "fleur"] },
{ StandardCursorType.No, ["not-allowed", "crossed_circle"] },
{ StandardCursorType.Hand, ["pointer", "hand2", "pointing_hand"] },
{ StandardCursorType.AppStarting, ["progress", "left_ptr_watch"] },
{ StandardCursorType.Help, ["help", "question_arrow"] },
{ StandardCursorType.TopSide, ["top_side", "n-resize"] },
{ StandardCursorType.BottomSide, ["bottom_side", "s-resize"] },
{ StandardCursorType.LeftSide, ["left_side", "w-resize"] },
{ StandardCursorType.RightSide, ["right_side", "e-resize"] },
{ StandardCursorType.TopLeftCorner, ["top_left_corner", "nw-resize"] },
{ StandardCursorType.TopRightCorner, ["top_right_corner", "ne-resize"] },
{ StandardCursorType.BottomLeftCorner, ["bottom_left_corner", "sw-resize"] },
{ StandardCursorType.BottomRightCorner, ["bottom_right_corner", "se-resize"] },
{ StandardCursorType.DragMove, ["grabbing", "dnd-move"] },
{ StandardCursorType.DragCopy, ["copy", "dnd-copy"] },
{ StandardCursorType.DragLink, ["alias", "dnd-link"] },
};
public WaylandCursorManager(WlDisplay display, WlShm shm, WlCompositor compositor)
{
_display = display;
_compositor = compositor;
_theme = UnsafeNativeMethods.wl_cursor_theme_load(null, 24, shm.Handle);
if (_theme == IntPtr.Zero)
throw new AvaloniaWaylandException("Failed to load default cursor theme");
foreach (var type in CursorNames.Keys)
LoadCursor(type);
}
private unsafe void LoadCursor(StandardCursorType type)
{
if (!CursorNames.TryGetValue(type, out var names))
return;
UnsafeNativeMethods.wl_cursor* cursor = null;
foreach (var name in names)
{
cursor = UnsafeNativeMethods.wl_cursor_theme_get_cursor(_theme, name);
if (cursor != null)
break;
}
if (cursor == null || cursor->image_count == 0)
{
_cursors[type] = null;
return;
}
var image = cursor->images[0];
var buffer = UnsafeNativeMethods.wl_cursor_image_get_buffer(image);
if (buffer == IntPtr.Zero)
{
_cursors[type] = null;
return;
}
var surface = _compositor.CreateSurface(null);
var wlBuffer = WlBuffer.Import(this._display, null, buffer, true, null);
surface.Attach(wlBuffer, 0, 0);
surface.Commit();
_cursors[type] = new CursorEntry(surface, wlBuffer, (int)image->hotspot_x, (int)image->hotspot_y);
}
/// <summary>
/// Gets the cursor surface and hotspot for the given cursor type.
/// Returns null for <see cref="StandardCursorType.None"/> or unknown cursors (hides cursor).
/// Falls back to Arrow if the specific cursor is not available.
/// </summary>
internal CursorEntry? GetCursor(StandardCursorType type)
{
if (type == StandardCursorType.None)
return null;
if (_cursors.TryGetValue(type, out var entry))
return entry ?? (type != StandardCursorType.Arrow ? GetCursor(StandardCursorType.Arrow) : null);
return GetCursor(StandardCursorType.Arrow);
}
public void Dispose()
{
foreach (var entry in _cursors.Values)
entry?.Surface.Dispose();
_cursors.Clear();
if (_theme != IntPtr.Zero)
UnsafeNativeMethods.wl_cursor_theme_destroy(_theme);
}
}

197
src/Avalonia.Wayland/Server/Transient/WaylandGlobals.cs

@ -0,0 +1,197 @@
using System;
using System.Collections.Generic;
using Avalonia.Platform;
using Avalonia.Wayland.Screens;
using Avalonia.Wayland.Server.Interop;
using Avalonia.Wayland.Server.Transient.Rendering;
using NWayland;
using NWayland.Interop;
using NWayland.Protocols.FractionalScaleV1;
using NWayland.Protocols.LinuxDmabufV1;
using NWayland.Protocols.TextInputUnstableV3;
using NWayland.Protocols.Viewporter;
using NWayland.Protocols.Wayland;
using NWayland.Protocols.XdgDecorationUnstableV1;
using NWayland.Protocols.XdgForeignUnstableV2;
using NWayland.Protocols.XdgOutputUnstableV1;
using NWayland.Protocols.XdgShell;
namespace Avalonia.Wayland.Server.Transient;
class WaylandGlobals
{
public WlRegistry Registry { get; }
private Dictionary<string, (uint name, uint version)> _knownGlobals = new();
public WaylandOutputsTracker Outputs { get; }
public event Action<uint>? GlobalRemoved;
public WlShm WlShm { get; }
public List<WlShm.FormatEnum> ShmFormats { get; } = new();
public WlCompositor WlCompositor { get; }
public XdgWmBase XdgWmBase { get; }
public WlDataDeviceManager? DataDeviceManager { get; }
public ZwpLinuxDmabufV1? LinuxDmabuf { get; }
public WpFractionalScaleManagerV1? FractionalScaleManager { get; }
public WpViewporter? Viewporter { get; }
public ZwpTextInputManagerV3? TextInputManagerV3 { get; }
public ZxdgExporterV2? XdgExporter { get; }
/// <summary>
/// Bound when the compositor advertises <c>zxdg_output_manager_v1</c>.
/// When present, screens use <c>zxdg_output_v1.logical_*</c> as the
/// authoritative logical bounds; otherwise we derive logical bounds
/// as <c>wl_output.mode / wl_output.scale</c>.
/// </summary>
public ZxdgOutputManagerV1? XdgOutputManager { get; }
/// <summary>
/// Bound only when the compositor advertises
/// <c>zxdg_decoration_manager_v1</c> AND the platform option
/// <see cref="WaylandPlatformOptions.ForceDrawnDecorations"/> is
/// false. <c>null</c> means we must use client-side decorations for
/// every toplevel (no SSD negotiation will be attempted).
/// </summary>
public ZxdgDecorationManagerV1? XdgDecorationManager { get; }
public bool HasFractionalScaling => FractionalScaleManager != null && Viewporter != null;
class RegistryListener(WaylandGlobals p) : NWayland.Protocols.Wayland.WlRegistry.Listener
{
protected override void Global(WlRegistry eventSender, uint name, string @interface, uint version)
{
p._knownGlobals[@interface] = (name, version);
if (@interface == WlOutput.ProxyType.Interface.Name)
{
p.Outputs.AddGlobal(p, name, version);
}
else if (@interface == WlSeat.ProxyType.Interface.Name)
{
p.OnSeatGlobal(name, version);
}
}
protected override void GlobalRemove(WlRegistry eventSender, uint name)
{
p.InputDispatcher.OnSeatRemoved(name);
p.GlobalRemoved?.Invoke(name);
}
}
private const uint SeatMinVersion = 5;
private void OnSeatGlobal(uint globalName, uint version)
{
var bindVersion = Math.Min(version, 9);
if (bindVersion < SeatMinVersion)
return;
InputDispatcher.OnSeatAdded(globalName, Registry, bindVersion);
}
T? Bind<T>(uint minVersion, uint maxVersion, IWlEventsListener? listener) where T : WlProxy, IWlProxyTypeDescriptorProvider
{
var descriptor = T.ProxyType;
if (descriptor.Interface.Version < maxVersion)
throw new AvaloniaWaylandException(
$"{descriptor.Interface.Name} v{maxVersion} is not supported by current bindings");
if (!_knownGlobals.TryGetValue(descriptor.Interface.Name, out var global))
return null;
if (global.version < minVersion)
return null;
return Registry.Bind<T>(global.name, Math.Min(global.version, maxVersion), listener);
}
T BindRequired<T>(uint minVersion, uint maxVersion,
IWlEventsListener? listener) where T : WlProxy, IWlProxyTypeDescriptorProvider
{
var rv = Bind<T>(minVersion, maxVersion, listener);
if (rv == null)
throw new AvaloniaWaylandException(
$"Required wayland global {T.ProxyType.Interface.Name} (>={minVersion}) not found");
return rv;
}
class ShmListener(WaylandGlobals globals) : WlShm.Listener
{
protected override void Format(WlShm eventSender, WlShm.FormatEnum format)
{
globals.ShmFormats.Add(format);
base.Format(eventSender, format);
}
}
class XdgWmBaseListener : XdgWmBase.Listener
{
protected override void Ping(XdgWmBase eventSender, uint serial)
{
eventSender.Pong(serial);
}
}
public WaylandGlobals(WaylandConnection connection, WaylandWorker worker, WaylandPlatformOptions platformOptions,
WaylandOutputsSinkProxy? outputsSink)
{
Connection = connection;
Worker = worker;
InputDispatcher = new WaylandInputDispatcher(this);
Outputs = new WaylandOutputsTracker(outputsSink);
Registry = connection.Display.GetRegistry(new RegistryListener(this), connection.Queue);
// Use a roundtrip to ensure we get all existing globals before our own event loop runs
connection.Queue.Roundtrip();
WlShm = BindRequired<WlShm>(1, 1, new ShmListener(this));
WlCompositor = BindRequired<WlCompositor>(4, 6, null);
XdgWmBase = BindRequired<XdgWmBase>(3, 4, new XdgWmBaseListener());
CursorManager = new WaylandCursorManager(connection.Display, WlShm, WlCompositor);
DataDeviceManager = Bind<WlDataDeviceManager>(3, 3, null);
LinuxDmabuf = Bind<ZwpLinuxDmabufV1>(4, 4, null);
FractionalScaleManager = Bind<WpFractionalScaleManagerV1>(1, 1, null);
Viewporter = Bind<WpViewporter>(1, 1, null);
TextInputManagerV3 = Bind<ZwpTextInputManagerV3>(1, 1, null);
XdgExporter = Bind<ZxdgExporterV2>(1, 1, null);
// Require v3 of zxdg_output_manager_v1 so wl_output.done acts as
// the unified terminator (xdg_output.done is deprecated since
// v3). Simplifies init-batch gating — same pragmatism as the
// wl_output v2 minimum.
XdgOutputManager = Bind<ZxdgOutputManagerV1>(3, 3, null);
if (XdgOutputManager != null)
Outputs.AttachXdgOutputManager(XdgOutputManager);
// ForceDrawnDecorations is a test/debug option: skip binding the
// SSD manager so toplevels behave as if the compositor never
// advertised support, exercising the CSD code path.
XdgDecorationManager = platformOptions.ForceDrawnDecorationsInternal
? null
: Bind<ZxdgDecorationManagerV1>(1, 1, null);
// Seats may have been announced before the data-device manager / text-input
// manager were bound — InputDispatcher backfills now and constructs the
// text-input-v3 facade if the manager is available.
// Seats are bound reactively via RegistryListener and managed by InputDispatcher
InputDispatcher.OnInitialGlobalsBound();
// Collect events from bound globals
connection.Queue.Roundtrip();
// GPU swapchain selection. Default is WSI (wl_egl_window +
// eglSwapBuffers) — required for NVIDIA and most resilient to driver
// quirks. Opt in to the dmabuf path (we own the allocator) by setting
// UseDmabufSwapchain = true.
var useDmabuf = platformOptions.UseDmabufSwapchain ?? false;
WaylandPlatformGraphics.IWaylandGraphics? gpu = null;
if (useDmabuf && LinuxDmabuf != null)
gpu = WaylandEglDmaBufPlatformGraphics.TryCreate(connection, this, platformOptions.GlProfiles);
else
gpu = WaylandEglWsiPlatformGraphics.TryCreate(connection, platformOptions.GlProfiles);
worker.PlatformGraphics.Initialize(gpu);
// TODO: sanity checks
}
public WaylandConnection Connection { get; }
public WaylandWorker Worker { get; }
public WaylandInputDispatcher InputDispatcher { get; }
public WaylandCursorManager CursorManager { get; }
public void Dispose()
{
InputDispatcher.Dispose();
Worker.PlatformGraphics.Reset();
}
}

188
src/Avalonia.Wayland/Server/Transient/WaylandInputDispatcher.Keyboard.cs

@ -0,0 +1,188 @@
using System;
using Avalonia.Input;
using Avalonia.Wayland.Server.Interop;
using Avalonia.Wayland.Server.Persistent;
using NWayland;
using NWayland.Protocols.Wayland;
using static Avalonia.Wayland.Server.Interop.XkbCommonNativeMethods;
namespace Avalonia.Wayland.Server.Transient;
partial class WaylandInputDispatcher
{
class KeyboardHandler : IDisposable
{
private readonly Seat _seat;
private readonly WlKeyboard _keyboard;
private readonly XkbContext _xkbContext;
private readonly XkbComposeTable? _composeTable;
private WXdgShellSurface? _focusedSurface;
private int _repeatRate; // keys/sec (0 = disabled)
private int _repeatDelay; // ms before first repeat
private XkbCommonKeymap? _keymap;
private XkbComposeState? _composeState;
// Standard XKB modifier bit positions
private const uint ModShift = 1 << 0;
private const uint ModControl = 1 << 2;
private const uint ModAlt = 1 << 3; // Mod1
private const uint ModMeta = 1 << 6; // Mod4 / Super
public KeyboardHandler(Seat seat)
{
_seat = seat;
_xkbContext = new XkbContext();
_composeTable = XkbComposeTable.TryCreate(_xkbContext);
_keyboard = seat.WlSeat.GetKeyboard(new Listener(this));
}
private static RawInputModifiers ModifiersFromXkbMask(uint mask)
{
var mods = RawInputModifiers.None;
if ((mask & ModShift) != 0) mods |= RawInputModifiers.Shift;
if ((mask & ModControl) != 0) mods |= RawInputModifiers.Control;
if ((mask & ModAlt) != 0) mods |= RawInputModifiers.Alt;
if ((mask & ModMeta) != 0) mods |= RawInputModifiers.Meta;
return mods;
}
public void Dispose()
{
_keyboard.Release();
_composeState?.Dispose();
_composeState = null;
_keymap?.Dispose();
_keymap = null;
_composeTable?.Dispose();
_xkbContext.Dispose();
}
class Listener(KeyboardHandler handler) : WlKeyboard.Listener
{
protected override void Keymap(WlKeyboard eventSender, WlKeyboard.KeymapFormatEnum format, WaylandFd fd, uint size)
{
handler._composeState?.Dispose();
handler._composeState = null;
handler._keymap?.Dispose();
handler._keymap = null;
var rawFd = fd.Consume();
if (format == WlKeyboard.KeymapFormatEnum.XkbV1)
{
try
{
handler._keymap = new XkbCommonKeymap(handler._xkbContext, rawFd, size);
if (handler._composeTable != null)
handler._composeState = new XkbComposeState(handler._composeTable);
}
catch
{
// If keymap creation fails, fall back to evdev-only mapping
}
}
else
{
UnsafeNativeMethods.close(rawFd);
}
}
protected override void Enter(WlKeyboard eventSender, uint serial, WlSurface? surface, ReadOnlySpan<byte> keys)
{
handler._focusedSurface = WaylandInputDispatcher.FindSurfaceForWlSurface(surface);
handler._focusedSurface?.EventSink.OnKeyRepeatInfo(handler._repeatRate, handler._repeatDelay);
if (handler._seat.DataDevice != null)
handler._seat.DataDevice.LastInputSerial = serial;
}
protected override void Leave(WlKeyboard eventSender, uint serial, WlSurface? surface)
{
handler._composeState?.Reset();
handler._focusedSurface?.EventSink.OnKeyboardLeave();
handler._focusedSurface = null;
handler._seat.KeyboardModifiers = RawInputModifiers.None;
}
protected override void Key(WlKeyboard eventSender, uint serial, uint time, uint key, WlKeyboard.KeyStateEnum state)
{
if (handler._seat.DataDevice != null)
handler._seat.DataDevice.LastInputSerial = serial;
var sink = handler._focusedSurface?.EventSink;
if (sink == null)
return;
var physicalKey = XkbKeyTransform.PhysicalKeyFromEvdev(key);
var mods = handler._seat.KeyboardModifiers;
Key avKey;
string? keySymbol;
if (handler._keymap != null)
{
(avKey, keySymbol) = XkbKeyTransform.ResolveKeyWithFallback(
handler._keymap, key, physicalKey);
// Process compose sequences on key press only, and only when the
// focused surface has an active text input client (e.g. a TextBox)
if (state == WlKeyboard.KeyStateEnum.Pressed
&& handler._composeState != null
&& handler._focusedSurface is { HasTextInputClient: true })
{
var (resolvedKeysym, _) = handler._keymap.ResolveKey(key);
var composeStatus = handler._composeState.Feed(resolvedKeysym);
switch (composeStatus)
{
case XKB_COMPOSE_COMPOSING:
// In the middle of a compose sequence — suppress entire event
return;
case XKB_COMPOSE_COMPOSED:
// Compose sequence complete — use composed text
keySymbol = XkbKeyTransform.FilterKeySymbol(
handler._composeState.GetComposedText());
var composedKeysym = handler._composeState.GetComposedKeysym();
var composedKey = XkbKeyTransform.KeyFromKeysym(composedKeysym);
if (composedKey != Avalonia.Input.Key.None)
avKey = composedKey;
break;
case XKB_COMPOSE_CANCELLED:
// Sequence cancelled — normal processing of current key
break;
// XKB_COMPOSE_NOTHING — normal processing
}
}
}
else
{
avKey = XkbKeyTransform.KeyFromEvdev(key);
keySymbol = null;
}
if (state == WlKeyboard.KeyStateEnum.Pressed)
sink.OnKeyDown(time, avKey, mods, physicalKey, keySymbol);
else
sink.OnKeyUp(time, avKey, mods, physicalKey, keySymbol);
}
protected override void Modifiers(WlKeyboard eventSender, uint serial, uint modsDepressed, uint modsLatched, uint modsLocked, uint group)
{
handler._keymap?.UpdateModifiers(modsDepressed, modsLatched, modsLocked, group);
var effective = modsDepressed | modsLatched | modsLocked;
handler._seat.KeyboardModifiers = ModifiersFromXkbMask(effective);
}
protected override void RepeatInfo(WlKeyboard eventSender, int rate, int delay)
{
handler._repeatRate = rate;
handler._repeatDelay = delay;
handler._focusedSurface?.EventSink.OnKeyRepeatInfo(rate, delay);
}
}
}
}

636
src/Avalonia.Wayland/Server/Transient/WaylandInputDispatcher.cs

@ -0,0 +1,636 @@
using System;
using System.Collections.Generic;
using Avalonia.Input;
using Avalonia.Input.Raw;
using Avalonia.Wayland.Clipboard;
using Avalonia.Wayland.Server.Transient.Clipboard;
using Avalonia.Wayland.Server.Interop;
using Avalonia.Wayland.Server.Persistent;
using NWayland;
using NWayland.Protocols.Wayland;
namespace Avalonia.Wayland.Server.Transient;
partial class WaylandInputDispatcher : IDisposable
{
private readonly WaylandGlobals _globals;
private readonly Dictionary<uint, Seat> _seats = new();
internal static WXdgShellSurface? FindSurfaceForWlSurface(WlSurface? surface) =>
surface != null && surface.Tags.TryGetValue(typeof(WXdgShellSurface), out var tag)
? (WXdgShellSurface)tag
: null;
internal void NotifyCursorChanged(WSurface surface)
{
foreach (var seat in _seats.Values)
seat.NotifyCursorChanged(surface);
}
/// <summary>
/// Sets the pointer cursor to provide visual DnD action feedback.
/// Called from the Wayland thread when <c>wl_data_source.action</c> fires during an active drag.
/// </summary>
internal void SetDndCursor(StandardCursorType cursorType)
{
foreach (var seat in _seats.Values)
seat.SetDndCursor(cursorType);
}
/// <summary>
/// Returns the <see cref="WlSurface"/> that currently has pointer focus.
/// Used as the origin surface for <see cref="WlDataDevice.StartDrag"/>.
/// </summary>
internal WlSurface? FindOriginSurface()
{
foreach (var seat in _seats.Values)
{
if (seat.PointerFocusedWlSurface is { } surface)
return surface;
}
return null;
}
public WaylandInputDispatcher(WaylandGlobals globals)
{
_globals = globals;
}
/// <summary>
/// Worker-side facade for <c>zwp_text_input_v3</c>. Null until
/// <see cref="OnInitialGlobalsBound"/> runs (and may stay null if the
/// compositor does not advertise the manager).
/// </summary>
internal WaylandTextInputV3? TextInputV3 { get; private set; }
/// <summary>
/// Returns the first available data device, or null if no seat has one.
/// Used by the clipboard implementation to access the active selection.
/// </summary>
internal WaylandDataDevice? GetDataDevice()
{
foreach (var seat in _seats.Values)
{
if (seat.DataDevice != null)
return seat.DataDevice;
}
return null;
}
internal void OnSeatAdded(uint globalName, WlRegistry registry, uint version)
{
var seat = new Seat(this, registry, globalName, version);
_seats[globalName] = seat;
TextInputV3?.OnSeatAdded(globalName, seat.WlSeat);
}
/// <summary>
/// Called once after <see cref="WaylandGlobals"/> has bound all available
/// globals. Constructs the text-input-v3 facade if the manager is present
/// and backfills per-seat objects (data devices, text-input-v3 proxies)
/// for any seats that were announced before this point.
/// </summary>
internal void OnInitialGlobalsBound()
{
if (_globals.TextInputManagerV3 is { } manager)
TextInputV3 = new WaylandTextInputV3(manager);
foreach (var (globalName, seat) in _seats)
{
seat.EnsureDataDevice();
TextInputV3?.OnSeatAdded(globalName, seat.WlSeat);
}
}
internal void OnSeatRemoved(uint globalName)
{
TextInputV3?.OnSeatRemoved(globalName);
if (_seats.Remove(globalName, out var seat))
seat.Dispose();
}
public void Dispose()
{
foreach (var seat in _seats.Values)
seat.Dispose();
_seats.Clear();
TextInputV3?.Dispose();
TextInputV3 = null;
}
// Linux input-event-codes.h button constants
private const uint BTN_LEFT = 0x110;
private const uint BTN_RIGHT = 0x111;
private const uint BTN_MIDDLE = 0x112;
private const uint BTN_SIDE = 0x113;
private const uint BTN_EXTRA = 0x114;
private static RawPointerEventType? MapButton(uint button, bool pressed) => button switch
{
BTN_LEFT => pressed ? RawPointerEventType.LeftButtonDown : RawPointerEventType.LeftButtonUp,
BTN_RIGHT => pressed ? RawPointerEventType.RightButtonDown : RawPointerEventType.RightButtonUp,
BTN_MIDDLE => pressed ? RawPointerEventType.MiddleButtonDown : RawPointerEventType.MiddleButtonUp,
BTN_SIDE => pressed ? RawPointerEventType.XButton1Down : RawPointerEventType.XButton1Up,
BTN_EXTRA => pressed ? RawPointerEventType.XButton2Down : RawPointerEventType.XButton2Up,
_ => null
};
private static RawInputModifiers ButtonToModifier(uint button) => button switch
{
BTN_LEFT => RawInputModifiers.LeftMouseButton,
BTN_RIGHT => RawInputModifiers.RightMouseButton,
BTN_MIDDLE => RawInputModifiers.MiddleMouseButton,
BTN_SIDE => RawInputModifiers.XButton1MouseButton,
BTN_EXTRA => RawInputModifiers.XButton2MouseButton,
_ => RawInputModifiers.None
};
class Seat : IDisposable
{
private readonly WaylandInputDispatcher _dispatcher;
private readonly WlSeat _wlSeat;
private PointerHandler? _pointerHandler;
private TouchHandler? _touchHandler;
private KeyboardHandler? _keyboardHandler;
public WlSeat WlSeat => _wlSeat;
public WaylandDataDevice? DataDevice { get; private set; }
/// <summary>
/// The WlSurface currently under pointer focus, exposed for DnD origin lookup.
/// </summary>
internal WlSurface? PointerFocusedWlSurface => _pointerHandler?._focusedWlSurface;
/// <summary>
/// Current keyboard modifiers (Shift/Ctrl/Alt/Meta), updated by the keyboard handler.
/// Read by pointer/touch handlers for combined modifier state.
/// </summary>
internal RawInputModifiers KeyboardModifiers;
public Seat(WaylandInputDispatcher dispatcher, WlRegistry registry, uint globalName, uint version)
{
_dispatcher = dispatcher;
_wlSeat = WlSeat.Bind(registry, globalName, version, new SeatListener(this));
EnsureDataDevice();
}
/// <summary>
/// Creates the data device for this seat if a <see cref="WlDataDeviceManager"/> is available
/// and the device hasn't been created yet. Called from the constructor and also after
/// the manager global becomes available (seats can be announced before the manager).
/// </summary>
internal void EnsureDataDevice()
{
if (DataDevice != null)
return;
var manager = _dispatcher._globals.DataDeviceManager;
if (manager == null)
return;
var deviceListener = new WaylandDataDeviceListener();
var wlDataDevice = manager.GetDataDevice(_wlSeat, deviceListener);
DataDevice = new WaylandDataDevice(wlDataDevice, _dispatcher, _wlSeat,
_dispatcher._globals.Connection.Display, _dispatcher._globals.Worker, () => KeyboardModifiers);
deviceListener.SetWrapper(DataDevice);
}
private void OnCapabilities(WlSeat.CapabilityEnum capabilities)
{
var hasPointer = capabilities.HasFlag(WlSeat.CapabilityEnum.Pointer);
if (hasPointer && _pointerHandler == null)
{
_pointerHandler = new PointerHandler(_dispatcher, this);
}
else if (!hasPointer && _pointerHandler != null)
{
_pointerHandler.Dispose();
_pointerHandler = null;
}
var hasTouch = capabilities.HasFlag(WlSeat.CapabilityEnum.Touch);
if (hasTouch && _touchHandler == null)
{
_touchHandler = new TouchHandler(_dispatcher, this);
}
else if (!hasTouch && _touchHandler != null)
{
_touchHandler.Dispose();
_touchHandler = null;
}
var hasKeyboard = capabilities.HasFlag(WlSeat.CapabilityEnum.Keyboard);
if (hasKeyboard && _keyboardHandler == null)
{
_keyboardHandler = new KeyboardHandler(this);
}
else if (!hasKeyboard && _keyboardHandler != null)
{
_keyboardHandler.Dispose();
_keyboardHandler = null;
}
}
public void Dispose()
{
_pointerHandler?.Dispose();
_pointerHandler = null;
_touchHandler?.Dispose();
_touchHandler = null;
_keyboardHandler?.Dispose();
_keyboardHandler = null;
DataDevice?.Dispose();
_wlSeat.Dispose();
}
internal void NotifyCursorChanged(WSurface surface)
{
_pointerHandler?.NotifyCursorChanged(surface);
}
internal void SetDndCursor(StandardCursorType cursorType)
{
_pointerHandler?.SetDndCursor(cursorType);
}
class SeatListener(Seat seat) : WlSeat.Listener
{
protected override void Capabilities(WlSeat eventSender, WlSeat.CapabilityEnum capabilities)
{
seat.OnCapabilities(capabilities);
}
}
}
class PointerHandler : IDisposable
{
private readonly WaylandInputDispatcher _dispatcher;
private readonly Seat _seat;
private readonly WlPointer _pointer;
// Persistent pointer state (survives across frames)
private WSurfaceEventSinkProxy? _focusedSink;
private WSurface? _focusedSurface;
internal WlSurface? _focusedWlSurface;
private Point _pointerPosition;
private RawInputModifiers _modifiers;
private uint _lastEnterSerial;
// Per-frame: ordered event actions dispatched during Frame
private readonly List<Action> _frameActions = new();
// Per-frame: axis accumulation (protocol mandates combining within a frame).
//
// Wayland sends two related events for scrolling:
// - axis_value120 (since v8) — discrete-step indicator: 120 == one wheel detent.
// Replaces the deprecated axis_discrete in v8+. Sent for wheel sources.
// - axis — continuous magnitude in surface-local pixel-equivalent units. Sent for
// every scroll source (wheel + touchpad/continuous), regardless of version.
//
// For Avalonia (1.0 == one detent), value120/120 is the right unit when present.
// For continuous sources (touchpad), there is no value120 and we fall back to the
// raw axis value. We accumulate the two streams independently and combine at frame
// flush time, preferring v120 per axis when present). This is robust to either event order.
private bool _frameHasAxis;
private ulong _frameAxisTimestamp;
private double _frameAxisRawX;
private double _frameAxisRawY;
private double _frameV120X;
private double _frameV120Y;
private bool _frameV120SeenX;
private bool _frameV120SeenY;
public PointerHandler(WaylandInputDispatcher dispatcher, Seat seat)
{
_dispatcher = dispatcher;
_seat = seat;
_pointer = seat.WlSeat.GetPointer(new Listener(this));
}
// Shared, stateless fallback used when a surface hasn't requested a specific cursor.
private static readonly WaylandStandardCursor s_defaultCursor = new(StandardCursorType.Arrow);
private void UpdateCursor()
{
if (_focusedSurface == null)
return;
var image = (_focusedSurface.CurrentCursor ?? s_defaultCursor).Resolve(_dispatcher._globals);
if (image is { } c)
_pointer.SetCursor(_lastEnterSerial, c.Surface, c.HotspotX, c.HotspotY);
else
_pointer.SetCursor(_lastEnterSerial, null, 0, 0);
}
internal void NotifyCursorChanged(WSurface surface)
{
if (ReferenceEquals(_focusedSurface, surface))
UpdateCursor();
}
/// <summary>
/// Sets the pointer cursor to reflect the current DnD action.
/// Called from <see cref="WaylandDataSource.OnAction"/> during an active drag source operation.
/// </summary>
/// <remarks>
/// TODO: Weston does not honor <c>wl_pointer.set_cursor</c> during drag.
/// To support Weston, we'd need to attach cursor buffers to the drag icon surface
/// passed to <c>wl_data_device.start_drag</c>. Our current approach matches Qt behavior
/// and works correctly with KWin/Mutter. Weston compat is low priority.
/// </remarks>
internal void SetDndCursor(StandardCursorType cursorType)
{
var cursorInfo = _dispatcher._globals.CursorManager.GetCursor(cursorType);
if (cursorInfo is { } c)
_pointer.SetCursor(_lastEnterSerial, c.Surface, c.HotspotX, c.HotspotY);
else
_pointer.SetCursor(_lastEnterSerial, null!, 0, 0);
}
private void ResetFrameState()
{
_frameActions.Clear();
_frameHasAxis = false;
_frameAxisRawX = 0;
_frameAxisRawY = 0;
_frameV120X = 0;
_frameV120Y = 0;
_frameV120SeenX = false;
_frameV120SeenY = false;
}
public void Dispose()
{
_pointer.Release();
}
class Listener(PointerHandler handler) : WlPointer.Listener
{
protected override void Enter(WlPointer eventSender, uint serial, WlSurface? surface, WlFixed surfaceX, WlFixed surfaceY)
{
var shellSurface = WaylandInputDispatcher.FindSurfaceForWlSurface(surface);
var pos = new Point((double)surfaceX, (double)surfaceY);
handler._pointerPosition = pos;
handler._frameActions.Add(() =>
{
handler._focusedSink = shellSurface?.EventSink;
handler._focusedSurface = shellSurface;
handler._focusedWlSurface = surface;
handler._lastEnterSerial = serial;
handler.UpdateCursor();
shellSurface?.EventSink.OnPointerEnter(0, serial, pos);
});
}
protected override void Leave(WlPointer eventSender, uint serial, WlSurface? surface)
{
handler._frameActions.Add(() =>
{
var leaveSink = handler._focusedSink;
handler._focusedSink = null;
handler._focusedSurface = null;
handler._focusedWlSurface = null;
leaveSink?.OnPointerLeave(serial);
});
}
protected override void Motion(WlPointer eventSender, uint time, WlFixed surfaceX, WlFixed surfaceY)
{
var pos = new Point((double)surfaceX, (double)surfaceY);
handler._pointerPosition = pos;
var mods = handler._modifiers | handler._seat.KeyboardModifiers;
handler._frameActions.Add(() =>
{
handler._focusedSink?.OnPointerMotion(time, pos, mods);
});
}
protected override void Button(WlPointer eventSender, uint serial, uint time, uint button, WlPointer.ButtonStateEnum state)
{
if (handler._seat.DataDevice != null)
handler._seat.DataDevice.LastInputSerial = serial;
var pressed = state == WlPointer.ButtonStateEnum.Pressed;
var type = MapButton(button, pressed);
if (type == null)
return;
var mod = ButtonToModifier(button);
if (pressed)
handler._modifiers |= mod;
else
handler._modifiers &= ~mod;
var mods = handler._modifiers | handler._seat.KeyboardModifiers;
var pos = handler._pointerPosition;
var cookie = pressed ? new WaylandInputEventCookie(handler._seat.WlSeat, serial, handler._dispatcher._globals.Connection.Display, handler._dispatcher._globals.Worker, handler._dispatcher._globals) : null;
handler._frameActions.Add(() =>
{
handler._focusedSink?.OnPointerButton(time, serial, type.Value, mods, pos, cookie);
});
}
private void EnqueueAxisDispatchIfNeeded()
{
if (handler._frameHasAxis)
return;
handler._frameHasAxis = true;
handler._frameActions.Add(() =>
{
// Combine v120 (notches) with raw axis (continuous). Per axis, prefer v120
// if any v120 event fired this frame; otherwise fall back to the raw axis.
var deltaX = handler._frameV120SeenX ? handler._frameV120X : handler._frameAxisRawX;
var deltaY = handler._frameV120SeenY ? handler._frameV120Y : handler._frameAxisRawY;
if (deltaX != 0 || deltaY != 0)
{
// Wayland: positive = scroll down/right. Avalonia: positive Y = scroll up.
var delta = new Vector(-deltaX, -deltaY);
handler._focusedSink?.OnPointerAxis(handler._frameAxisTimestamp, delta,
handler._modifiers | handler._seat.KeyboardModifiers, handler._pointerPosition);
}
});
}
protected override void Axis(WlPointer eventSender, uint time, WlPointer.AxisEnum axis, WlFixed value)
{
handler._frameAxisTimestamp = time;
if (axis == WlPointer.AxisEnum.VerticalScroll)
handler._frameAxisRawY += (double)value;
else if (axis == WlPointer.AxisEnum.HorizontalScroll)
handler._frameAxisRawX += (double)value;
EnqueueAxisDispatchIfNeeded();
}
protected override void AxisSource(WlPointer eventSender, WlPointer.AxisSourceEnum axisSource)
{
}
protected override void AxisStop(WlPointer eventSender, uint time, WlPointer.AxisEnum axis)
{
}
protected override void AxisDiscrete(WlPointer eventSender, WlPointer.AxisEnum axis, int discrete)
{
// wl_pointer v5–v7 discrete-step indicator. 1 == one wheel detent. Deprecated
// and not sent in v8+ (replaced by axis_value120). Folded into the v120
// accumulator (since their semantics align: 1 detent each).
if (axis == WlPointer.AxisEnum.VerticalScroll)
{
handler._frameV120Y += discrete;
handler._frameV120SeenY = true;
}
else if (axis == WlPointer.AxisEnum.HorizontalScroll)
{
handler._frameV120X += discrete;
handler._frameV120SeenX = true;
}
EnqueueAxisDispatchIfNeeded();
}
protected override void AxisValue120(WlPointer eventSender, WlPointer.AxisEnum axis, int value120)
{
// Higher-resolution scroll: 120 units = one notch. Multiple value120 events
// for the same axis in a frame must be summed (per spec example: a single
// hardware event can produce -240 = two negative notches).
if (axis == WlPointer.AxisEnum.VerticalScroll)
{
handler._frameV120Y += value120 / 120.0;
handler._frameV120SeenY = true;
}
else if (axis == WlPointer.AxisEnum.HorizontalScroll)
{
handler._frameV120X += value120 / 120.0;
handler._frameV120SeenX = true;
}
EnqueueAxisDispatchIfNeeded();
}
protected override void Frame(WlPointer eventSender)
{
try
{
foreach (var action in handler._frameActions)
action();
}
finally
{
handler.ResetFrameState();
}
}
}
}
class TouchHandler : IDisposable
{
private readonly WaylandInputDispatcher _dispatcher;
private readonly Seat _seat;
private readonly WlTouch _touch;
// Track active touch point ID → (surface sink, last position)
private readonly Dictionary<int, (WSurfaceEventSinkProxy Sink, Point Position)> _activeTouchPoints = new();
// Per-frame: ordered event actions dispatched during Frame
private readonly List<Action> _frameActions = new();
public TouchHandler(WaylandInputDispatcher dispatcher, Seat seat)
{
_dispatcher = dispatcher;
_seat = seat;
_touch = seat.WlSeat.GetTouch(new Listener(this));
}
private void ResetFrameState()
{
_frameActions.Clear();
}
public void Dispose()
{
_touch.Release();
}
class Listener(TouchHandler handler) : WlTouch.Listener
{
protected override void Down(WlTouch eventSender, uint serial, uint time, WlSurface? surface,
int id, WlFixed x, WlFixed y)
{
if (handler._seat.DataDevice != null)
handler._seat.DataDevice.LastInputSerial = serial;
var shellSurface = WaylandInputDispatcher.FindSurfaceForWlSurface(surface);
var pos = new Point((double)x, (double)y);
var cookie = new WaylandInputEventCookie(handler._seat.WlSeat, serial, handler._dispatcher._globals.Connection.Display, handler._dispatcher._globals.Worker, handler._dispatcher._globals);
handler._frameActions.Add(() =>
{
if (shellSurface != null)
{
handler._activeTouchPoints[id] = (shellSurface.EventSink, pos);
shellSurface.EventSink.OnTouchDown(time, id, pos, cookie);
}
});
}
protected override void Motion(WlTouch eventSender, uint time, int id, WlFixed x, WlFixed y)
{
var pos = new Point((double)x, (double)y);
handler._frameActions.Add(() =>
{
if (handler._activeTouchPoints.TryGetValue(id, out var entry))
{
entry.Position = pos;
handler._activeTouchPoints[id] = entry;
entry.Sink.OnTouchMove(time, id, pos);
}
});
}
protected override void Up(WlTouch eventSender, uint serial, uint time, int id)
{
handler._frameActions.Add(() =>
{
if (handler._activeTouchPoints.Remove(id, out var entry))
entry.Sink.OnTouchUp(time, id, entry.Position);
});
}
protected override void Cancel(WlTouch eventSender)
{
handler._frameActions.Add(() =>
{
foreach (var (id, entry) in handler._activeTouchPoints)
entry.Sink.OnTouchCancel(id, entry.Position);
handler._activeTouchPoints.Clear();
});
}
protected override void Frame(WlTouch eventSender)
{
try
{
foreach (var action in handler._frameActions)
action();
}
finally
{
handler.ResetFrameState();
}
}
protected override void Shape(WlTouch eventSender, int id, WlFixed major, WlFixed minor)
{
// Shape info not currently used
}
protected override void Orientation(WlTouch eventSender, int id, WlFixed orientation)
{
// Orientation info not currently used
}
}
}
}

259
src/Avalonia.Wayland/Server/Transient/WaylandOutputsTracker.cs

@ -0,0 +1,259 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Avalonia.Wayland.Screens;
using NWayland.Protocols.Wayland;
using NWayland.Protocols.XdgOutputUnstableV1;
namespace Avalonia.Wayland.Server.Transient;
/// <summary>
/// Worker-thread tracker for <c>wl_output</c> globals (plus optional
/// <c>zxdg_output_v1</c> companions). Produces an immutable
/// <see cref="WaylandOutputsSnapshot"/> on every change and pushes it
/// to the UI thread
/// </summary>
internal sealed class WaylandOutputsTracker
{
public WaylandOutputsTracker(WaylandOutputsSinkProxy? sink)
{
_sink = sink;
}
// Track outputs that haven't got their first done event
private readonly List<Output> _pendingOutputs = new();
public List<Output> Outputs { get; } = new();
private readonly WaylandOutputsSinkProxy? _sink;
private ZxdgOutputManagerV1? _xdgOutputManager;
/// <summary>
/// Wires the xdg-output manager once <see cref="WaylandGlobals"/> has
/// finished its initial round-trip. Any outputs already present (in
/// either <see cref="_pendingOutputs"/> or <see cref="Outputs"/>) get
/// their <c>zxdg_output_v1</c> attached on the spot; outputs added
/// later attach inside their constructor.
/// </summary>
public void AttachXdgOutputManager(ZxdgOutputManagerV1 manager)
{
if (_xdgOutputManager != null)
return;
_xdgOutputManager = manager;
foreach (var o in _pendingOutputs.Concat(Outputs))
o.AttachXdgOutput(manager);
}
public void AddGlobal(WaylandGlobals globals, uint name, uint version)
{
if (version < 2)
return;
var output = new Output(this, globals.Registry, name, version);
_pendingOutputs.Add(output);
if (_xdgOutputManager != null)
output.AttachXdgOutput(_xdgOutputManager);
globals.GlobalRemoved += removedName =>
{
// Snapshot before mutating to avoid double-handling.
foreach (var o in _pendingOutputs.Concat(Outputs).ToList())
{
if (o.Name == removedName)
{
_pendingOutputs.Remove(o);
var wasVisible = Outputs.Remove(o);
o.Dispose();
if (wasVisible)
PushSnapshot();
break;
}
}
};
}
/// <summary>
/// Promotes an output to <see cref="Outputs"/> once its initial done
/// batch has arrived (or just rebuilds the snapshot if it was already
/// visible and just had post-init changes applied).
/// </summary>
internal void OnOutputDone(Output output)
{
var becameVisible = _pendingOutputs.Remove(output);
if (becameVisible)
Outputs.Add(output);
PushSnapshot();
}
private void PushSnapshot()
{
if (_sink == null)
return;
var list = new List<WaylandOutputSnapshot>(Outputs.Count);
foreach (var o in Outputs)
list.Add(o.ToSnapshot());
_sink.OnOutputsChanged(new WaylandOutputsSnapshot(list));
}
internal sealed class Output : IDisposable
{
public uint Name { get; }
private readonly WaylandOutputsTracker _tracker;
private readonly WlOutput _output;
private ZxdgOutputV1? _xdgOutput;
// wl_output state
public PixelPoint ModePosition { get; private set; }
public PixelSize ModeSize { get; private set; }
public int Scale { get; private set; } = 1;
public int RefreshMilliHz { get; private set; }
public WlOutput.SubpixelEnum Subpixel { get; private set; } = WlOutput.SubpixelEnum.Unknown;
public WlOutput.TransformEnum Transform { get; private set; } = WlOutput.TransformEnum.Normal;
public PixelSize PhysicalSizeMm { get; private set; }
public string? Manufacturer { get; private set; }
public string? Model { get; private set; }
public string? OutputName { get; private set; }
public string? OutputDescription { get; private set; }
// xdg_output state
public bool HasXdgOutput { get; private set; }
public PixelPoint XdgPosition { get; private set; }
public PixelSize XdgSize { get; private set; }
public string? XdgName { get; private set; }
public string? XdgDescription { get; private set; }
public object Id { get; } = new();
public WlOutput WlOutput => _output;
public Output(WaylandOutputsTracker tracker, WlRegistry registry, uint name, uint version)
{
Name = name;
_tracker = tracker;
_output = WlOutput.Bind(registry, name, Math.Min(4u, version), new WlListener(this));
}
public void AttachXdgOutput(ZxdgOutputManagerV1 manager)
{
if (_xdgOutput != null)
return;
// Manager bind is gated to v3+ in WaylandGlobals, so
// wl_output.done is the unified terminator for both wl_output
// and zxdg_output_v1 events.
_xdgOutput = manager.GetXdgOutput(_output, new XdgListener(this));
}
internal void OnWlOutputDone()
{
_tracker.OnOutputDone(this);
}
public WaylandOutputSnapshot ToSnapshot()
{
var (pos, size) = ComputeLogical();
return new WaylandOutputSnapshot(
Id: Id,
Name: XdgName ?? OutputName,
Description: XdgDescription ?? OutputDescription,
Manufacturer: Manufacturer,
Model: Model,
LogicalPosition: pos,
LogicalSize: size,
IntegerScale: Scale,
RefreshRateHz: RefreshMilliHz / 1000.0,
Subpixel: Subpixel,
Transform: Transform,
PhysicalSizeMm: PhysicalSizeMm);
}
public PixelPoint LogicalPosition => ComputeLogical().pos;
public PixelSize LogicalSize => ComputeLogical().size;
private (PixelPoint pos, PixelSize size) ComputeLogical()
{
if (HasXdgOutput)
{
// https://gitlab.gnome.org/GNOME/mutter/-/issues/2631
// If integer scale > 1 and xdg_output reports the same size
// as wl_output.mode (which is in physical pixels), mutter
// is lying. Fall through to the mode/scale derivation.
var mutterpt = Scale > 1 && XdgSize == ModeSize;
if (!mutterpt)
return (XdgPosition, XdgSize);
}
// Fallback: derive logical pixels from wl_output.mode divided
// by wl_output.scale (geometry top-left is already in
// compositor surface coords, no division needed). Matches
// GTK/Qt fallback when xdg_output is absent.
return (ModePosition, new PixelSize(
Math.Max(1, ModeSize.Width / Math.Max(1, Scale)),
Math.Max(1, ModeSize.Height / Math.Max(1, Scale))));
}
public void Dispose()
{
if (_xdgOutput != null)
{
_xdgOutput.Destroy();
_xdgOutput.Dispose();
_xdgOutput = null;
}
if (_output.IsReleaseAvailable)
_output.Release();
else
_output.Dispose();
}
private sealed class WlListener(Output p) : WlOutput.Listener
{
protected override void Name(WlOutput eventSender, string name) => p.OutputName = name;
protected override void Description(WlOutput eventSender, string description) => p.OutputDescription = description;
protected override void Done(WlOutput eventSender) => p.OnWlOutputDone();
protected override void Geometry(WlOutput eventSender, int x, int y, int physicalWidth, int physicalHeight,
WlOutput.SubpixelEnum subpixel, string make, string model, WlOutput.TransformEnum transform)
{
p.ModePosition = new PixelPoint(x, y);
p.PhysicalSizeMm = new PixelSize(physicalWidth, physicalHeight);
p.Subpixel = subpixel;
p.Manufacturer = make;
p.Model = model;
p.Transform = transform;
}
protected override void Mode(WlOutput eventSender, WlOutput.ModeEnum flags, int width, int height, int refresh)
{
if (!flags.HasFlag(WlOutput.ModeEnum.Current))
return;
p.ModeSize = new PixelSize(width, height);
p.RefreshMilliHz = refresh;
}
protected override void Scale(WlOutput eventSender, int factor) => p.Scale = factor;
}
private sealed class XdgListener(Output p) : ZxdgOutputV1.Listener
{
protected override void LogicalPosition(ZxdgOutputV1 eventSender, int x, int y)
{
p.HasXdgOutput = true;
p.XdgPosition = new PixelPoint(x, y);
}
protected override void LogicalSize(ZxdgOutputV1 eventSender, int width, int height)
{
p.HasXdgOutput = true;
p.XdgSize = new PixelSize(width, height);
}
// zxdg_output_v1.done is deprecated since v3 (we only bind
// v3+); wl_output.done is the unified terminator.
protected override void Done(ZxdgOutputV1 eventSender) { }
protected override void Name(ZxdgOutputV1 eventSender, string name) => p.XdgName = name;
protected override void Description(ZxdgOutputV1 eventSender, string description) => p.XdgDescription = description;
}
}
}

570
src/Avalonia.Wayland/Server/Transient/WaylandTextInputV3.cs

@ -0,0 +1,570 @@
using System;
using System.Collections.Generic;
using Avalonia.Input.TextInput;
using Avalonia.Logging;
using Avalonia.Wayland.Server.Persistent;
using NWayland.Protocols.TextInputUnstableV3;
using NWayland.Protocols.Wayland;
namespace Avalonia.Wayland.Server.Transient;
/// <summary>
/// Worker-side facade for <c>zwp_text_input_v3</c>. Owns the per-seat protocol
/// proxies and per-surface state cache. Bridges incoming compositor events into
/// the broker via <see cref="IWaylandTextInputV3Events"/>.
/// </summary>
/// <remarks>
/// All public methods must be called on the Wayland worker thread (post via
/// <c>WaylandWorkerClient.PostOob</c> from the UI thread).
/// </remarks>
internal sealed class WaylandTextInputV3(ZwpTextInputManagerV3 manager)
{
private readonly Dictionary<uint, PerSeat> _seats = new();
private readonly Dictionary<WSurface, PerSurface> _surfaces = new();
public void OnSeatAdded(uint seatGlobalName, WlSeat wlSeat)
{
if (_seats.ContainsKey(seatGlobalName))
return;
var seat = new PerSeat(this, seatGlobalName);
seat.Proxy = manager.GetTextInput(wlSeat, seat.CreateListener(), null);
_seats[seatGlobalName] = seat;
}
public void OnSeatRemoved(uint seatGlobalName)
{
if (!_seats.Remove(seatGlobalName, out var seat))
return;
if (seat.FocusedSurface is { } surface
&& _surfaces.TryGetValue(surface, out var perSurface))
{
perSurface.EnteredSeats.Remove(seat);
}
try { seat.Proxy?.Destroy(); } catch { }
seat.Proxy?.Dispose();
}
public void Dispose()
{
foreach (var seat in _seats.Values)
{
try { seat.Proxy?.Destroy(); } catch { }
seat.Proxy?.Dispose();
}
_seats.Clear();
_surfaces.Clear();
try { manager.Destroy(); } catch { }
manager.Dispose();
}
public void RegisterSurface(WSurface s, WaylandTextInputV3EventsProxy sink)
{
if (_surfaces.ContainsKey(s))
return;
var ps = new PerSurface(s, sink);
// Reconcile: if any seat's text-input-v3 already has focus on this
// surface (enter event arrived before the broker registered), record
// it now so a subsequent SetActive(true) can issue Enable+Commit.
foreach (var seat in _seats.Values)
{
if (ReferenceEquals(seat.FocusedSurface, s))
ps.EnteredSeats.Add(seat);
}
_surfaces[s] = ps;
}
public void UnregisterSurface(WSurface s)
{
if (!_surfaces.Remove(s, out var perSurface))
return;
// For each seat currently entered on this surface, send disable+commit
// so the compositor stops routing IME events to a now-dead surface.
foreach (var seat in perSurface.EnteredSeats)
{
if (ReferenceEquals(seat.FocusedSurface, s))
seat.FocusedSurface = null;
DisableAndClear(seat, perSurface);
}
}
public void SetActive(WSurface s, bool hasClient, bool supportsPreedit, bool supportsSurroundingText)
{
if (!_surfaces.TryGetValue(s, out var ps))
return;
ps.HasClient = hasClient;
ps.SupportsPreedit = supportsPreedit;
ps.SupportsSurroundingText = supportsSurroundingText;
PushStateToEnteredSeats(s, ps);
}
/// <summary>
/// Aborts any in-flight IME composition for this surface: sends
/// disable+commit on every entered seat and clears the per-seat
/// last-delivered state so the next preedit is not deduped away.
/// Also clears cached surrounding text so the next client starts clean.
/// Called when the broker's TextInputMethodClient changes.
/// </summary>
public void AbortComposition(WSurface s)
{
if (!_surfaces.TryGetValue(s, out var ps))
return;
foreach (var seat in ps.EnteredSeats)
{
DisableAndClear(seat, ps);
seat.ClearLastDelivered();
}
ps.LastSentEnabled = false;
ps.SurroundingText = null;
ps.SurroundingCursorChar = 0;
ps.SurroundingAnchorChar = 0;
ps.CursorRect = null;
ps.Options = null;
ps.HasClient = false;
}
public void SetCursorRect(WSurface s, Rect logical)
{
if (!_surfaces.TryGetValue(s, out var ps))
return;
ps.CursorRect = logical;
if (!ps.LastSentEnabled)
return;
var pixelRect = TranslateCursorRect(s, logical);
foreach (var seat in ps.EnteredSeats)
{
if (seat.Proxy is not { } proxy)
continue;
proxy.SetCursorRectangle(pixelRect.x, pixelRect.y, pixelRect.w, pixelRect.h);
seat.Commit();
}
}
public void SetOptions(WSurface s, TextInputOptions options)
{
if (!_surfaces.TryGetValue(s, out var ps))
return;
ps.Options = options;
if (!ps.LastSentEnabled)
return;
var (hint, purpose) = TextInputOptionsConverter.Convert(options);
foreach (var seat in ps.EnteredSeats)
{
if (seat.Proxy is not { } proxy)
continue;
proxy.SetContentType(hint, purpose);
seat.Commit();
}
}
public void SetSurroundingText(WSurface s, string text, int cursorChar, int anchorChar)
{
if (!_surfaces.TryGetValue(s, out var ps))
return;
ps.SurroundingText = text;
ps.SurroundingCursorChar = cursorChar;
ps.SurroundingAnchorChar = anchorChar;
if (!ps.LastSentEnabled || !ps.SupportsSurroundingText)
return;
// Compositors enforce a 4000-byte cap per spec; truncate around cursor.
var (clampedText, cursorByte, anchorByte) = ClampSurroundingForWayland(text, cursorChar, anchorChar);
foreach (var seat in ps.EnteredSeats)
{
if (seat.Proxy is not { } proxy)
continue;
proxy.SetSurroundingText(clampedText, cursorByte, anchorByte);
proxy.SetTextChangeCause(ZwpTextInputV3.ChangeCauseEnum.Other);
seat.Commit();
}
}
public void Reset(WSurface s)
{
// v3 has no reset request. Closest safe behaviour: drop pending preedit
// by issuing disable + enable + commit. Compositor treats this as a fresh
// session and discards in-flight preedit.
if (!_surfaces.TryGetValue(s, out var ps) || !ps.HasClient)
return;
var hadDeliveredPreedit = false;
foreach (var seat in ps.EnteredSeats)
{
if (seat.Proxy is not { } proxy)
continue;
if (!string.IsNullOrEmpty(seat.LastDeliveredPreedit))
hadDeliveredPreedit = true;
seat.ClearPending();
seat.ClearLastDelivered();
proxy.Disable();
seat.Commit();
proxy.Enable();
PushSeatStateLocked(seat, s, ps);
seat.Commit();
}
// Clear stale composition on the client side too — otherwise the visible
// preedit lingers until the IME (re-)sends one, which may be deduped.
if (hadDeliveredPreedit)
ps.Sink.OnImeUpdate(ps.Surface.TextInputSessionToken, 0, 0, null, null, -1, -1);
}
private static void DisableAndClear(PerSeat seat, PerSurface ps)
{
if (ps.LastSentEnabled && seat.Proxy is { } proxy)
{
try
{
proxy.Disable();
seat.Commit();
}
catch { }
}
seat.ClearPending();
}
private static void PushStateToEnteredSeats(WSurface s, PerSurface ps)
{
var enable = ps.HasClient;
foreach (var seat in ps.EnteredSeats)
{
if (seat.Proxy is not { } proxy)
continue;
if (enable)
{
proxy.Enable();
PushSeatStateLocked(seat, s, ps);
}
else if (ps.LastSentEnabled)
{
proxy.Disable();
}
seat.Commit();
}
ps.LastSentEnabled = enable;
}
private static void PushSeatStateLocked(PerSeat seat, WSurface s, PerSurface ps)
{
if (seat.Proxy is not { } proxy)
return;
if (ps.Options is { } options)
{
var (hint, purpose) = TextInputOptionsConverter.Convert(options);
proxy.SetContentType(hint, purpose);
}
if (ps.CursorRect is { } rect)
{
var (x, y, w, h) = TranslateCursorRect(s, rect);
proxy.SetCursorRectangle(x, y, w, h);
}
if (ps.SupportsSurroundingText && ps.SurroundingText is { } text)
{
var (clampedText, cursorByte, anchorByte) =
ClampSurroundingForWayland(text, ps.SurroundingCursorChar, ps.SurroundingAnchorChar);
proxy.SetSurroundingText(clampedText, cursorByte, anchorByte);
proxy.SetTextChangeCause(ZwpTextInputV3.ChangeCauseEnum.Other);
}
}
private static (int x, int y, int w, int h) TranslateCursorRect(WSurface s, Rect logical)
{
// The cursor rectangle is in surface-local logical units. Under fractional
// scaling this matches wp_viewport.set_destination units (also surface-local
// logical). Add shadow extents so the candidate window appears next to the
// editable content area, not next to the drop shadow.
var shadow = (s as WXdgShellSurface)?.ShadowExtents ?? default;
var x = (int)Math.Round(logical.X) + (int)Math.Round(shadow.Left);
var y = (int)Math.Round(logical.Y) + (int)Math.Round(shadow.Top);
var w = Math.Max(1, (int)Math.Round(logical.Width));
var h = Math.Max(1, (int)Math.Round(logical.Height));
return (x, y, w, h);
}
private static (string text, int cursorByte, int anchorByte) ClampSurroundingForWayland(
string text, int cursorChar, int anchorChar)
{
const int MaxBytes = 4000;
var totalBytes = System.Text.Encoding.UTF8.GetByteCount(text);
if (totalBytes <= MaxBytes)
{
return (
text,
WaylandTextUtils.Utf8ByteIndexFromCharIndex(text, cursorChar),
WaylandTextUtils.Utf8ByteIndexFromCharIndex(text, anchorChar));
}
// Trim around cursor: keep ~half of MaxBytes on each side. The protocol
// accepts text that doesn't extend to start/end as a partial window.
var half = MaxBytes / 2;
var cursorClamped = Math.Clamp(cursorChar, 0, text.Length);
var startChar = WaylandTextUtils.CharIndexFromUtf8Offset(text, -half, cursorClamped);
var endChar = WaylandTextUtils.CharIndexFromUtf8Offset(text, half, cursorClamped);
var trimmed = text.Substring(startChar, endChar - startChar);
var cursorByte = WaylandTextUtils.Utf8ByteIndexFromCharIndex(trimmed,
Math.Clamp(cursorChar - startChar, 0, trimmed.Length));
var anchorByte = WaylandTextUtils.Utf8ByteIndexFromCharIndex(trimmed,
Math.Clamp(anchorChar - startChar, 0, trimmed.Length));
return (trimmed, cursorByte, anchorByte);
}
// ----- inner state types -----
private sealed class PerSurface(WSurface surface, WaylandTextInputV3EventsProxy sink)
{
public WSurface Surface { get; } = surface;
public WaylandTextInputV3EventsProxy Sink { get; } = sink;
public List<PerSeat> EnteredSeats { get; } = new();
public bool HasClient;
public bool SupportsPreedit;
public bool SupportsSurroundingText;
public bool LastSentEnabled;
public Rect? CursorRect;
public TextInputOptions? Options;
public string? SurroundingText;
public int SurroundingCursorChar;
public int SurroundingAnchorChar;
}
private sealed class PerSeat(WaylandTextInputV3 owner, uint globalName)
{
public uint GlobalName { get; } = globalName;
public ZwpTextInputV3? Proxy;
public WSurface? FocusedSurface;
public uint CommitSerial;
public uint LastDoneSerial;
// Pending state, accumulated until a done event. The protocol uses
// a double-buffered model: preedit_string / commit_string /
// delete_surrounding_text events stage values that are applied — and
// reset — atomically on each done. So a done with no preceding
// preedit_string event clears the preedit; this is how the protocol
// model works, not a special-case rule.
public string PendingPreedit = string.Empty;
public int PendingPreeditCursorBeginByte;
public int PendingPreeditCursorEndByte = -1;
public string PendingCommit = string.Empty;
public bool HasDelete;
public uint PendingDeleteBeforeBytes;
public uint PendingDeleteAfterBytes;
// Last delivered preedit (for done-event dedup).
public string? LastDeliveredPreedit;
public int LastDeliveredCursorBeginChar;
public int LastDeliveredCursorEndChar;
public ZwpTextInputV3.Listener CreateListener() => new SeatListener(owner, this);
public void Commit()
{
CommitSerial++;
Proxy?.Commit();
}
public void ClearPending()
{
PendingPreedit = string.Empty;
PendingPreeditCursorBeginByte = 0;
PendingPreeditCursorEndByte = -1;
PendingCommit = string.Empty;
HasDelete = false;
PendingDeleteBeforeBytes = PendingDeleteAfterBytes = 0;
}
public void ClearLastDelivered()
{
LastDeliveredPreedit = null;
LastDeliveredCursorBeginChar = 0;
LastDeliveredCursorEndChar = 0;
}
}
private sealed class SeatListener(WaylandTextInputV3 owner, PerSeat seat) : ZwpTextInputV3.Listener
{
protected override void Enter(ZwpTextInputV3 sender, WlSurface? surface)
{
var ws = ResolveSurface(surface);
if (ws == null)
return;
seat.FocusedSurface = ws;
if (!owner._surfaces.TryGetValue(ws, out var ps))
return;
ps.EnteredSeats.Remove(seat);
ps.EnteredSeats.Add(seat);
seat.ClearPending();
if (ps.HasClient && seat.Proxy is { } proxy)
{
proxy.Enable();
PushSeatStateLocked(seat, ws, ps);
seat.Commit();
ps.LastSentEnabled = true;
}
}
protected override void Leave(ZwpTextInputV3 sender, WlSurface? surface)
{
var ws = ResolveSurface(surface);
if (ws == null && seat.FocusedSurface == null)
return;
ws ??= seat.FocusedSurface;
seat.FocusedSurface = null;
seat.ClearPending();
if (ws != null && owner._surfaces.TryGetValue(ws, out var ps))
{
ps.EnteredSeats.Remove(seat);
if (ps.EnteredSeats.Count == 0)
ps.LastSentEnabled = false;
// Per spec, leaving invalidates any in-flight preedit. If we
// had delivered preedit text from this seat, clear it on the
// client side so the user doesn't see stale composition text.
if (!string.IsNullOrEmpty(seat.LastDeliveredPreedit))
{
ps.Sink.OnImeUpdate(ps.Surface.TextInputSessionToken, 0, 0, null, null, -1, -1);
}
}
seat.ClearLastDelivered();
}
protected override void PreeditString(ZwpTextInputV3 sender, string? text, int cursorBegin, int cursorEnd)
{
seat.PendingPreedit = text ?? string.Empty;
seat.PendingPreeditCursorBeginByte = cursorBegin;
seat.PendingPreeditCursorEndByte = cursorEnd;
}
protected override void CommitString(ZwpTextInputV3 sender, string? text)
{
seat.PendingCommit = text ?? string.Empty;
}
protected override void DeleteSurroundingText(ZwpTextInputV3 sender, uint beforeLength, uint afterLength)
{
seat.HasDelete = true;
seat.PendingDeleteBeforeBytes = beforeLength;
seat.PendingDeleteAfterBytes = afterLength;
}
protected override void Done(ZwpTextInputV3 sender, uint serial)
{
seat.LastDoneSerial = serial;
// Snapshot pending state, then clear immediately. The protocol's
// double-buffered model requires pending preedit/commit/delete
// to be consumed atomically on each done — so a subsequent done
// with no preedit_string event clears the preedit.
var pendingPreedit = seat.PendingPreedit;
var pendingPreeditBeginByte = seat.PendingPreeditCursorBeginByte;
var pendingPreeditEndByte = seat.PendingPreeditCursorEndByte;
var pendingCommit = seat.PendingCommit;
var hasDelete = seat.HasDelete;
var pendingDeleteBefore = seat.PendingDeleteBeforeBytes;
var pendingDeleteAfter = seat.PendingDeleteAfterBytes;
seat.ClearPending();
// Only the active seat for its focused surface surfaces IME state.
var surface = seat.FocusedSurface;
if (surface == null
|| !owner._surfaces.TryGetValue(surface, out var ps)
|| ps.EnteredSeats.Count == 0
|| ps.EnteredSeats[ps.EnteredSeats.Count - 1] != seat)
{
if (Logger.TryGet(LogEventLevel.Verbose, "Wayland.TextInput") is { } log)
log.Log(this, "Dropping IME 'done' from non-active seat {Seat}", seat.GlobalName);
return;
}
var sink = ps.Sink;
// Per spec (zwp_text_input_v3.done): "The serial number reflects
// the last state of zwp_text_input_v3.commit request. The client
// is expected to compare this serial with the most recent commit's
// serial it has sent. If they don't match the client must ignore
// the changes." Every enable/disable/state push we make ends in a
// commit() that bumps commit_count, so a stale serial means the
// batch was generated against state we've already superseded
// (e.g. a prior focus/client session). Drop the whole batch.
if (serial != seat.CommitSerial)
{
if (Logger.TryGet(LogEventLevel.Verbose, "Wayland.TextInput") is { } staleLog)
staleLog.Log(this,
"Dropping stale text-input-v3 done batch (done.serial={Serial}, commit_count={Commit})",
serial, seat.CommitSerial);
return;
}
// Spec event order on the client side:
// 1. clear current preedit (implicit before commit/delete);
// 2. apply delete_surrounding_text;
// 3. insert commit text;
// 4. apply new preedit.
// We deliver all four operations to the UI thread as a SINGLE
// proxy call so they apply atomically (no other UI work can
// interleave between them).
var commitText = pendingCommit.Length > 0 ? pendingCommit : null;
var hasCommit = commitText != null;
int deleteBeforeChars = 0, deleteAfterChars = 0;
if (hasDelete && ps.SupportsSurroundingText)
(deleteBeforeChars, deleteAfterChars) = ConvertDeleteToChars(
ps, pendingDeleteBefore, pendingDeleteAfter);
// Compute new preedit (null = clear).
int beginChar, endChar;
if (pendingPreeditBeginByte < 0 || pendingPreeditEndByte < 0)
{
beginChar = endChar = -1;
}
else
{
beginChar = WaylandTextUtils.CharIndexFromUtf8Offset(pendingPreedit, pendingPreeditBeginByte, 0);
endChar = WaylandTextUtils.CharIndexFromUtf8Offset(pendingPreedit, pendingPreeditEndByte, 0);
}
var newPreedit = pendingPreedit.Length == 0 ? null : pendingPreedit;
var preeditUnchanged = seat.LastDeliveredPreedit == newPreedit
&& seat.LastDeliveredCursorBeginChar == beginChar
&& seat.LastDeliveredCursorEndChar == endChar;
// Skip the entire batch only when nothing observable happens: no
// commit, no delete, and the preedit is byte-for-byte identical
// to what we last sent.
var hasObservableDelete = deleteBeforeChars > 0 || deleteAfterChars > 0;
if (!hasCommit && !hasObservableDelete && preeditUnchanged)
return;
sink.OnImeUpdate(
ps.Surface.TextInputSessionToken,
deleteBeforeChars, deleteAfterChars,
commitText,
newPreedit, beginChar, endChar);
seat.LastDeliveredPreedit = newPreedit;
seat.LastDeliveredCursorBeginChar = beginChar;
seat.LastDeliveredCursorEndChar = endChar;
}
private static (int beforeChars, int afterChars) ConvertDeleteToChars(PerSurface ps, uint beforeBytes, uint afterBytes)
{
// No surrounding-text snapshot means we can't safely convert the
// protocol's UTF-8 byte counts to UTF-16 char counts (passing the
// bytes through would be wrong for any non-ASCII text). The IME
// shouldn't issue delete_surrounding_text without us first sending
// set_surrounding_text, so just drop.
if (ps.SurroundingText is null)
return (0, 0);
var cursorChar = ps.SurroundingCursorChar;
var beforeStartChar = WaylandTextUtils.CharIndexFromUtf8Offset(ps.SurroundingText, -(int)beforeBytes, cursorChar);
var afterEndChar = WaylandTextUtils.CharIndexFromUtf8Offset(ps.SurroundingText, (int)afterBytes, cursorChar);
return (cursorChar - beforeStartChar, afterEndChar - cursorChar);
}
private static WSurface? ResolveSurface(WlSurface? wlSurface)
{
if (wlSurface == null)
return null;
if (wlSurface.Tags.TryGetValue(typeof(WXdgShellSurface), out var tag))
return (WSurface)tag;
return null;
}
}
}

66
src/Avalonia.Wayland/Server/Transient/XdgToplevelExport.cs

@ -0,0 +1,66 @@
using System;
using System.Threading.Tasks;
using Avalonia.Wayland.Server.Persistent;
using NWayland;
using NWayland.Protocols.Wayland;
using NWayland.Protocols.XdgForeignUnstableV2;
namespace Avalonia.Wayland.Server.Transient;
/// <summary>
/// Worker-thread handle to a single <c>zxdg_exported_v2</c> export of a wayland
/// toplevel. Implements the UI-facing <see cref="IWaylandXdgTopLevelExport"/>
/// directly so it can be returned through the cross-thread proxy: <see cref="IWaylandXdgTopLevelExport.HandleTask"/>
/// is just a TCS that the worker completes when the compositor delivers the handle event,
/// and Dispose hops back to the worker thread for the actual destroy.
/// </summary>
internal sealed class XdgToplevelExport : IDisposable
{
private readonly WXdgTopLevel _owner;
private readonly WaylandWorker _worker;
private readonly TaskCompletionSource<string?> _handleTcs =
new(TaskCreationOptions.RunContinuationsAsynchronously);
private ZxdgExportedV2? _exported;
private bool _alive = true;
internal XdgToplevelExport(WXdgTopLevel owner, WaylandWorker worker, ZxdgExporterV2 exporter,
WlSurface surface, WlEventQueue queue)
{
_owner = owner;
_worker = worker;
_exported = exporter.ExportToplevel(surface, new ExportListener(this), queue);
}
public IWaylandXdgTopLevelExport GetUiThreadHandle() => new UiThreadHandle(this);
class UiThreadHandle(XdgToplevelExport parent) : IWaylandXdgTopLevelExport
{
public Task<string?> HandleTask => parent._handleTcs.Task;
public void Dispose() => parent._worker.PostOob(parent.Dispose);
}
public void Dispose()
{
if (!_alive)
return;
_alive = false;
_exported?.Destroy();
_exported = null;
_owner.RemoveExport(this);
// Unblock any awaiter that's still waiting on a handle event we'll never get.
_handleTcs.TrySetResult(null);
}
private void OnHandle(string handle)
{
if (!_alive)
return;
_handleTcs.TrySetResult(handle);
}
private sealed class ExportListener(XdgToplevelExport self) : ZxdgExportedV2.Listener
{
protected override void Handle(ZxdgExportedV2 eventSender, string handle) => self.OnHandle(handle);
}
}

19
src/Avalonia.Wayland/Server/WaylandDispatchPriority.cs

@ -0,0 +1,19 @@
namespace Avalonia.Wayland.Server;
/// <summary>
/// Priority for cross-thread dispatch between UI and Wayland threads.
/// </summary>
internal enum WaylandDispatchPriority
{
/// <summary>
/// UI→worker: batched with the next compositor commit.
/// Worker→UI: posted at default dispatcher priority.
/// </summary>
Normal,
/// <summary>
/// UI→worker: out-of-band, processed immediately by the worker.
/// Worker→UI: posted at Send dispatcher priority (highest).
/// </summary>
Oob
}

17
src/Avalonia.Wayland/Server/WaylandMarshallers.cs

@ -0,0 +1,17 @@
using System;
using Avalonia.Threading;
namespace Avalonia.Wayland.Server;
/// <summary>
/// Marshallers used by generated cross-thread proxies.
/// </summary>
/// <remarks>
/// The UI-thread marshaller is stateless and exposed as a singleton. The
/// worker-thread marshaller is per-worker and exposed via
/// <see cref="WaylandWorkerClient.Marshaller"/> — there is no global worker.
/// </remarks>
internal static class WaylandMarshallers
{
public static Action<Action, DispatcherPriority> UIThread { get; } = Dispatcher.UIThread.Post;
}

54
src/Avalonia.Wayland/Server/WaylandPlatformGraphics.cs

@ -0,0 +1,54 @@
using System;
using Avalonia.Platform;
using Avalonia.Platform.Surfaces;
using Avalonia.Wayland.Server.Persistent;
namespace Avalonia.Wayland.Server;
class WaylandPlatformGraphics : IPlatformGraphicsWithFeatures, IPlatformGraphicsReadyStateFeature
{
private IWaylandGraphics? _inner;
public interface IWaylandGraphics : IDisposable
{
IPlatformGraphicsContext CreateContext();
IPlatformRenderSurface CreateRenderSurface(WSurface surface);
}
public bool UsesSharedContext => false;
public IPlatformGraphicsContext CreateContext() => (_inner ?? throw new InvalidOperationException()).CreateContext();
/// <summary>
/// Currently-bound backend, or <c>null</c> if none. Identity also
/// serves as a cache key for callers that need to invalidate
/// per-backend state across reconnects.
/// </summary>
public IWaylandGraphics? CurrentGraphics => _inner;
public IPlatformGraphicsContext GetSharedContext() => throw new NotSupportedException();
public object? TryGetFeature(Type featureType)
{
if (featureType == typeof(IPlatformGraphicsReadyStateFeature))
return this;
return null;
}
public bool IsReady { get; private set; }
public bool UsesContexts => _inner != null;
public void Reset()
{
IsReady = false;
_inner?.Dispose();
_inner = null;
}
public void Initialize(IWaylandGraphics? graphics)
{
_inner?.Dispose();
_inner = graphics;
IsReady = true;
}
}

147
src/Avalonia.Wayland/Server/WaylandWorker.RenderTimer.cs

@ -0,0 +1,147 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Threading;
using System.Timers;
using Avalonia.Logging;
using Avalonia.Rendering;
namespace Avalonia.Wayland.Server;
partial class WaylandWorker
{
private bool _renderLoopWakeupPending = false;
private ServerSignaler _renderLoopWakeupSignaler = null!;
private readonly Stopwatch _clock = Stopwatch.StartNew();
private TimeSpan? _renderLoopStarvedSince;
private readonly object _renderLoopStarvationLock = new object();
private readonly RenderLoopImpl _renderLoop = new RenderLoopImpl();
public IRenderLoop RenderLoop => _renderLoop;
// The target FPS for UI thread animations when Wayland compositor doesn't think
// that we should be rendering yet
private const int ThrottledUiThreadFps = 20;
private static readonly TimeSpan s_RenderLoopStarvationInterval = TimeSpan.FromSeconds(1.0 / ThrottledUiThreadFps);
private readonly System.Timers.Timer _renderLoopStarvationTimer = new System.Timers.Timer(s_RenderLoopStarvationInterval);
class RenderLoopImpl : IRenderLoop
{
private readonly List<IRenderLoopTask> _tasks = new();
private readonly List<IRenderLoopTask> _tasksCopy = new();
private int _inTick;
internal Action? WakeupCallback;
public bool RunsInBackground => true;
public void Add(IRenderLoopTask i)
{
lock (_tasks)
_tasks.Add(i);
Wakeup();
}
public void Remove(IRenderLoopTask i)
{
lock (_tasks)
_tasks.Remove(i);
}
public void Wakeup()
{
WakeupCallback?.Invoke();
}
public void DoTick()
{
if (Interlocked.CompareExchange(ref _inTick, 1, 0) != 0)
return;
try
{
lock (_tasks)
{
_tasksCopy.Clear();
_tasksCopy.AddRange(_tasks);
}
for (int i = 0; i < _tasksCopy.Count; i++)
_tasksCopy[i].Render();
_tasksCopy.Clear();
}
finally
{
Interlocked.Exchange(ref _inTick, 0);
}
}
}
public void WakeupRenderLoop() => _renderLoopWakeupPending = true;
public void AnyThreadWakeupRenderLoop() => _renderLoopWakeupSignaler.Signal();
void InitRenderTimer()
{
_renderLoop.WakeupCallback = () =>
{
WakeupRenderLoop();
_wakeupFd.Set();
};
Compositor.AfterCommit += delegate
{
if (_hasPendingServerJobs)
{
_hasPendingServerJobs = false;
AnyThreadWakeupRenderLoop();
}
lock (_renderLoopStarvationLock)
{
if (_renderLoopStarvedSince == null)
{
_renderLoopStarvedSince = _clock.Elapsed;
_renderLoopStarvationTimer.Enabled = true;
}
}
_wakeupFd.Set();
};
_renderLoopWakeupSignaler = new ServerSignaler(this, WakeupRenderLoop);
_renderLoopStarvationTimer.Elapsed += delegate { OnRenderLoopStarved(); };
}
private void OnRenderLoopStarved()
{
lock (_renderLoopStarvationLock)
{
if (_renderLoopStarvedSince.HasValue &&
_renderLoopStarvedSince.Value + s_RenderLoopStarvationInterval < _clock.Elapsed)
AnyThreadWakeupRenderLoop();
}
}
private void TickRenderLoopIfNeeded()
{
if (_renderLoopWakeupPending)
{
lock (_renderLoopStarvationLock)
{
_renderLoopStarvedSince = null;
_renderLoopStarvationTimer.Stop();
}
_renderLoopWakeupPending = false;
try
{
_renderLoop.DoTick();
}
catch (Exception ex)
{
AnyThreadWakeupRenderLoop();
Logger.TryGet(LogEventLevel.Error, LogArea.Visual)?.Log(this, "Exception in render loop: {Error}", ex);
}
}
}
}

316
src/Avalonia.Wayland/Server/WaylandWorker.cs

@ -0,0 +1,316 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
using Avalonia.Logging;
using Avalonia.Rendering;
using Avalonia.Rendering.Composition;
using Avalonia.Wayland.Screens;
using Avalonia.Wayland.Server.Interop;
using Avalonia.Wayland.Server.Persistent;
using Avalonia.Wayland.Server.Transient;
using NWayland;
using NWayland.Interop;
using NWayland.Protocols.Wayland;
namespace Avalonia.Wayland.Server;
partial class WaylandWorker
{
private Queue<Action> _queue = new();
private readonly WakeupFd _wakeupFd = new();
private volatile bool _hasPendingServerJobs;
private WaylandConnection? _connection;
private WaylandGlobals? _globals;
private WaylandOutputsSinkProxy? _outputsSink;
public WaylandGlobals? Globals => _globals;
public WaylandPlatformGraphics PlatformGraphics { get; } = new();
private Thread? _thread;
public IRawEventGrouperDispatchQueue InputDispatchQueue { get; }
public Compositor Compositor { get; }
public WaylandWorkerClient Client { get; }
private HashSet<IPersistentWaylandObject> _persistentObjects = new();
private HashSet<IPersistentWaylandObject> _connectedObjects = new();
public WaylandWorker(IRawEventGrouperDispatchQueue inputDispatchQueue)
{
InputDispatchQueue = inputDispatchQueue;
Compositor = new Compositor(_renderLoop, PlatformGraphics);
Client = new WaylandWorkerClient(this);
InitRenderTimer();
}
/// <summary>
/// Posts a callback to the wayland thread alongside with the current compositor batch.
/// The order of execution of such callbacks and callbacks submitted via different means is UNDEFINED.
/// </summary>
public void PostWithCommit(Action cb)
{
_hasPendingServerJobs = true;
Compositor.PostServerJob(cb);
}
/// <summary>
/// Posts a callback to the wayland thread queue directly, bypassing the compositor batch.
/// Use for rare out-of-band messages that need immediate delivery.
/// </summary>
public void PostOob(Action cb)
{
lock (_queue)
_queue.Enqueue(cb);
_wakeupFd.Set();
}
/// <summary>
/// Posts a callback to the wayland thread queue out-of-band (bypassing the compositor batch)
/// and returns a task that indicates its completion.
/// </summary>
public Task<T> InvokeOobAsync<T>(Func<T> cb)
{
var tcs = new TaskCompletionSource<T>(TaskCreationOptions.RunContinuationsAsynchronously);
PostOob(() =>
{
try
{
var result = cb();
tcs.SetResult(result);
}
catch (Exception ex)
{
tcs.SetException(ex);
}
});
return tcs.Task;
}
/// <summary>
/// Posts a callback to the wayland thread queue out-of-band (bypassing the compositor batch)
/// and returns a task that completes when the callback finishes.
/// Unlike <see cref="InvokeOobAsync{T}(Func{T})"/>, this does not return a value.
/// </summary>
public Task InvokeOobAsync(Action cb)
{
var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
PostOob(() =>
{
try
{
cb();
tcs.SetResult();
}
catch (Exception ex)
{
tcs.SetException(ex);
}
});
return tcs.Task;
}
void Run(WaylandPlatformOptions options, WaylandConnection connection)
{
var reconnectsDisabled = options.EnableReconnects == false || options.DisplayFd.HasValue;
while (true)
{
RunConnection(connection, options);
foreach (var obj in _persistentObjects.ToList())
obj.OnDisconnected();
Compositor.Server.ResetAllGpuResources();
_globals?.Dispose();
_globals = null;
Logger.TryGet(LogEventLevel.Error, "Wayland")?.Log(null, "Wayland connection lost");
if (reconnectsDisabled)
throw new AvaloniaWaylandException("Connection lost");
connection.Dispose();
while (true)
{
var probe = Probe(options);
if (probe == null)
Thread.Sleep(1000);
else
{
connection = probe;
break;
}
}
}
}
void RunQueue()
{
while (true)
{
Action cb;
lock (_queue)
{
if (!_queue.TryDequeue(out cb!))
return;
}
cb();
}
}
void RunConnection(WaylandConnection connection, WaylandPlatformOptions options)
{
_thread = Thread.CurrentThread;
_connection = connection;
_globals = new WaylandGlobals(_connection, this, options, _outputsSink);
foreach (var obj in _persistentObjects)
ConnectPersistentObject(obj);
WakeupRenderLoop();
Compositor.Server.InvalidateAllCompositionTargets();
while (true)
{
var res = connection.DispatchQueueOrWakeup(connection.Queue, _wakeupFd.PollFd);
if (res == WaylandConnection.DispatchResult.ConnectionReset)
return;
if (res == WaylandConnection.DispatchResult.Wakeup)
_wakeupFd.Clear();
RunQueue();
TickRenderLoopIfNeeded();
}
}
class Tracer : INWaylandTracer
{
// Dedicated log area so protocol tracing can be enabled independently of
// the platform's normal verbose ("Wayland") traffic.
public const string TraceArea = "WaylandProtocol";
string GetProxyString(WlProxy proxy)
{
// Handle throws once the proxy is disposed; tracing must stay best-effort.
var handle = proxy.IsDisposed ? IntPtr.Zero : proxy.Handle;
return $"{proxy.GetType().Name}({handle:x8}/{proxy.Id})";
}
string PrintArg(WlMessageDescription method, WlTracedArgument arg, int index)
{
var code = method.Arguments[index].Code;
return code switch
{
WaylandArgumentCodes.Object when arg.Object is WlProxy proxy => GetProxyString(proxy),
WaylandArgumentCodes.String => arg.Object?.ToString() ?? "null",
WaylandArgumentCodes.Int32 or WaylandArgumentCodes.Fd => arg.Int32.ToString(),
WaylandArgumentCodes.UInt32 or WaylandArgumentCodes.NewId => arg.UInt32.ToString(),
WaylandArgumentCodes.Fixed => ((double)arg.Fixed).ToString(),
_ => arg.Object?.ToString() ?? "null"
};
}
public void Trace(WlProxy sender, bool isEvent, bool isDestructor, WlMessageDescription method, ReadOnlySpan<WlTracedArgument> args)
{
if (Logger.TryGet(LogEventLevel.Verbose, TraceArea) is not { } logger)
return;
var argStrings = new string[args.Length];
for (var i = 0; i < args.Length; i++)
argStrings[i] = PrintArg(method, args[i], i);
logger.Log(null, "{Message}",
$"{GetProxyString(sender)}-{(isEvent ? '<' : '>')}{(isDestructor ? " (D)" : "")}-{method.Name}({string.Join(", ", argStrings)})");
}
public void TraceDestroy(WlProxy proxy, bool isFromFinalizer, bool nativeCallSkipped)
{
if (isFromFinalizer)
Logger.TryGet(LogEventLevel.Verbose, TraceArea)?.Log(null, "{Message}", $"{GetProxyString(proxy)} IS FINALIZED");
}
}
public static WaylandConnection? Probe(WaylandPlatformOptions options)
{
try
{
var conn = options.DisplayFd is { } fd
? new WaylandConnection(fd)
: new WaylandConnection(options.WlDisplayName);
if (Logger.TryGet(LogEventLevel.Verbose, Tracer.TraceArea) != null)
conn.Display.Tracer = new Tracer();
return conn;
}
catch(Exception e)
{
Logger.TryGet(LogEventLevel.Error, "Wayland")?.Log(null, "Failed to connect to Wayland display: " + e);
return null;
}
}
public void Start(WaylandPlatformOptions options, WaylandConnection probedConnection,
WaylandOutputsSinkProxy? outputsSink = null)
{
_outputsSink = outputsSink;
StartCore(options, probedConnection, null);
}
public void Start(WaylandPlatformOptions options, WlDisplay foreignDisplay,
WaylandOutputsSinkProxy? outputsSink = null)
{
_outputsSink = outputsSink;
StartCore(options, null, foreignDisplay);
}
void StartCore(WaylandPlatformOptions options, WaylandConnection? probedConnection, WlDisplay? foreignDisplay)
{
new Thread(() =>
{
if(probedConnection!=null)
Run(options, probedConnection);
else if (foreignDisplay != null)
{
RunConnection(new WaylandConnection(foreignDisplay), options);
// This is fatal
throw new AvaloniaWaylandException("Connection lost");
}
else
throw new InvalidOperationException("Either probedConnection or foreignDisplay must be provided.");
})
{
Name = "AvaloniaWayland",
IsBackground = true
}.Start();
}
void VerifyAccess()
{
if(_thread != Thread.CurrentThread)
throw new AvaloniaWaylandException("Call from invalid thread");
}
void ConnectPersistentObject(IPersistentWaylandObject waylandObject)
{
if (_connection == null || _globals == null)
throw new InvalidOperationException();
waylandObject.OnConnected(_connection, _globals);
_connectedObjects.Add(waylandObject);
}
public void RegisterPersistentObject(IPersistentWaylandObject waylandObject)
{
VerifyAccess();
_persistentObjects.Add(waylandObject);
if (_connection?.IsConnected == true)
ConnectPersistentObject(waylandObject);
}
public void UnregisterPersistentObject(IPersistentWaylandObject waylandObject)
{
VerifyAccess();
if (_connectedObjects.Remove(waylandObject))
waylandObject.OnDisconnected();
_persistentObjects.Remove(waylandObject);
}
}

123
src/Avalonia.Wayland/Server/WaylandWorkerClient.cs

@ -0,0 +1,123 @@
using System;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Input.Raw;
using Avalonia.Rendering.Composition;
using Avalonia.Wayland.Server.Persistent;
namespace Avalonia.Wayland.Server;
/// <summary>
/// UI-thread-safe interface to the wayland worker.
/// UI thread code should use this instead of accessing WaylandWorker directly.
/// </summary>
class WaylandWorkerClient
{
private readonly WaylandWorker _worker;
internal WaylandWorkerClient(WaylandWorker worker)
{
_worker = worker;
Compositor = worker.Compositor;
InputDispatchQueue = worker.InputDispatchQueue;
AnyThreadWakeupRenderLoop = worker.AnyThreadWakeupRenderLoop;
Marshaller = (action, priority) =>
{
if (priority == WaylandDispatchPriority.Oob)
worker.PostOob(action);
else
worker.PostWithCommit(action);
};
}
public Compositor Compositor { get; }
/// <summary>Shared platform-wide input-dispatch queue (any-thread safe).</summary>
public IRawEventGrouperDispatchQueue InputDispatchQueue { get; }
/// <summary>
/// Wakes the wayland worker's render loop. Documented as safe to call
/// from any thread (see <see cref="WaylandWorker.AnyThreadWakeupRenderLoop"/>).
/// </summary>
public Action AnyThreadWakeupRenderLoop { get; }
/// <summary>
/// Marshaller for UI→worker cross-thread proxies (see
/// <c>CrossThreadProxyGenerator</c>). Bound to this worker instance —
/// no global state.
/// </summary>
public Action<Action, WaylandDispatchPriority> Marshaller { get; }
/// <summary>
/// Posts a rare out-of-band callback directly to the wayland thread queue.
/// </summary>
public void PostOob(Action cb) => _worker.PostOob(cb);
/// <summary>
/// Posts a callback to the wayland thread batched with the current compositor commit.
/// </summary>
public void PostWithCommit(Action cb) => _worker.PostWithCommit(cb);
/// <summary>
/// Posts a callback to the wayland thread queue out-of-band (bypassing the compositor batch)
/// and returns a task that indicates its completion.
/// </summary>
public Task<T> InvokeOobAsync<T>(Func<T> cb) => _worker.InvokeOobAsync(cb);
/// <summary>
/// Creates a UI-thread wrapper for a standard themed cursor. The wrapper must be released via
/// <see cref="WaylandCursorProxy.Destroy()"/> (e.g. <see cref="WaylandCursorImpl.Dispose"/>).
/// </summary>
public WaylandCursorProxy CreateStandardCursor(Input.StandardCursorType cursorType)
=> new WaylandCursorProxy(new WaylandStandardCursor(cursorType), Marshaller);
/// <summary>
/// Creates a persistent worker-side custom bitmap cursor and returns a UI-thread wrapper for it.
/// <paramref name="pixels"/> must be tightly-packed Bgra8888 premultiplied data
/// (stride == <c>size.Width * 4</c>). The returned wrapper must be released via
/// <see cref="WaylandCursorProxy.Destroy()"/> (e.g. <see cref="WaylandCursorImpl.Dispose"/>).
/// </summary>
public WaylandCursorProxy CreateBitmapCursor(byte[] pixels, PixelSize size, int hotspotX, int hotspotY)
=> new WaylandCursorProxy(new WaylandBitmapCursor(_worker, pixels, size, hotspotX, hotspotY), Marshaller);
/// <summary>
/// Creates a new xdg_toplevel surface and returns the bundle of UI-thread
/// accessors needed to drive it. The underlying <see cref="WXdgTopLevel"/>
/// is worker-thread state and is intentionally not exposed.
/// </summary>
public WaylandSurfaceCreateResult<WXdgTopLevelProxy> CreateTopLevelHandle(WXdgTopLevelEventSinkProxy sink)
{
var topLevel = new WXdgTopLevel(_worker, sink);
var proxy = new WXdgTopLevelProxy(topLevel, Marshaller);
return new WaylandSurfaceCreateResult<WXdgTopLevelProxy>(
Proxy: proxy,
GetRenderSurfaces: () => topLevel.RenderSurfaces,
BasicInitCompleted: topLevel.BasicInitCompleted);
}
/// <summary>
/// Creates a new xdg_popup surface parented to <paramref name="parent"/>
/// (a <see cref="WXdgTopLevelProxy"/> / <see cref="WXdgPopupProxy"/> /
/// other <see cref="WXdgShellSurfaceProxy"/> belonging to this worker).
/// Throws if <paramref name="parent"/> doesn't unwrap to a known
/// worker-side parent surface. The popup isn't actually mapped until
/// the UI side calls <see cref="IWXdgPopup.UpdatePositioner"/> at least
/// once and a buffer is attached.
/// </summary>
public WaylandSurfaceCreateResult<WXdgPopupProxy> CreatePopupHandle(
WXdgPopupEventSinkProxy sink,
WXdgShellSurfaceProxy parent)
{
var popup = new WXdgPopup(_worker, sink, (WXdgShellSurface)parent.ProxyTarget);
var proxy = new WXdgPopupProxy(popup, Marshaller);
// Popups don't currently surface a "basic init" task to the UI
// side: the UI driver waits on the first OnPopupConfigure delivered
// through the event sink instead. We therefore complete the task
// immediately with an empty configure batch placeholder so the
// shared WaylandSurfaceCreateResult<T> shape is preserved.
return new WaylandSurfaceCreateResult<WXdgPopupProxy>(
Proxy: proxy,
GetRenderSurfaces: () => popup.RenderSurfaces,
BasicInitCompleted: Task.FromResult(new XdgConfigureBatch()));
}
}

79
src/Avalonia.Wayland/TextInputOptionsConverter.cs

@ -0,0 +1,79 @@
using Avalonia.Input.TextInput;
using NWayland.Protocols.TextInputUnstableV3;
namespace Avalonia.Wayland;
/// <summary>
/// Maps Avalonia <see cref="TextInputOptions"/> onto the
/// <c>zwp_text_input_v3</c> <c>(content_hint, content_purpose)</c> pair.
/// </summary>
internal static class TextInputOptionsConverter
{
public static (ZwpTextInputV3.ContentHintEnum hint, ZwpTextInputV3.ContentPurposeEnum purpose)
Convert(TextInputOptions options)
{
var hint = ZwpTextInputV3.ContentHintEnum.None;
var purpose = ZwpTextInputV3.ContentPurposeEnum.Normal;
switch (options.ContentType)
{
case TextInputContentType.Normal:
break;
case TextInputContentType.Alpha:
hint |= ZwpTextInputV3.ContentHintEnum.Latin;
break;
case TextInputContentType.Digits:
purpose = ZwpTextInputV3.ContentPurposeEnum.Digits;
break;
case TextInputContentType.Pin:
purpose = ZwpTextInputV3.ContentPurposeEnum.Pin;
break;
case TextInputContentType.Number:
purpose = ZwpTextInputV3.ContentPurposeEnum.Number;
break;
case TextInputContentType.Email:
purpose = ZwpTextInputV3.ContentPurposeEnum.Email;
break;
case TextInputContentType.Url:
purpose = ZwpTextInputV3.ContentPurposeEnum.Url;
break;
case TextInputContentType.Name:
purpose = ZwpTextInputV3.ContentPurposeEnum.Name;
break;
case TextInputContentType.Password:
purpose = ZwpTextInputV3.ContentPurposeEnum.Password;
hint |= ZwpTextInputV3.ContentHintEnum.HiddenText
| ZwpTextInputV3.ContentHintEnum.SensitiveData;
break;
case TextInputContentType.Social:
case TextInputContentType.Search:
break;
}
if (options.Multiline)
hint |= ZwpTextInputV3.ContentHintEnum.Multiline;
if (options.IsSensitive)
hint |= ZwpTextInputV3.ContentHintEnum.SensitiveData;
if (options.Lowercase)
hint |= ZwpTextInputV3.ContentHintEnum.Lowercase;
if (options.Uppercase)
hint |= ZwpTextInputV3.ContentHintEnum.Uppercase;
if (options.AutoCapitalization)
hint |= ZwpTextInputV3.ContentHintEnum.AutoCapitalization;
if (options.ShowSuggestions == true)
hint |= ZwpTextInputV3.ContentHintEnum.Completion
| ZwpTextInputV3.ContentHintEnum.Spellcheck;
// Password always overrides: even with ShowSuggestions=true, never reveal text.
if (options.ContentType == TextInputContentType.Password)
{
hint &= ~(ZwpTextInputV3.ContentHintEnum.Completion
| ZwpTextInputV3.ContentHintEnum.Spellcheck);
hint |= ZwpTextInputV3.ContentHintEnum.HiddenText
| ZwpTextInputV3.ContentHintEnum.SensitiveData;
}
return (hint, purpose);
}
}

103
src/Avalonia.Wayland/WaylandConversionExtensions.cs

@ -0,0 +1,103 @@
using System;
using Avalonia.Controls.Primitives.PopupPositioning;
using Avalonia.Wayland.Server.Persistent;
using NWayland.Protocols.XdgShell;
namespace Avalonia.Wayland;
/// <summary>
/// Conversions between Avalonia and Wayland-protocol types. Mirrors the
/// per-platform <c>ToAvalonia</c>/<c>ToWayland</c>/<c>ToWin32</c>/etc.
/// extension-method pattern used by the other backends.
/// </summary>
internal static class WaylandConversionExtensions
{
/// <summary>
/// Translates Avalonia's <see cref="PopupPositionerParameters"/> (used
/// by <see cref="IPopupPositioner.Update"/>) into the worker-side
/// <see cref="XdgPopupPositionerParams"/>.
///
/// Coordinate convention: <see cref="PopupPositionerParameters.AnchorRectangle"/>
/// arrives in the parent's client-area logical coordinates (consistent with
/// <see cref="ManagedPopupPositioner"/>'s usage of "parent client area"). On
/// Wayland CSD surfaces the entire buffer is the "client area" — including
/// the shadow margins — so the rect is passed through as buffer-relative
/// logical coords. The worker-side <see cref="WXdgPopup"/> performs the
/// buffer→geometry origin shift and clamps the rect into the parent's
/// window-geometry rectangle (see <see cref="XdgPopupPositionerParams"/>).
/// </summary>
public static XdgPopupPositionerParams ToWayland(this PopupPositionerParameters parameters) =>
new(
Size: parameters.Size,
AnchorRect: parameters.AnchorRectangle,
Anchor: parameters.Anchor.ToWayland(),
Gravity: parameters.Gravity.ToWayland(),
ConstraintAdjustment: parameters.ConstraintAdjustment.ToWayland(),
Offset: parameters.Offset);
/// <summary>
/// <see cref="PopupAnchor"/> is a bitfield (Top=1, Bottom=2, Left=4, Right=8,
/// corners are bit combinations); the protocol's <see cref="XdgPositioner.AnchorEnum"/>
/// is a dense enum (None=0..BottomRight=8). Translate explicitly per
/// combination rather than reinterpret-casting.
/// </summary>
public static XdgPositioner.AnchorEnum ToWayland(this PopupAnchor a) => a switch
{
PopupAnchor.None => XdgPositioner.AnchorEnum.None,
PopupAnchor.Top => XdgPositioner.AnchorEnum.Top,
PopupAnchor.Bottom => XdgPositioner.AnchorEnum.Bottom,
PopupAnchor.Left => XdgPositioner.AnchorEnum.Left,
PopupAnchor.Right => XdgPositioner.AnchorEnum.Right,
PopupAnchor.TopLeft => XdgPositioner.AnchorEnum.TopLeft,
PopupAnchor.TopRight => XdgPositioner.AnchorEnum.TopRight,
PopupAnchor.BottomLeft => XdgPositioner.AnchorEnum.BottomLeft,
PopupAnchor.BottomRight => XdgPositioner.AnchorEnum.BottomRight,
_ => throw new ArgumentOutOfRangeException(
nameof(a), a, $"Unsupported PopupAnchor combination: {a}"),
};
/// <summary>
/// Same shape concern as <see cref="ToWayland(PopupAnchor)"/>:
/// <see cref="PopupGravity"/> is a bitfield, <see cref="XdgPositioner.GravityEnum"/>
/// is dense.
/// </summary>
public static XdgPositioner.GravityEnum ToWayland(this PopupGravity g) => g switch
{
PopupGravity.None => XdgPositioner.GravityEnum.None,
PopupGravity.Top => XdgPositioner.GravityEnum.Top,
PopupGravity.Bottom => XdgPositioner.GravityEnum.Bottom,
PopupGravity.Left => XdgPositioner.GravityEnum.Left,
PopupGravity.Right => XdgPositioner.GravityEnum.Right,
PopupGravity.TopLeft => XdgPositioner.GravityEnum.TopLeft,
PopupGravity.TopRight => XdgPositioner.GravityEnum.TopRight,
PopupGravity.BottomLeft => XdgPositioner.GravityEnum.BottomLeft,
PopupGravity.BottomRight => XdgPositioner.GravityEnum.BottomRight,
_ => throw new ArgumentOutOfRangeException(
nameof(g), g, $"Unsupported PopupGravity combination: {g}"),
};
/// <summary>
/// <see cref="PopupPositionerConstraintAdjustment"/> currently has the
/// same numeric layout as <see cref="XdgPositioner.ConstraintAdjustmentEnum"/>,
/// but that's not a contractual guarantee — translate flag-by-flag
/// rather than reinterpret-casting.
/// </summary>
public static XdgPositioner.ConstraintAdjustmentEnum ToWayland(
this PopupPositionerConstraintAdjustment a)
{
var r = XdgPositioner.ConstraintAdjustmentEnum.None;
if ((a & PopupPositionerConstraintAdjustment.SlideX) != 0)
r |= XdgPositioner.ConstraintAdjustmentEnum.SlideX;
if ((a & PopupPositionerConstraintAdjustment.SlideY) != 0)
r |= XdgPositioner.ConstraintAdjustmentEnum.SlideY;
if ((a & PopupPositionerConstraintAdjustment.FlipX) != 0)
r |= XdgPositioner.ConstraintAdjustmentEnum.FlipX;
if ((a & PopupPositionerConstraintAdjustment.FlipY) != 0)
r |= XdgPositioner.ConstraintAdjustmentEnum.FlipY;
if ((a & PopupPositionerConstraintAdjustment.ResizeX) != 0)
r |= XdgPositioner.ConstraintAdjustmentEnum.ResizeX;
if ((a & PopupPositionerConstraintAdjustment.ResizeY) != 0)
r |= XdgPositioner.ConstraintAdjustmentEnum.ResizeY;
return r;
}
}

52
src/Avalonia.Wayland/WaylandCursorFactory.cs

@ -0,0 +1,52 @@
using System;
using Avalonia.Input;
using Avalonia.Media.Imaging;
using Avalonia.Platform;
using Avalonia.Wayland.Server;
using Avalonia.Wayland.Server.Persistent;
namespace Avalonia.Wayland;
class WaylandCursorFactory : ICursorFactory
{
private readonly WaylandWorkerClient _client;
public WaylandCursorFactory(WaylandWorkerClient client)
{
_client = client;
}
public ICursorImpl GetCursor(StandardCursorType cursorType)
=> new WaylandCursorImpl(_client.CreateStandardCursor(cursorType));
public unsafe ICursorImpl CreateCursor(Bitmap cursor, PixelPoint hotSpot)
{
// Extract the cursor pixels into a tightly-packed Bgra8888 premultiplied buffer on the UI
// thread (CopyPixels transcodes format + alpha). The worker thread then uploads this raw
// buffer into a wl_buffer without touching the UI-owned Bitmap.
var size = cursor.PixelSize;
var stride = size.Width * 4;
var pixels = new byte[stride * size.Height];
fixed (byte* ptr = pixels)
{
using var locked = new LockedFramebuffer((IntPtr)ptr, size, stride,
new Vector(96, 96), PixelFormats.Bgra8888, AlphaFormat.Premul, null);
cursor.CopyPixels(locked);
}
return new WaylandCursorImpl(_client.CreateBitmapCursor(pixels, size, hotSpot.X, hotSpot.Y));
}
}
class WaylandCursorImpl : ICursorImpl
{
/// <summary>UI-thread wrapper for the worker-side cursor (standard or custom bitmap).</summary>
public WaylandCursorProxy Cursor { get; }
public WaylandCursorImpl(WaylandCursorProxy cursor)
{
Cursor = cursor;
}
public void Dispose() => Cursor.Destroy();
}

62
src/Avalonia.Wayland/WaylandPlatform.cs

@ -0,0 +1,62 @@
using System.Collections.Generic;
using Avalonia.Controls.Platform;
using Avalonia.FreeDesktop;
using Avalonia.Input;
using Avalonia.Input.Platform;
using Avalonia.Platform;
using Avalonia.Rendering;
using Avalonia.Threading;
using Avalonia.Wayland.Clipboard;
using Avalonia.Wayland.Screens;
using Avalonia.Wayland.Server;
using Avalonia.X11;
namespace Avalonia.Wayland;
class WaylandPlatform
{
public static void Initialize(WaylandPlatformOptions options)
{
var connection = WaylandWorker.Probe(options);
if (connection == null)
throw new AvaloniaWaylandException("Unable to connect to Wayland display");
// TODO: wait for globals to be ready on the wayland thread and perform sanity checks too.
// This is needed for UsePlatformDetect() in Avalonia.Desktop: if we detect a compositor we
// can't support we should fail here so the caller can fall back to X11. Later we'll also want
// feature-detection flags (e.g. xdg-toplevel-drag-v1 for docks) so apps can request features
// that force an X11 fallback when the compositor lacks them.
var inputDispatchQueue = new AutomaticRawEventGrouperDispatchQueue();
Dispatcher.InitializeUIThreadDispatcher(new ManagedDispatcherImpl(null));
var worker = new WaylandWorker(inputDispatchQueue);
var screens = new SnapshotScreensImpl();
var screensProxy = new WaylandOutputsSinkProxy(screens, WaylandMarshallers.UIThread);
worker.Start(options, connection, screensProxy);
var clipboardImpl = new WaylandClipboardImpl(worker);
var clipboard = new Input.Platform.Clipboard(clipboardImpl);
AvaloniaLocator.CurrentMutable
.Bind<IWindowingPlatform>().ToConstant(new WaylandTopLevelFactory(worker.Client))
.Bind<IRenderLoop>().ToConstant(worker.RenderLoop)
.Bind<PlatformHotkeyConfiguration>().ToConstant(new PlatformHotkeyConfiguration(KeyModifiers.Control))
.Bind<KeyGestureFormatInfo>()
.ToConstant(new KeyGestureFormatInfo(new Dictionary<Key, string>() { }, meta: "Super"))
.Bind<IKeyboardDevice>().ToConstant(new KeyboardDevice())
.Bind<ICursorFactory>().ToConstant(new WaylandCursorFactory(worker.Client))
.Bind<IClipboardImpl>().ToConstant(clipboardImpl)
.Bind<IClipboard>().ToConstant(clipboard)
.Bind<IPlatformDragSource>().ToConstant(new WaylandDragSource())
.Bind<IPlatformSettings>().ToSingleton<DBusPlatformSettings>()
.Bind<IMountedVolumeInfoProvider>().ToConstant(new LinuxMountedVolumeInfoProvider())
.Bind<IPlatformIconLoader>().ToConstant(new X11IconLoader())
.Bind<IScreenImpl>().ToConstant(screens);
}
}

75
src/Avalonia.Wayland/WaylandPlatformOptions.cs

@ -0,0 +1,75 @@
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using Avalonia.OpenGL;
// XxxPlatformOptions are deliberately in global Avalonia namespace
// ReSharper disable once CheckNamespace
namespace Avalonia;
/// <summary>
/// Platform-specific options for the Wayland windowing backend, supplied via
/// <see cref="AvaloniaWaylandPlatformExtensions.UseWayland"/>.
/// </summary>
public class WaylandPlatformOptions
{
/// <summary>
/// The name of the Wayland display to connect to (e.g. <c>wayland-0</c>). When <c>null</c>,
/// the <c>WAYLAND_DISPLAY</c> environment variable is used. Ignored when <see cref="DisplayFd"/> is set.
/// </summary>
public string? WlDisplayName { get; set; }
/// <summary>
/// An already-opened file descriptor for the Wayland display socket.
/// When set, <see cref="WlDisplayName"/> is ignored and
/// <c>wl_display_connect_to_fd</c> is used instead of <c>wl_display_connect</c>.
/// Reconnects are automatically disabled in this mode because the fd
/// is consumed by <c>libwayland</c> and cannot be reused.
/// </summary>
public int? DisplayFd { get; set; }
/// <summary>
/// Whether to automatically attempt to reconnect to the compositor if the connection is lost.
/// Defaults to enabled. Reconnects are always disabled when <see cref="DisplayFd"/> is set, because
/// the file descriptor is consumed by <c>libwayland</c> and cannot be reused.
/// </summary>
public bool? EnableReconnects { get; set; }
/// <summary>
/// Suppresses server-side decoration negotiation
/// (<c>zxdg_decoration_manager_v1</c>): toplevels behave as if the
/// compositor never advertised SSD support. Used primarily for
/// testing the CSD path on compositors that would otherwise enforce
/// server-side decorations (KWin, etc.). Equivalent in intent to
/// <c>X11PlatformOptions.ForceDrawnDecorations</c>.
/// </summary>
[Experimental("AVALONIA_WAYLAND_FORCE_CSD"
#if NET10_0_OR_GREATER
, Message = "Experimental, used mostly for testing"
#endif
)]
public bool ForceDrawnDecorations { get; set; }
#pragma warning disable AVALONIA_WAYLAND_FORCE_CSD
internal bool ForceDrawnDecorationsInternal => ForceDrawnDecorations;
#pragma warning restore AVALONIA_WAYLAND_FORCE_CSD
/// <summary>
/// The OpenGL/OpenGL ES versions to try, in priority order, when creating the GL context.
/// The first profile the driver supports is used.
/// </summary>
public IList<GlVersion> GlProfiles { get; set; } = new List<GlVersion>
{
new GlVersion(GlProfileType.OpenGL, 4, 0),
new GlVersion(GlProfileType.OpenGL, 3, 2),
new GlVersion(GlProfileType.OpenGL, 3, 0),
new GlVersion(GlProfileType.OpenGLES, 3, 2),
new GlVersion(GlProfileType.OpenGLES, 3, 0),
new GlVersion(GlProfileType.OpenGLES, 2, 0)
};
/// <summary>
/// Whether to use a dmabuf-based swapchain for GPU rendering. When <c>null</c>, the backend
/// decides based on compositor and driver capabilities.
/// </summary>
public bool? UseDmabufSwapchain { get; set; }
}

31
src/Avalonia.Wayland/WaylandSurfaceCreateResult.cs

@ -0,0 +1,31 @@
using System;
using System.Threading.Tasks;
using Avalonia.Input.Raw;
using Avalonia.Platform;
using Avalonia.Platform.Surfaces;
using Avalonia.SourceGenerator;
using Avalonia.Wayland.Server.Persistent;
namespace Avalonia.Wayland;
/// <summary>
/// Result of creating a worker-side wayland surface from the UI thread.
/// Bundles the UI→worker proxy together with whatever extra UI-thread
/// accessors the caller needs to drive the surface.
/// </summary>
/// <typeparam name="T">
/// The concrete proxy subtype (e.g. <see cref="WXdgTopLevelProxy"/>) — all
/// such proxies derive from <see cref="WSurfaceProxy"/>.
/// </typeparam>
/// <remarks>
/// <see cref="GetRenderSurfaces"/> is a callback rather than a snapshot
/// because <c>ITopLevelImpl.Surfaces</c> is queried by the render thread,
/// not the UI thread; routing it through a callback avoids any need for the
/// UI side to cache (or even read) worker-thread state.
/// </remarks>
[DefinitelyNotARecord]
internal sealed partial class WaylandSurfaceCreateResult<T>(
T Proxy,
Func<IPlatformRenderSurface[]> GetRenderSurfaces,
Task<XdgConfigureBatch> BasicInitCompleted)
where T : WSurfaceProxy;

107
src/Avalonia.Wayland/WaylandTextUtils.cs

@ -0,0 +1,107 @@
using System;
using System.Text;
namespace Avalonia.Wayland;
/// <summary>
/// Helpers for converting between UTF-8 byte offsets (used by Wayland text-input
/// protocols) and UTF-16 char indices (used by .NET <see cref="string"/>).
/// </summary>
/// <remarks>
/// The Wayland text-input protocols address text by byte offset into the UTF-8
/// encoding of the string. Avalonia / .NET use UTF-16 internally. Conversion
/// must respect multi-byte sequences and surrogate pairs (a single non-BMP
/// codepoint is 4 bytes in UTF-8 and 2 chars in UTF-16) and must be robust to
/// negative offsets, out-of-range indices and partially-truncated sequences.
/// </remarks>
internal static class WaylandTextUtils
{
/// <summary>
/// Convert a wayland-side byte offset (relative to <paramref name="baseChar"/>)
/// into a UTF-16 char index in <paramref name="text"/>. A negative
/// <paramref name="byteLength"/> means "go backwards from baseChar".
/// Out-of-range offsets are clamped to <c>[0, text.Length]</c>.
/// </summary>
public static int CharIndexFromUtf8Offset(string text, int byteLength, int baseChar)
{
if (byteLength == 0)
return Math.Clamp(baseChar, 0, text.Length);
baseChar = Math.Clamp(baseChar, 0, text.Length);
if (byteLength < 0)
{
var prefix = text.AsSpan(0, baseChar);
var prefixBytes = System.Text.Encoding.UTF8.GetByteCount(prefix);
var targetBytes = Math.Max(prefixBytes + byteLength, 0);
return CountCharsForUtf8Bytes(prefix, targetBytes);
}
else
{
var suffix = text.AsSpan(baseChar);
var suffixBytes = System.Text.Encoding.UTF8.GetByteCount(suffix);
var targetBytes = Math.Min(byteLength, suffixBytes);
return baseChar + CountCharsForUtf8Bytes(suffix, targetBytes);
}
}
/// <summary>
/// Convert a UTF-16 char index <paramref name="charIndex"/> in
/// <paramref name="text"/> into a wayland-side byte offset, relative to
/// <paramref name="baseChar"/>. The returned value is the byte distance
/// between <paramref name="baseChar"/> and <paramref name="charIndex"/>;
/// it is negative when <paramref name="charIndex"/> &lt; <paramref name="baseChar"/>.
/// </summary>
public static int Utf8OffsetFromCharIndex(string text, int charIndex, int baseChar)
{
if (charIndex == baseChar)
return 0;
baseChar = Math.Clamp(baseChar, 0, text.Length);
charIndex = Math.Clamp(charIndex, 0, text.Length);
if (charIndex >= baseChar)
return System.Text.Encoding.UTF8.GetByteCount(text.AsSpan(baseChar, charIndex - baseChar));
return -System.Text.Encoding.UTF8.GetByteCount(text.AsSpan(charIndex, baseChar - charIndex));
}
/// <summary>
/// Convert an absolute char index to its absolute UTF-8 byte index in
/// <paramref name="text"/>. Equivalent to
/// <c>Utf8OffsetFromCharIndex(text, charIndex, 0)</c>.
/// </summary>
public static int Utf8ByteIndexFromCharIndex(string text, int charIndex)
{
charIndex = Math.Clamp(charIndex, 0, text.Length);
return System.Text.Encoding.UTF8.GetByteCount(text.AsSpan(0, charIndex));
}
/// <summary>
/// Walk the UTF-16 <paramref name="span"/> until either it is exhausted or
/// the cumulative UTF-8 byte count reaches <paramref name="targetBytes"/>.
/// Stops at a UTF-16 char boundary that lies on a UTF-8 codepoint boundary,
/// i.e. it never returns a position halfway through a surrogate pair.
/// </summary>
private static int CountCharsForUtf8Bytes(ReadOnlySpan<char> span, int targetBytes)
{
if (targetBytes <= 0)
return 0;
var bytes = 0;
var i = 0;
while (i < span.Length)
{
Rune.DecodeFromUtf16(span.Slice(i), out var rune, out var cpChars);
var cpBytes = rune.Utf8SequenceLength;
if (bytes + cpBytes > targetBytes)
break;
bytes += cpBytes;
i += cpChars;
}
return i;
}
}

53
src/Avalonia.Wayland/WaylandTopLevelFactory.cs

@ -0,0 +1,53 @@
using System;
using System.Linq;
using Avalonia.FreeDesktop;
using Avalonia.Platform;
using Avalonia.Wayland.Server;
using Avalonia.Wayland.Server.Persistent;
using Avalonia.X11;
namespace Avalonia.Wayland;
class WaylandTopLevelFactory : IWindowingPlatform
{
private readonly WaylandWorkerClient _client;
public WaylandTopLevelFactory(WaylandWorkerClient client)
{
_client = client;
}
public IWindowImpl CreateWindow() => new WindowImpl(_client);
public ITopLevelImpl CreateEmbeddableTopLevel() => throw new System.NotSupportedException();
public IWindowImpl CreateEmbeddableWindow() => throw new System.NotSupportedException();
public ITrayIconImpl? CreateTrayIcon()
{
// org.kde.StatusNotifierItem (works on KDE; on GNOME via the
// AppIndicator extension). No XEmbed fallback — XEmbed is X11-only,
// and there is no Wayland-native systray protocol. If the watcher
// service is not on the bus we return null and the framework falls
// back to its own no-op handling.
var dbusTrayIcon = new DBusTrayIconImpl();
if (!dbusTrayIcon.IsActive)
{
dbusTrayIcon.Dispose();
return null;
}
// DBusTrayIconImpl wants an IWindowIconImpl-to-uint[] flattener
// (X11 NET_WM_ICON wire format: [w, h, ARGB32 pixels…]) which our
// X11IconLoader-produced X11IconData already stores in. Reuse it.
dbusTrayIcon.IconConverterDelegate = static icon =>
icon is X11IconData x ? x.Data.Select(p => p.ToUInt32()).ToArray() : Array.Empty<uint>();
return dbusTrayIcon;
}
public void GetWindowsZOrder(ReadOnlySpan<IWindowImpl> windows, Span<long> zOrder)
{
// Z-order querying isn't supported on Wayland; zero the output so callers
// get a deterministic result instead of whatever the span previously held.
zOrder.Clear();
}
}

164
src/Avalonia.Wayland/WindowImpl.Sink.cs

@ -0,0 +1,164 @@
using System;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Input.Raw;
using Avalonia.Wayland.Server;
using Avalonia.Wayland.Server.Persistent;
using NWayland.Protocols.XdgShell;
namespace Avalonia.Wayland;
partial class WindowImpl
{
private new sealed class Sink : WindowBaseImpl.Sink, IWXdgTopLevelEventSink
{
private new WindowImpl Parent => (WindowImpl)base.Parent;
private XdgConfigureBatch? _initialBatch;
private WaylandSurfaceCreateResult<WXdgTopLevelProxy>? _handle;
private WXdgTopLevelProxy? _surfaceProxy;
public Sink(WindowImpl parent, bool secondShow) : base(parent)
{
_handle = Parent.Client.CreateTopLevelHandle(new WXdgTopLevelEventSinkProxy(this, WaylandMarshallers.UIThread));
_surfaceProxy = _handle.Proxy;
Parent._handle = _handle;
Parent._surfaceProxy = _surfaceProxy;
var initialBatch = _handle.BasicInitCompleted.GetAwaiter().GetResult();
_initialBatch = initialBatch;
Parent.ApplyConfigureBatch(initialBatch);
// If compositor didn't provide a size, auto-size
if (!secondShow && initialBatch.Size is not { Width: > 0, Height: > 0 })
Parent.ClientSize = new Size(Math.Min(640, Parent.MaxAutoSizeHint.Width),
Math.Min(480, Parent.MaxAutoSizeHint.Height));
// Ack the initial configure
_surfaceProxy.SetPendingAckSerial(initialBatch.Serial);
if (initialBatch.InitialDecorationMode is { } im)
Parent.ApplyDecorationMode(im);
// Re-send stored shadow extents to the new surface
if (Parent._shadowExtents != default)
_surfaceProxy.SetShadowExtents(Parent._shadowExtents);
// Re-send cached title to the new surface
if (Parent._title != null)
_surfaceProxy.SetTitle(Parent._title);
// Re-apply cached min/max size constraints after a fresh worker
// surface is created. null on both sides means SetMinMaxSize was
// never called (or both bounds are unconstrained) — nothing to push.
if (Parent._minSize.HasValue || Parent._maxSize.HasValue)
_surfaceProxy.SetMinMaxSize(Parent._minSize, Parent._maxSize);
// Re-apply cursor (defaults to Arrow on a fresh worker WSurface).
if (Parent.CurrentCursor is not null)
Parent.ApplyCurrentCursor(_surfaceProxy);
// Re-register text-input sink on the freshly created worker
// surface and re-apply current state, if a client is attached.
Parent._textInputMethod?.OnSurfaceCreated();
// Refresh the storage provider's factory chain — the previous wayland
// connection (and its xdg-foreign exporter, if any) is gone, and the new
// compositor may expose different globals. Reset() drops any cached
// provider so portal availability is re-evaluated against the fresh
// connection on the next picker call.
Parent._storageProvider?.Reset(Parent.BuildStorageFactories());
}
protected override void DisconnectFromSurface()
{
var proxy = Parent._surfaceProxy;
Parent._handle = null;
Parent._surfaceProxy = null;
proxy?.Disconnect();
}
public void OnConfigure(XdgConfigureBatch batch)
{
// Marshalled to UI thread by WSurfaceEventSinkProxy.
if (IsDisposed)
return;
// Skip the initial batch — already processed synchronously in the constructor
if (ReferenceEquals(batch, _initialBatch))
{
_initialBatch = null;
return;
}
Parent.ApplyConfigureBatch(batch);
// Post the ack serial back to the wayland thread for next commit
_surfaceProxy?.SetPendingAckSerial(batch.Serial);
}
public void OnDecorationModeChanged(DecorationMode mode)
{
if (IsDisposed || Parent._csdSticky)
return;
Parent.ApplyDecorationMode(mode);
}
public void OnClose()
{
// Marshalled to UI thread by WSurfaceEventSinkProxy.
if (IsDisposed)
return;
if (Parent.Closing?.Invoke(WindowCloseReason.WindowClosing) != true)
Dispose();
}
protected override void OnInputWhileDisabled() => Parent.GotInputWhenDisabled?.Invoke();
// Title-bar move and edge-resize chrome dispatch is xdg_toplevel-specific.
protected override bool HandleSurfaceSpecificDispatch(RawInputEventArgs args)
{
// TODO: We need to properly check if touch contact is primary, but it's tracked in TouchDevice,
// so we might want to move that handling to x-plat part of the codebase
// another point is that we generally need to cancel implicit capture if pointer gesture started
// with right mouse button
if (InputRoot == null || !(args is RawPointerEventArgs
{
Type: RawPointerEventType.LeftButtonDown or RawPointerEventType.TouchBegin
} mouse))
return false;
var chromeRole = InputRoot.HitTestChromeElement(mouse.Position);
if (chromeRole == WindowDecorationsElementRole.TitleBar)
{
_surfaceProxy?.Move(mouse.PlatformInputEventCookie, WaylandDispatchPriority.Oob);
return true;
}
if (Parent._canResize && chromeRole is { } role)
{
var resizeEdge = role switch
{
WindowDecorationsElementRole.ResizeN => XdgToplevel.ResizeEdgeEnum.Top,
WindowDecorationsElementRole.ResizeS => XdgToplevel.ResizeEdgeEnum.Bottom,
WindowDecorationsElementRole.ResizeE => XdgToplevel.ResizeEdgeEnum.Right,
WindowDecorationsElementRole.ResizeW => XdgToplevel.ResizeEdgeEnum.Left,
WindowDecorationsElementRole.ResizeNE => XdgToplevel.ResizeEdgeEnum.TopRight,
WindowDecorationsElementRole.ResizeNW => XdgToplevel.ResizeEdgeEnum.TopLeft,
WindowDecorationsElementRole.ResizeSE => XdgToplevel.ResizeEdgeEnum.BottomRight,
WindowDecorationsElementRole.ResizeSW => XdgToplevel.ResizeEdgeEnum.BottomLeft,
_ => (XdgToplevel.ResizeEdgeEnum?)null
};
if (resizeEdge is { } edge)
{
_surfaceProxy?.Resize(mouse.PlatformInputEventCookie, edge, WaylandDispatchPriority.Oob);
return true;
}
}
return false;
}
}
}

233
src/Avalonia.Wayland/WindowImpl.TextInput.cs

@ -0,0 +1,233 @@
using System;
using Avalonia.Input;
using Avalonia.Input.Raw;
using Avalonia.Input.TextInput;
using Avalonia.Wayland.Server;
using Avalonia.Wayland.Server.Persistent;
using Avalonia.Wayland.Server.Transient;
namespace Avalonia.Wayland;
partial class WindowImpl
{
/// <summary>
/// UI-thread broker for text input. Combines two responsibilities so they
/// stay coherent on compositors with or without zwp_text_input_v3:
/// 1. Compose-key gating — flips <c>WSurface.HasTextInputClient</c> on the
/// Wayland thread so XKB-compose key sequences are processed if a text
/// input client is attached.
/// 2. zwp_text_input_v3 IME — when the compositor exposes the manager,
/// drives the worker-side <see cref="WaylandTextInputV3"/> facade and
/// surfaces its callbacks back to the active <see cref="TextInputMethodClient"/>.
/// </summary>
/// <remarks>
/// All worker→UI callbacks (<see cref="IWaylandTextInputV3Events"/>) are
/// marshalled by the generated <c>WaylandTextInputV3EventsProxy</c> the
/// broker registers with the surface, so the methods below run on the UI
/// thread despite being invoked by the worker.
/// </remarks>
private sealed class WaylandTextInputMethod
: ITextInputMethodImpl, IWaylandTextInputV3Events
{
private readonly WindowImpl _owner;
private TextInputMethodClient? _client;
private bool _hasPreedit;
private WaylandTextInputV3EventsProxy? _sinkProxy;
// Each callback the worker emits carries the session token most
// recently set via SetTextInputActive. SetClient bumps _clientEpoch
// and propagates it to the worker; worker→UI callbacks emitted under
// a previous client carry the old token and are dropped by the guard
// at the top of every IWaylandTextInputV3Events method.
private int _clientEpoch;
// Tracks the surface proxy we registered the sink against. After
// hide/show the surface is recreated; we need to re-register the
// sink and re-apply the current text-input state.
private WXdgTopLevelProxy? _registeredAgainstSurface;
public WaylandTextInputMethod(WindowImpl owner)
{
_owner = owner;
}
public void SetClient(TextInputMethodClient? client)
{
var oldClient = _client;
if (ReferenceEquals(oldClient, client))
return;
UnsubscribeClient();
// Bump epoch BEFORE swapping _client. The worker stamps every
// subsequent IME callback with the token we send below; older
// callbacks (for the previous client) carry the previous token
// and are dropped at the top of each OnImeXxx handler.
_clientEpoch++;
// Abort any in-flight composition on the old client (if any).
if (oldClient != null && _hasPreedit)
{
try { oldClient.SetPreeditText(null, null); } catch { }
}
_hasPreedit = false;
_client = client;
var hasClient = client != null;
var supportsPreedit = client?.SupportsPreedit ?? false;
var supportsSurrounding = client?.SupportsSurroundingText ?? false;
ApplyTextInputStateToSurface(hasClient, supportsPreedit, supportsSurrounding);
if (client != null)
{
client.SurroundingTextChanged += OnClientSurroundingTextChanged;
client.SelectionChanged += OnClientSelectionChanged;
PushSurrounding();
}
}
/// <summary>
/// Called by <see cref="WindowImpl.Sink"/> after a new worker surface
/// is created (Show following Hide). Re-registers the sink on the
/// freshly created worker <c>WSurface</c> and re-applies the current
/// text-input state so the IME keeps working across surface
/// recreation.
/// </summary>
public void OnSurfaceCreated()
{
if (_owner._surfaceProxy is null)
return;
// Force re-registration on the new surface even if we already
// have a sink proxy — the worker-side sink lives on WSurface and
// is reset when the surface is recreated.
_registeredAgainstSurface = null;
var hasClient = _client != null;
var supportsPreedit = _client?.SupportsPreedit ?? false;
var supportsSurrounding = _client?.SupportsSurroundingText ?? false;
ApplyTextInputStateToSurface(hasClient, supportsPreedit, supportsSurrounding);
if (hasClient)
PushSurrounding();
}
private void ApplyTextInputStateToSurface(bool hasClient, bool supportsPreedit, bool supportsSurrounding)
{
var proxy = _owner._surfaceProxy;
if (proxy is null)
return;
if (!ReferenceEquals(_registeredAgainstSurface, proxy))
{
_sinkProxy ??= new WaylandTextInputV3EventsProxy(this, WaylandMarshallers.UIThread);
proxy.RegisterTextInputSink(_sinkProxy);
_registeredAgainstSurface = proxy;
}
proxy.SetTextInputActive(hasClient, supportsPreedit, supportsSurrounding, _clientEpoch);
}
public void SetCursorRect(Rect rect) =>
_owner._surfaceProxy?.SetTextInputCursorRect(rect);
public void SetOptions(TextInputOptions options) =>
_owner._surfaceProxy?.SetTextInputOptions(options);
public void Reset() =>
_owner._surfaceProxy?.ResetTextInput();
void IWaylandTextInputV3Events.OnImeUpdate(
int sessionToken,
int deleteBeforeChars, int deleteAfterChars,
string? commitText,
string? preeditText, int preeditCursorBeginChar, int preeditCursorEndChar)
{
// The session token rejects in-flight callbacks queued on the UI
// dispatcher before SetClient (or surface recreation) bumped the
// epoch — those would otherwise apply to the wrong client.
if (sessionToken != _clientEpoch || _client is not { } client)
return;
if (_owner.InputRoot is not { } inputRoot)
return;
var input = _owner.Input;
// Per spec, in this exact order:
// 1. clear current preedit (implicit before commit/delete);
// 2. apply delete_surrounding_text;
// 3. insert commit text;
// 4. apply new preedit.
var hasDelete = (deleteBeforeChars > 0 || deleteAfterChars > 0)
&& client.SupportsSurroundingText;
var hasCommit = !string.IsNullOrEmpty(commitText);
var newPreeditEmpty = string.IsNullOrEmpty(preeditText);
// Clear the existing preedit before we mutate the document with
// delete or commit (it's about to be replaced anyway, and any
// ongoing composition needs to be torn down first).
if (_hasPreedit && (hasDelete || hasCommit))
{
client.SetPreeditText(null, null);
_hasPreedit = false;
}
if (hasDelete && input is not null)
{
// Select the to-be-deleted range, then synthesize a Backspace
// keypress. TextBox handles "Backspace with non-empty selection"
// by deleting the selection — same pattern iOS uses for
// IUIKeyInput.DeleteBackward (see TextInputResponder.cs).
var sel = client.Selection;
var anchor = Math.Min(sel.Start, sel.End);
var cursor = Math.Max(sel.Start, sel.End);
client.Selection = new TextSelection(
Math.Max(0, anchor - deleteBeforeChars),
cursor + deleteAfterChars);
input.Invoke(new RawKeyEventArgs(_owner.Keyboard, 0, inputRoot,
RawKeyEventType.KeyDown, Key.Back, RawInputModifiers.None,
PhysicalKey.Backspace, "\b"));
input.Invoke(new RawKeyEventArgs(_owner.Keyboard, 0, inputRoot,
RawKeyEventType.KeyUp, Key.Back, RawInputModifiers.None,
PhysicalKey.Backspace, "\b"));
}
if (hasCommit)
input?.Invoke(new RawTextInputEventArgs(_owner.Keyboard, 0, inputRoot, commitText!));
if (!newPreeditEmpty)
{
_hasPreedit = true;
client.SetPreeditText(preeditText,
preeditCursorEndChar < 0 ? null : preeditCursorEndChar);
}
else if (_hasPreedit)
{
// No new preedit: clear any leftover composition.
client.SetPreeditText(null, null);
_hasPreedit = false;
}
}
private void OnClientSurroundingTextChanged(object? sender, EventArgs e) => PushSurrounding();
private void OnClientSelectionChanged(object? sender, EventArgs e) => PushSurrounding();
private void PushSurrounding()
{
if (_client is not { SupportsSurroundingText: true } client)
return;
var text = client.SurroundingText ?? string.Empty;
var selection = client.Selection;
_owner._surfaceProxy?.SetTextInputSurroundingText(text, selection.End, selection.Start);
}
private void UnsubscribeClient()
{
if (_client is null)
return;
_client.SurroundingTextChanged -= OnClientSurroundingTextChanged;
_client.SelectionChanged -= OnClientSelectionChanged;
}
}
}

396
src/Avalonia.Wayland/WindowImpl.cs

@ -0,0 +1,396 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Avalonia.Controls;
using Avalonia.Controls.Platform;
using Avalonia.Dialogs;
using Avalonia.FreeDesktop;
using Avalonia.Input;
using Avalonia.Input.TextInput;
using Avalonia.Platform;
using Avalonia.Platform.Storage;
using Avalonia.Platform.Storage.FileIO;
using Avalonia.Platform.Surfaces;
using Avalonia.Wayland.Server;
using Avalonia.Wayland.Server.Persistent;
using NWayland.Protocols.XdgShell;
namespace Avalonia.Wayland;
internal partial class WindowImpl : WindowBaseImpl, IWindowImpl
{
private WaylandSurfaceCreateResult<WXdgTopLevelProxy>? _handle;
private WXdgTopLevelProxy? _surfaceProxy;
private WaylandTextInputMethod? _textInputMethod;
private bool _isActivated;
private Size _maxAutoSizeHint;
private WindowState _windowState;
private bool _canResize = true;
private Size _restoreBounds;
private Thickness _shadowExtents;
private Size? _minSize;
private Size? _maxSize;
// Sticky-CSD latch on the UI side. Set true the first time the
// framework asks for partial / no decorations. Never resets,
// including across reconnects.
// This is a limitation of V1 of the protocol that's supported in the wild
private bool _csdSticky;
private string? _title;
private FallbackStorageProvider? _storageProvider;
public WindowImpl(WaylandWorkerClient client) : base(client)
{
CurrentSink = new Sink(this, false);
}
private void ApplyConfigureBatch(XdgConfigureBatch batch)
{
_maxAutoSizeHint = batch.MaxSize;
// Map xdg_toplevel states to Avalonia WindowState
var newWindowState = WindowState.Normal;
if ((batch.States & XdgToplevelStates.Fullscreen) != 0)
newWindowState = WindowState.FullScreen;
else if ((batch.States & XdgToplevelStates.Maximized) != 0)
newWindowState = WindowState.Maximized;
var oldWindowState = _windowState;
// Save restore bounds when leaving Normal state
if (oldWindowState == WindowState.Normal && newWindowState != WindowState.Normal)
_restoreBounds = ClientSize;
if (oldWindowState != newWindowState)
{
_windowState = newWindowState;
WindowStateChanged?.Invoke(newWindowState);
}
// Activation
var isActivated = (batch.States & XdgToplevelStates.Activated) != 0;
var wasActivated = _isActivated;
_isActivated = isActivated;
if (isActivated && !wasActivated)
Activated?.Invoke();
else if (!isActivated && wasActivated)
Deactivated?.Invoke();
// Resize: configure size is window geometry (excluding shadows).
// Add shadow extents to get full surface size for the UI thread.
if (batch.Size is { Width: > 0, Height: > 0 })
{
var newClientSize = new Size(
batch.Size.Width + _shadowExtents.Left + _shadowExtents.Right,
batch.Size.Height + _shadowExtents.Top + _shadowExtents.Bottom);
if (newClientSize != ClientSize)
{
ClientSize = newClientSize;
Resized?.Invoke(newClientSize, WindowResizeReason.Layout);
}
}
else if (newWindowState == WindowState.Normal && oldWindowState != WindowState.Normal
&& _restoreBounds is { Width: > 0, Height: > 0 })
{
if (_restoreBounds != ClientSize)
{
ClientSize = _restoreBounds;
Resized?.Invoke(_restoreBounds, WindowResizeReason.Layout);
}
}
}
public override object? TryGetFeature(Type featureType)
{
if (featureType == typeof(ITextInputMethodImpl))
return _textInputMethod ??= new WaylandTextInputMethod(this);
if (featureType == typeof(IStorageProvider))
return _storageProvider ??= new FallbackStorageProvider(BuildStorageFactories());
return base.TryGetFeature(featureType);
}
private Func<Task<IStorageProvider?>>[] BuildStorageFactories() => new Func<Task<IStorageProvider?>>[]
{
// Probe xdg-foreign-v2 export at factory-eval time. The DBus portal is only
// useful with a real wayland: parent_window — exposing the dialog as a
// floating, unparented window is worse UX than a managed in-process picker.
// If the probe fails (no exporter global, surface not mapped, or compositor
// never delivers the handle), return null and let FallbackStorageProvider
// move on to ManagedStorageProvider. The factory is re-evaluated on every
// (re)connect via FallbackStorageProvider.Reset.
async () =>
{
var firstLease = await AcquirePortalParentLeaseAsync();
if (firstLease is null)
return null;
// We don't keep the probe lease around — each picker call exports fresh.
await firstLease.DisposeAsync();
return await DBusSystemDialog.TryCreateAsync(AcquirePortalParentLeaseAsync);
},
// HACK: relies on focus root being TopLevel which currently is true
() => Task.FromResult(((PresentationSource?)InputRoot)?.FocusRoot is TopLevel tl ? (IStorageProvider?)new ManagedStorageProvider(tl) : null)
};
private async Task<IPortalParentLease?> AcquirePortalParentLeaseAsync()
{
var proxy = _surfaceProxy;
if (proxy is null)
return null;
// Cross-thread proxy auto-wraps the worker call into Task<IWaylandXdgTopLevelExport?>.
var export = await proxy.ExportToplevel();
if (export is null)
return null;
var raw = await export.HandleTask;
if (raw is null)
{
export.Dispose();
return null;
}
return new XdgForeignPortalParentLease(export, "wayland:" + raw);
}
private sealed class XdgForeignPortalParentLease : IPortalParentLease
{
private readonly IWaylandXdgTopLevelExport _export;
public XdgForeignPortalParentLease(IWaylandXdgTopLevelExport export, string handle)
{
_export = export;
Handle = handle;
}
public string Handle { get; }
public ValueTask DisposeAsync()
{
_export.Dispose();
return default;
}
}
public override IPlatformRenderSurface[] Surfaces => _handle?.GetRenderSurfaces() ?? [];
internal override WXdgShellSurfaceProxy? SurfaceProxy => _surfaceProxy;
public override IPopupImpl? CreatePopup() => new PopupImpl(Client, this);
// TODO: Query client decorations
public override Size? FrameSize => null;
public override void Show(bool activate, bool isDialog)
{
if (IsDisposed)
throw new ObjectDisposedException(nameof(WindowImpl));
if (CurrentSink == null)
{
// TODO: Re-apply window state
CurrentSink = new Sink(this, true);
}
Client.AnyThreadWakeupRenderLoop();
}
public override Size MaxAutoSizeHint => _maxAutoSizeHint;
public WindowState WindowState
{
get => _windowState;
set
{
// Capture current state on the UI thread for the transition logic
var currentState = _windowState;
// Don't update the field — the compositor will confirm via configure
var proxy = _surfaceProxy;
if (proxy is null)
return;
switch (value)
{
case WindowState.Normal:
if (currentState == WindowState.Maximized)
proxy.UnsetMaximized();
else if (currentState == WindowState.FullScreen)
proxy.UnsetFullscreen();
break;
case WindowState.Maximized:
if (currentState == WindowState.FullScreen)
proxy.UnsetFullscreen();
proxy.SetMaximized();
break;
case WindowState.FullScreen:
if (currentState == WindowState.Maximized)
proxy.UnsetMaximized();
proxy.SetFullscreen();
break;
case WindowState.Minimized:
proxy.SetMinimized();
break;
}
}
}
public bool WindowStateGetterIsUsable => true;
public Action<WindowState>? WindowStateChanged { get; set; }
public void SetTitle(string? title)
{
_title = title;
_surfaceProxy?.SetTitle(title);
}
public void SetParent(IWindowImpl? parent)
{
var parentProxy = (parent as WindowImpl)?._handle?.Proxy;
_surfaceProxy?.SetParent(parentProxy);
}
public void SetEnabled(bool enable) => IsEnabled = enable;
public Action? GotInputWhenDisabled { get; set; }
public void SetWindowDecorations(WindowDecorations enabled)
{
if (_csdSticky || enabled == WindowDecorations.Full)
return;
// We disable SSD support completely once CSD was requested at least once. Protocol limitation
_csdSticky = true;
_surfaceProxy?.DestroyDecoration();
ApplyDecorationMode(DecorationMode.ClientSide);
}
/// <summary>
/// Apply a new effective decoration mode on the UI thread. Updates
/// the cached <see cref="NeedsManagedDecorations"/> flag and nudges the
/// framework to re-evaluate chrome layout. Idempotent.
/// </summary>
internal void ApplyDecorationMode(DecorationMode mode)
{
if(_serverReportedMode == mode)
return;
_serverReportedMode = mode;
RaiseDecorationChangeCallbacks();
}
private void RaiseDecorationChangeCallbacks()
{
DrawnDecorationsRequestChanged?.Invoke();
ExtendClientAreaToDecorationsChanged?.Invoke(IsClientAreaExtendedToDecorations);
}
public void SetIcon(IWindowIconImpl? icon) { }
public void ShowTaskbarIcon(bool value) { }
public void CanResize(bool value) => _canResize = value;
public void SetCanMinimize(bool value) { }
public void SetCanMaximize(bool value) { }
public Func<WindowCloseReason, bool>? Closing { get; set; }
public Action<bool>? ExtendClientAreaToDecorationsChanged
{
get => field;
set
{
field = value;
value?.Invoke(true);
}
}
private DecorationMode _serverReportedMode = DecorationMode.ClientSide;
private bool _appRequestedDecorationExtension;
public bool IsClientAreaExtendedToDecorations => NeedsManagedDecorations && _appRequestedDecorationExtension;
public bool NeedsManagedDecorations => _serverReportedMode == DecorationMode.ClientSide;
public PlatformRequestedDrawnDecoration RequestedDrawnDecorations =>
NeedsManagedDecorations
? PlatformRequestedDrawnDecoration.TitleBar | PlatformRequestedDrawnDecoration.Border |
PlatformRequestedDrawnDecoration.ResizeGrips | PlatformRequestedDrawnDecoration.Shadow
: PlatformRequestedDrawnDecoration.None;
public Action? DrawnDecorationsRequestChanged { get; set; }
public Thickness ExtendedMargins => default;
public Thickness OffScreenMargin => default;
// Not supported by Wayland — surfaces have no screen position.
public void Move(PixelPoint point) { }
public void BeginMoveDrag(PointerPressedEventArgs e)
{
var cookie = e.PlatformInputEventCookie;
e.Pointer.Capture(null);
_surfaceProxy?.Move(cookie, WaylandDispatchPriority.Oob);
}
public void BeginResizeDrag(WindowEdge edge, PointerPressedEventArgs e)
{
var resizeEdge = edge switch
{
WindowEdge.NorthWest => XdgToplevel.ResizeEdgeEnum.TopLeft,
WindowEdge.North => XdgToplevel.ResizeEdgeEnum.Top,
WindowEdge.NorthEast => XdgToplevel.ResizeEdgeEnum.TopRight,
WindowEdge.West => XdgToplevel.ResizeEdgeEnum.Left,
WindowEdge.East => XdgToplevel.ResizeEdgeEnum.Right,
WindowEdge.SouthWest => XdgToplevel.ResizeEdgeEnum.BottomLeft,
WindowEdge.South => XdgToplevel.ResizeEdgeEnum.Bottom,
WindowEdge.SouthEast => XdgToplevel.ResizeEdgeEnum.BottomRight,
_ => XdgToplevel.ResizeEdgeEnum.None
};
var cookie = e.PlatformInputEventCookie;
e.Pointer.Capture(null);
_surfaceProxy?.Resize(cookie, resizeEdge, WaylandDispatchPriority.Oob);
}
private int _resizeStackDepth = 0;
private bool SizeNearlyEquals(double x, double y) => Math.Abs(x - y) < 0.01;
public void Resize(Size clientSize, WindowResizeReason reason = WindowResizeReason.Application)
{
// There is some bug on the x-plat side of things that increments the size by 0.000001 or smth and causes
// a stack overflow
// So we check for recursion depth and for nearly equal values here.
if (_resizeStackDepth > 5)
return;
if (SizeNearlyEquals(clientSize.Width, ClientSize.Width) &&
SizeNearlyEquals(clientSize.Height, ClientSize.Height))
return;
try
{
_resizeStackDepth++;
// Size is just a number, the actual resizing is done by the compositor when submitting a frame
ClientSize = clientSize;
Resized?.Invoke(clientSize, reason);
}
finally
{
_resizeStackDepth--;
}
}
public void SetMinMaxSize(Size minSize, Size maxSize)
{
// Avalonia uses (0,0) and PositiveInfinity to mean "unconstrained".
// Map both onto null at this boundary so the worker layer can
// express "no constraint" without sentinel-value plumbing.
static Size? Normalize(Size s) =>
s.Width > 0 && s.Height > 0 && !double.IsPositiveInfinity(s.Width)
&& !double.IsPositiveInfinity(s.Height)
? s
: (Size?)null;
_minSize = Normalize(minSize);
_maxSize = Normalize(maxSize);
_surfaceProxy?.SetMinMaxSize(_minSize, _maxSize);
}
public void SetExtendClientAreaToDecorationsHint(bool extendIntoClientAreaHint)
{
_appRequestedDecorationExtension = extendIntoClientAreaHint;
RaiseDecorationChangeCallbacks();
}
public void SetExtendClientAreaTitleBarHeightHint(double titleBarHeight) { }
void IWindowImpl.SetShadowExtents(Thickness extents)
{
_shadowExtents = extents;
_surfaceProxy?.SetShadowExtents(extents);
}
}

190
src/Avalonia.Wayland/WindowImplBase.DragDrop.cs

@ -0,0 +1,190 @@
using Avalonia.Input;
using Avalonia.Input.Raw;
using Avalonia.Wayland.Clipboard;
using Avalonia.Wayland.Server.Persistent;
using Avalonia.Wayland.Server.Transient.Clipboard;
namespace Avalonia.Wayland;
partial class WindowBaseImpl
{
partial class Sink
{
// DnD session state (Wayland-thread only; carried to UI thread via event args)
private IDataTransfer? _currentDragDataTransfer;
private DragDropEffects _currentDragSourceEffects;
private WaylandDragMotionArgs? _pendingDragMotion;
private WaylandOfferCookie? _currentDndCookie;
/// <summary>
/// Handles DnD-related dispatch in <see cref="DispatchInput"/>.
/// Returns true if the event was DnD and was fully handled.
/// </summary>
protected bool HandleDragDropDispatch(RawInputEventArgs args)
{
// DnD motion debouncing: consume latest position under lock, create RawDragEvent
if (args is WaylandDragMotionArgs dragMotion)
{
Point pos;
RawInputModifiers mods;
lock (dragMotion.SyncRoot)
{
pos = dragMotion.Position;
mods = dragMotion.Modifiers;
dragMotion.Consumed = true;
}
var overEvent = new RawDragEvent(DragDropDevice.Instance, RawDragEventType.DragOver,
InputRoot!, pos, dragMotion.DataTransfer, dragMotion.SourceEffects, mods);
Parent.Input?.Invoke(overEvent);
var effects = overEvent.Effects;
dragMotion.OfferCookie?.PostUpdateFeedback(effects);
return true;
}
return false;
}
/// <summary>
/// Post-dispatch feedback for DnD enter/drop events.
/// Called after <see cref="WindowBaseImpl.Input"/> has processed the event.
/// Returns true if the event was DnD and feedback was sent.
/// </summary>
protected bool HandleDragDropPostDispatch(RawInputEventArgs args)
{
if (args is not WaylandRawDragEvent dragEvent)
return false;
// Route DnD feedback through the session cookie, which resolves
// the correct data device on the Wayland thread.
if (dragEvent.Type is RawDragEventType.DragEnter)
{
var effects = dragEvent.Effects;
dragEvent.OfferCookie?.PostUpdateFeedback(effects);
}
else if (dragEvent.Type is RawDragEventType.Drop)
{
dragEvent.OfferCookie?.PostFinish();
}
return true;
}
void IWSurfaceEventSink.OnDragEnter(Point position, string[] mimeTypes, WaylandOfferCookie offerCookie,
DragDropEffects sourceEffects, RawInputModifiers modifiers)
{
if (InputRoot is null)
return;
_currentDndCookie = offerCookie;
// Check for in-process drag source to avoid deadlock: if the offer contains
// our sentinel MIME type, use the original IDataTransfer directly instead of
// creating a pipe-based WaylandDataTransfer (whose reads would deadlock because
// the source's send handler dispatches to the same UI thread).
_currentDragDataTransfer = WaylandOutgoingTransfer.TryGetInProcessTransfer(mimeTypes)
?? new WaylandDataTransfer(mimeTypes, offerCookie);
_currentDragSourceEffects = sourceEffects;
_pendingDragMotion = null;
ScheduleInput(new WaylandRawDragEvent(DragDropDevice.Instance, RawDragEventType.DragEnter,
InputRoot, position, _currentDragDataTransfer, sourceEffects, modifiers, offerCookie));
}
void IWSurfaceEventSink.OnDragMotion(Point position, RawInputModifiers modifiers)
{
var pending = _pendingDragMotion;
if (pending != null)
{
lock (pending.SyncRoot)
{
if (!pending.Consumed)
{
pending.Position = position;
pending.Modifiers = modifiers;
return;
}
}
}
if (InputRoot is null || _currentDragDataTransfer is null)
return;
var args = new WaylandDragMotionArgs(DragDropDevice.Instance, InputRoot,
position, _currentDragDataTransfer, _currentDragSourceEffects, modifiers, _currentDndCookie);
_pendingDragMotion = args;
RawEventGrouper.HandleEvent(args);
}
void IWSurfaceEventSink.OnDragLeave()
{
if (InputRoot is null || _currentDragDataTransfer is null)
return;
_pendingDragMotion = null;
ScheduleInput(new RawDragEvent(DragDropDevice.Instance, RawDragEventType.DragLeave,
InputRoot, default, _currentDragDataTransfer, DragDropEffects.None, RawInputModifiers.None));
_currentDragDataTransfer = null;
_currentDndCookie = null;
}
void IWSurfaceEventSink.OnDrop(Point position, RawInputModifiers modifiers)
{
if (InputRoot is null || _currentDragDataTransfer is null)
return;
_pendingDragMotion = null;
ScheduleInput(new WaylandRawDragEvent(DragDropDevice.Instance, RawDragEventType.Drop,
InputRoot, position, _currentDragDataTransfer, _currentDragSourceEffects, modifiers, _currentDndCookie));
_currentDragDataTransfer = null;
_currentDndCookie = null;
}
}
/// <summary>
/// <see cref="RawDragEvent"/> subclass that carries the <see cref="WaylandOfferCookie"/>
/// through the event queue so the UI thread can route DnD feedback without cross-thread
/// field access.
/// </summary>
private class WaylandRawDragEvent(
IDragDropDevice device, RawDragEventType type, IInputRoot root,
Point location, IDataTransfer dataTransfer, DragDropEffects effects,
RawInputModifiers modifiers, WaylandOfferCookie? offerCookie)
: RawDragEvent(device, type, root, location, dataTransfer, effects, modifiers)
{
public WaylandOfferCookie? OfferCookie { get; } = offerCookie;
}
/// <summary>
/// Specialized event args for DnD motion debouncing.
/// <para>
/// Ownership semantics: while a <see cref="WaylandDragMotionArgs"/> instance is in the dispatch
/// queue, ownership belongs to the Wayland thread — it may update <see cref="Position"/> and
/// <see cref="Modifiers"/> under <see cref="SyncRoot"/>. When the UI thread processes the event,
/// it acquires the lock, reads the latest values, and sets <see cref="Consumed"/> to
/// <c>true</c>. This atomically transfers ownership to the UI thread: subsequent Wayland-thread
/// updates see <c>Consumed == true</c> and create a new args instance instead. Therefore the
/// state is never truly shared — at any point exactly one thread owns the mutable fields.
/// </para>
/// </summary>
private class WaylandDragMotionArgs(
IDragDropDevice device, IInputRoot root,
Point position, IDataTransfer dataTransfer,
DragDropEffects sourceEffects, RawInputModifiers modifiers,
WaylandOfferCookie? offerCookie)
: RawInputEventArgs(device, 0, root)
{
public readonly object SyncRoot = new();
public Point Position = position;
public RawInputModifiers Modifiers = modifiers;
public bool Consumed;
public readonly IDataTransfer DataTransfer = dataTransfer;
public readonly DragDropEffects SourceEffects = sourceEffects;
public readonly WaylandOfferCookie? OfferCookie = offerCookie;
}
}

144
src/Avalonia.Wayland/WindowImplBase.Keyboard.cs

@ -0,0 +1,144 @@
using System;
using Avalonia.Input;
using Avalonia.Input.Raw;
using Avalonia.Threading;
using Avalonia.Wayland.Server.Persistent;
namespace Avalonia.Wayland;
partial class WindowBaseImpl
{
partial class Sink
{
// Key repeat state (managed on UI thread via DispatcherTimer)
private DispatcherTimer? _keyRepeatTimer;
private Key _repeatKey;
private PhysicalKey _repeatPhysicalKey;
private RawInputModifiers _repeatModifiers;
private string? _repeatKeySymbol;
private int _keyRepeatDelay;
private int _keyRepeatRate;
void IWSurfaceEventSink.OnKeyDown(ulong timestamp, Key key, RawInputModifiers modifiers,
PhysicalKey physicalKey, string? keySymbol)
{
if (InputRoot is null)
return;
ScheduleInput(new RawKeyEventArgs(Keyboard, timestamp, InputRoot,
RawKeyEventType.KeyDown, key, modifiers, physicalKey, keySymbol));
}
void IWSurfaceEventSink.OnKeyUp(ulong timestamp, Key key, RawInputModifiers modifiers, PhysicalKey physicalKey,
string? keySymbol)
{
if (InputRoot is null)
return;
ScheduleInput(new RawKeyEventArgs(Keyboard, timestamp, InputRoot,
RawKeyEventType.KeyUp, key, modifiers, physicalKey, keySymbol));
}
void IWSurfaceEventSink.OnKeyboardLeave()
{
if (InputRoot is null)
return;
ScheduleInput(new RawKeyboardLeaveEventArgs(Keyboard, 0, InputRoot));
}
void IWSurfaceEventSink.OnKeyRepeatInfo(int rate, int delay)
{
_keyRepeatRate = rate;
_keyRepeatDelay = delay;
}
/// <summary>
/// Called from DispatchInput on the UI thread to handle keyboard events for key repeat.
/// Returns true if the event is fully handled and should not be dispatched further.
/// </summary>
protected bool HandleKeyboardDispatch(RawInputEventArgs args)
{
if (args is RawKeyboardLeaveEventArgs)
{
StopKeyRepeat();
return true;
}
if (args is RawKeyEventArgs keyArgs)
{
if (keyArgs.Type == RawKeyEventType.KeyDown)
{
StartKeyRepeat(keyArgs.Key, keyArgs.PhysicalKey, keyArgs.Modifiers, keyArgs.KeySymbol);
}
else if (keyArgs.Type == RawKeyEventType.KeyUp && _repeatKey == keyArgs.Key)
{
StopKeyRepeat();
}
}
return false;
}
private void StartKeyRepeat(Key key, PhysicalKey physicalKey, RawInputModifiers modifiers, string? keySymbol)
{
StopKeyRepeat();
if (_keyRepeatRate <= 0)
return;
// Modifier keys don't repeat
if (key is Key.LeftShift or Key.RightShift or Key.LeftCtrl or Key.RightCtrl
or Key.LeftAlt or Key.RightAlt or Key.LWin or Key.RWin)
return;
_repeatKey = key;
_repeatPhysicalKey = physicalKey;
_repeatModifiers = modifiers;
_repeatKeySymbol = keySymbol;
var intervalMs = Math.Max(1, 1000 / _keyRepeatRate);
_keyRepeatTimer = new DispatcherTimer(
TimeSpan.FromMilliseconds(intervalMs),
// Ensure it doesn't block any actual input events that would stop the repeat
DispatcherPriority.Input - 1,
Dispatcher.UIThread);
_keyRepeatTimer.Tick += OnKeyRepeatTick;
// Start with the initial delay, then switch to repeat interval on first tick
_keyRepeatTimer.Interval = TimeSpan.FromMilliseconds(_keyRepeatDelay);
_keyRepeatTimer.Start();
}
protected void StopKeyRepeat()
{
if (_keyRepeatTimer != null)
{
_keyRepeatTimer.Stop();
_keyRepeatTimer.Tick -= OnKeyRepeatTick;
_keyRepeatTimer = null;
}
}
private void OnKeyRepeatTick(object? sender, EventArgs e)
{
if (InputRoot is null || _keyRepeatTimer == null)
{
StopKeyRepeat();
return;
}
// After first tick (initial delay), switch to repeat interval
var intervalMs = Math.Max(1, 1000 / _keyRepeatRate);
var repeatInterval = TimeSpan.FromMilliseconds(intervalMs);
if (_keyRepeatTimer.Interval != repeatInterval)
_keyRepeatTimer.Interval = repeatInterval;
Parent.Input?.Invoke(new RawKeyEventArgs(Keyboard, 0, InputRoot,
RawKeyEventType.KeyDown, _repeatKey, _repeatModifiers, _repeatPhysicalKey, _repeatKeySymbol));
}
}
/// <summary>
/// Internal event args for keyboard leave, routed through the event queue
/// to be serialized with other input events.
/// </summary>
private class RawKeyboardLeaveEventArgs(IInputDevice device, ulong timestamp, IInputRoot root)
: RawInputEventArgs(device, timestamp, root);
}

86
src/Avalonia.Wayland/WindowImplBase.Pointer.cs

@ -0,0 +1,86 @@
using Avalonia.Input;
using Avalonia.Input.Raw;
using Avalonia.Wayland.Server.Persistent;
namespace Avalonia.Wayland;
partial class WindowBaseImpl
{
partial class Sink
{
void IWSurfaceEventSink.OnPointerEnter(ulong timestamp, uint serial, Point position)
{
if (InputRoot is null)
return;
ScheduleInput(new RawPointerEventArgs(Mouse, timestamp, InputRoot,
RawPointerEventType.Move, position, RawInputModifiers.None));
}
void IWSurfaceEventSink.OnPointerLeave(uint serial)
{
if (InputRoot is null)
return;
ScheduleInput(new RawPointerEventArgs(Mouse, 0, InputRoot,
RawPointerEventType.LeaveWindow, new Point(), RawInputModifiers.None));
}
void IWSurfaceEventSink.OnPointerMotion(ulong timestamp, Point position, RawInputModifiers modifiers)
{
if (InputRoot is null)
return;
ScheduleInput(new RawPointerEventArgs(Mouse, timestamp, InputRoot,
RawPointerEventType.Move, position, modifiers));
}
void IWSurfaceEventSink.OnPointerButton(ulong timestamp, uint serial, RawPointerEventType type,
RawInputModifiers modifiers, Point position, object? platformCookie)
{
if (InputRoot is null)
return;
ScheduleInput(new RawPointerEventArgs(Mouse, timestamp, InputRoot,
type, position, modifiers) { PlatformInputEventCookie = platformCookie });
}
void IWSurfaceEventSink.OnPointerAxis(ulong timestamp, Vector delta, RawInputModifiers modifiers,
Point position)
{
if (InputRoot is null)
return;
ScheduleInput(new RawMouseWheelEventArgs(Mouse, timestamp, InputRoot,
position, delta, modifiers));
}
void IWSurfaceEventSink.OnTouchDown(ulong timestamp, int touchId, Point position, object? platformCookie)
{
if (InputRoot is null)
return;
ScheduleInput(new RawTouchEventArgs(Touch, timestamp, InputRoot,
RawPointerEventType.TouchBegin, position, RawInputModifiers.None, touchId)
{ PlatformInputEventCookie = platformCookie });
}
void IWSurfaceEventSink.OnTouchMove(ulong timestamp, int touchId, Point position)
{
if (InputRoot is null)
return;
ScheduleInput(new RawTouchEventArgs(Touch, timestamp, InputRoot,
RawPointerEventType.TouchUpdate, position, RawInputModifiers.None, touchId));
}
void IWSurfaceEventSink.OnTouchUp(ulong timestamp, int touchId, Point position)
{
if (InputRoot is null)
return;
ScheduleInput(new RawTouchEventArgs(Touch, timestamp, InputRoot,
RawPointerEventType.TouchEnd, position, RawInputModifiers.None, touchId));
}
void IWSurfaceEventSink.OnTouchCancel(int touchId, Point position)
{
if (InputRoot is null)
return;
ScheduleInput(new RawTouchEventArgs(Touch, 0, InputRoot,
RawPointerEventType.TouchCancel, position, RawInputModifiers.None, touchId));
}
}
}

262
src/Avalonia.Wayland/WindowImplBase.cs

@ -0,0 +1,262 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Avalonia.Controls;
using Avalonia.Controls.Platform;
using Avalonia.Input;
using Avalonia.Input.Platform;
using Avalonia.Input.Raw;
using Avalonia.Input.TextInput;
using Avalonia.Platform;
using Avalonia.Platform.Storage;
using Avalonia.Platform.Storage.FileIO;
using Avalonia.Platform.Surfaces;
using Avalonia.Rendering.Composition;
using Avalonia.Threading;
using Avalonia.Wayland.Server;
using Avalonia.Wayland.Server.Persistent;
namespace Avalonia.Wayland;
/// <summary>
/// Shared base for Wayland top-level and popup window implementations.
/// Holds the common DI/threading state, render-scale, cursor, event
/// callbacks and no-op platform methods that Wayland doesn't support
/// (Move, SetTopmost, Activate, PointToClient/Screen, etc.).
/// </summary>
internal abstract partial class WindowBaseImpl : IWindowBaseImpl
{
protected WaylandWorkerClient Client { get; }
protected IInputRoot? InputRoot { get; private set; }
protected MouseDevice Mouse { get; }
protected TouchDevice Touch { get; }
protected KeyboardDevice Keyboard { get; }
public double RenderScaling { get; set; } = 1;
protected WaylandCursorImpl? CurrentCursor { get; private set; }
protected bool IsEnabled { get; set; } = true;
protected bool IsDisposed { get; private set; }
internal IReadOnlyList<object> CurrentOutputIds { get; set; } = Array.Empty<object>();
protected WindowBaseImpl(WaylandWorkerClient client)
{
Client = client;
Compositor = client.Compositor;
Mouse = MouseDevice.Primary;
Touch = new TouchDevice();
Keyboard = (KeyboardDevice)AvaloniaLocator.Current.GetRequiredService<IKeyboardDevice>();
}
public Compositor Compositor { get; }
public double DesktopScaling => RenderScaling;
public IPlatformHandle? Handle => null;
public Size ClientSize
{
get;
protected set
{
field = value;
Client.AnyThreadWakeupRenderLoop();
}
}
public abstract IPlatformRenderSurface[] Surfaces { get; }
// Not supported by Wayland.
public PixelPoint Position => default;
public Point PointToClient(PixelPoint point) => new(point.X, point.Y);
public PixelPoint PointToScreen(Point point) => new((int)point.X, (int)point.Y);
public void Activate() { }
public void SetTopmost(bool value) { }
public abstract Size MaxAutoSizeHint { get; }
public virtual Size? FrameSize => null;
public WindowTransparencyLevel TransparencyLevel { get; } = WindowTransparencyLevel.Transparent;
public AcrylicPlatformCompensationLevels AcrylicCompensationLevels { get; } = default;
public void SetTransparencyLevelHint(IReadOnlyList<WindowTransparencyLevel> transparencyLevels) { }
public void SetFrameThemeVariant(PlatformThemeVariant themeVariant) { }
public Action? Closed { get; set; }
public Action? LostFocus { get; set; }
public Action<PixelPoint>? PositionChanged { get; set; }
public Action? Activated { get; set; }
public Action? Deactivated { get; set; }
public Action<RawInputEventArgs>? Input { get; set; }
public Action<Rect>? Paint { get; set; }
public Action<Size, WindowResizeReason>? Resized { get; set; }
public Action<double>? ScalingChanged { get; set; }
public Action<WindowTransparencyLevel>? TransparencyLevelChanged { get; set; }
public void SetInputRoot(IInputRoot inputRoot) => InputRoot = inputRoot;
protected Sink? CurrentSink { get; set; }
public abstract void Show(bool activate, bool isDialog);
public virtual void Hide()
{
var current = CurrentSink;
CurrentSink = null;
current?.Dispose();
}
public abstract IPopupImpl? CreatePopup();
public virtual void Dispose()
{
if (IsDisposed)
return;
IsDisposed = true;
var current = CurrentSink;
CurrentSink = null;
current?.Dispose();
Closed?.Invoke();
}
public void SetCursor(ICursorImpl? cursor)
{
CurrentCursor = cursor as WaylandCursorImpl;
ApplyCurrentCursor(SurfaceProxy);
}
/// <summary>
/// Pushes <see cref="CurrentCursor"/> to the given surface proxy (<c>null</c> falls back to the
/// default arrow). Also used by the sinks to re-apply the cursor on a freshly created surface.
/// </summary>
internal void ApplyCurrentCursor(WXdgShellSurfaceProxy? proxy) => proxy?.SetCursor(CurrentCursor?.Cursor);
/// <summary>
/// Returns the worker-side <c>xdg_surface</c>-derived proxy backing
/// this top-level / popup, or <c>null</c> when no surface is
/// currently created. Used by base-class operations that need to
/// reach the surface uniformly across <see cref="WindowImpl"/> and
/// <see cref="PopupImpl"/> (e.g. cursor application, child-popup
/// parenting).
/// </summary>
internal abstract WXdgShellSurfaceProxy? SurfaceProxy { get; }
protected void UpdateScaling(double scale)
{
if (Math.Abs(scale - RenderScaling) < 1e-6)
return;
RenderScaling = scale;
ScalingChanged?.Invoke(scale);
Client.AnyThreadWakeupRenderLoop();
}
protected void PostToUiThread(Action action) =>
Dispatcher.UIThread.Post(action, DispatcherPriority.Input);
public virtual object? TryGetFeature(Type featureType)
{
if (featureType == typeof(IScreenImpl))
return AvaloniaLocator.Current.GetService<IScreenImpl>();
if (featureType == typeof(IClipboard))
return AvaloniaLocator.Current.GetRequiredService<IClipboard>();
if (featureType == typeof(ILauncher))
return new BclLauncher();
return null;
}
/// <summary>
/// UI-thread sink for surface events. Receives worker→UI calls (already
/// marshalled by <see cref="WSurfaceEventSinkProxy"/>) and converts them
/// into Avalonia raw input events. Subclasses add surface-role-specific
/// behaviour: top-levels handle xdg_toplevel configure/close (via
/// <see cref="IWXdgTopLevelEventSink"/>) and chrome-driven move/resize;
/// popups handle their own configure batch (via
/// <see cref="IWXdgPopupEventSink"/>).
/// </summary>
internal abstract partial class Sink : IWSurfaceEventSink, IDisposable
{
protected WindowBaseImpl Parent { get; }
protected bool IsDisposed { get; private set; }
protected RawEventGrouper RawEventGrouper { get; }
protected Sink(WindowBaseImpl parent)
{
Parent = parent;
RawEventGrouper = new RawEventGrouper(DispatchInput, Parent.Client.InputDispatchQueue);
}
public virtual void Dispose()
{
if (IsDisposed)
return;
IsDisposed = true;
StopKeyRepeat();
DisconnectFromSurface();
}
/// <summary>
/// Tear down the worker-side surface proxy. Subclasses null out their
/// typed handle/proxy fields (and the corresponding fields on
/// <see cref="Parent"/>) and post a disconnect to the worker.
/// </summary>
protected abstract void DisconnectFromSurface();
protected IInputRoot? InputRoot => Parent.InputRoot;
protected TouchDevice Touch => Parent.Touch;
protected MouseDevice Mouse => Parent.Mouse;
protected KeyboardDevice Keyboard => Parent.Keyboard;
protected void ScheduleInput(RawInputEventArgs args) => RawEventGrouper.HandleEvent(args);
protected virtual void DispatchInput(RawInputEventArgs args)
{
if (IsDisposed || InputRoot is null)
return;
if (!Parent.IsEnabled)
{
OnInputWhileDisabled();
return;
}
if (HandleKeyboardDispatch(args))
return;
if (HandleDragDropDispatch(args))
return;
if (HandleSurfaceSpecificDispatch(args))
return;
Parent.Input?.Invoke(args);
if (HandleDragDropPostDispatch(args))
return;
if (!args.Handled && args is RawKeyEventArgs { KeySymbol: { } text, Type: RawKeyEventType.KeyDown })
Parent.Input?.Invoke(new RawTextInputEventArgs(Keyboard, args.Timestamp, InputRoot, text));
}
/// <summary>
/// Lets a derived sink intercept input before it reaches the framework.
/// Used by <see cref="WindowImpl.Sink"/> for title-bar move and edge resize chrome.
/// </summary>
protected virtual bool HandleSurfaceSpecificDispatch(RawInputEventArgs args) => false;
protected virtual void OnInputWhileDisabled() { }
// Configure/close are surface-role-specific (xdg_toplevel vs xdg_popup
// carry different payloads). Subclasses implement the matching
// worker→UI interface (IWXdgTopLevelEventSink / IWXdgPopupEventSink).
public virtual void OnScaleChanged(double scale)
{
if (IsDisposed)
return;
Parent.UpdateScaling(scale);
}
public virtual void OnSurfaceOutputsChanged(IReadOnlyList<object> outputIds)
{
if (IsDisposed)
return;
Parent.CurrentOutputIds = outputIds;
}
}
}

126
src/Avalonia.Wayland/XkbCommonKeymap.cs

@ -0,0 +1,126 @@
using System;
using System.Text;
using Avalonia.Wayland.Server.Interop;
using static Avalonia.Wayland.Server.Interop.XkbCommonNativeMethods;
namespace Avalonia.Wayland;
/// <summary>
/// Wraps an xkbcommon keymap and state, providing safe .NET API for keyboard layout handling.
/// Created from the keymap fd received in the wl_keyboard.keymap event.
/// </summary>
sealed unsafe class XkbCommonKeymap : IDisposable
{
private IntPtr _keymap;
private IntPtr _state;
public XkbCommonKeymap(XkbContext context, int fd, uint size)
{
var mapped = UnsafeNativeMethods.mmap(
IntPtr.Zero, (IntPtr)size, PROT_READ, MAP_PRIVATE, fd, IntPtr.Zero);
UnsafeNativeMethods.close(fd);
if (mapped == IntPtr.Zero || mapped == new IntPtr(-1))
throw new InvalidOperationException("Failed to mmap keymap fd");
try
{
// size includes null terminator; xkb_keymap_new_from_buffer expects length without it
_keymap = xkb_keymap_new_from_buffer(
context.Handle, (byte*)mapped, (IntPtr)(size - 1),
XKB_KEYMAP_FORMAT_TEXT_V1, XKB_KEYMAP_COMPILE_NO_FLAGS);
if (_keymap == IntPtr.Zero)
throw new InvalidOperationException("Failed to create xkb keymap from buffer");
_state = xkb_state_new(_keymap);
if (_state == IntPtr.Zero)
throw new InvalidOperationException("Failed to create xkb state");
}
catch
{
Dispose();
throw;
}
finally
{
UnsafeNativeMethods.munmap(mapped, (IntPtr)size);
}
}
/// <summary>
/// Number of keyboard layout groups in this keymap.
/// </summary>
public uint LayoutCount => _keymap != IntPtr.Zero ? xkb_keymap_num_layouts(_keymap) : 0;
/// <summary>
/// Updates the keyboard state with new modifier and group values from wl_keyboard.modifiers.
/// </summary>
public void UpdateModifiers(uint modsDepressed, uint modsLatched, uint modsLocked, uint group)
{
if (_state != IntPtr.Zero)
xkb_state_update_mask(_state, modsDepressed, modsLatched, modsLocked, 0, 0, group);
}
/// <summary>
/// Resolves an evdev keycode to a keysym and UTF-8 text using the current xkb state.
/// </summary>
/// <param name="evdevKeycode">The evdev keycode (NOT xkb keycode — offset +8 is applied internally).</param>
/// <returns>The keysym and the UTF-8 text produced by the key, or null if no text.</returns>
public (uint keysym, string? text) ResolveKey(uint evdevKeycode)
{
if (_state == IntPtr.Zero)
return (0, null);
var xkbKey = evdevKeycode + 8;
var keysym = xkb_state_key_get_one_sym(_state, xkbKey);
// Get UTF-8 text: first call with null to get required size
var needed = xkb_state_key_get_utf8(_state, xkbKey, null, IntPtr.Zero);
if (needed <= 0)
return (keysym, null);
// Allocate buffer (+1 for null terminator written by xkbcommon)
var bufSize = needed + 1;
var buf = stackalloc byte[bufSize];
xkb_state_key_get_utf8(_state, xkbKey, buf, (IntPtr)bufSize);
var text = Encoding.UTF8.GetString(buf, needed);
return (keysym, text);
}
/// <summary>
/// Gets the keysym for a key in a specific layout group at level 0 (base level).
/// Used for non-latin keyboard fallback — cycling through layouts to find a latin key.
/// </summary>
/// <param name="evdevKeycode">The evdev keycode.</param>
/// <param name="layout">The layout group index.</param>
/// <returns>The keysym, or 0 if not found.</returns>
public uint FindKeysymInLayout(uint evdevKeycode, uint layout)
{
if (_keymap == IntPtr.Zero)
return 0;
var xkbKey = evdevKeycode + 8;
var count = xkb_keymap_key_get_syms_by_level(_keymap, xkbKey, layout, 0, out var syms);
if (count > 0 && syms != null)
return syms[0];
return 0;
}
public void Dispose()
{
if (_state != IntPtr.Zero)
{
xkb_state_unref(_state);
_state = IntPtr.Zero;
}
if (_keymap != IntPtr.Zero)
{
xkb_keymap_unref(_keymap);
_keymap = IntPtr.Zero;
}
}
}

81
src/Avalonia.Wayland/XkbComposeState.cs

@ -0,0 +1,81 @@
using System;
using System.Text;
using static Avalonia.Wayland.Server.Interop.XkbCommonNativeMethods;
namespace Avalonia.Wayland;
/// <summary>
/// Wraps an xkb_compose_state for tracking compose/dead key sequences.
/// </summary>
sealed unsafe class XkbComposeState : IDisposable
{
private IntPtr _state;
public XkbComposeState(XkbComposeTable table)
{
_state = xkb_compose_state_new(table.Handle, XKB_COMPOSE_STATE_NO_FLAGS);
if (_state == IntPtr.Zero)
throw new InvalidOperationException("Failed to create xkb compose state");
}
/// <summary>
/// Feeds a keysym to the compose state machine.
/// Returns the compose status after feeding.
/// </summary>
public int Feed(uint keysym)
{
if (_state == IntPtr.Zero)
return XKB_COMPOSE_NOTHING;
xkb_compose_state_feed(_state, keysym);
return xkb_compose_state_get_status(_state);
}
/// <summary>
/// Gets the composed UTF-8 text. Only valid when status is XKB_COMPOSE_COMPOSED.
/// </summary>
public string? GetComposedText()
{
if (_state == IntPtr.Zero)
return null;
var needed = xkb_compose_state_get_utf8(_state, null, IntPtr.Zero);
if (needed <= 0)
return null;
var bufSize = needed + 1;
// Compose results are tiny in practice, but `needed` comes from native code and is
// technically unbounded, so fall back to the heap past a small stack threshold.
Span<byte> buf = bufSize <= 256 ? stackalloc byte[256] : new byte[bufSize];
fixed (byte* bufPtr = buf)
xkb_compose_state_get_utf8(_state, bufPtr, (IntPtr)bufSize);
return Encoding.UTF8.GetString(buf.Slice(0, needed));
}
/// <summary>
/// Gets the composed keysym. Only valid when status is XKB_COMPOSE_COMPOSED.
/// </summary>
public uint GetComposedKeysym()
{
if (_state == IntPtr.Zero)
return 0;
return xkb_compose_state_get_one_sym(_state);
}
public void Reset()
{
if (_state != IntPtr.Zero)
xkb_compose_state_reset(_state);
}
public void Dispose()
{
if (_state != IntPtr.Zero)
{
xkb_compose_state_unref(_state);
_state = IntPtr.Zero;
}
}
}

42
src/Avalonia.Wayland/XkbComposeTable.cs

@ -0,0 +1,42 @@
using System;
using static Avalonia.Wayland.Server.Interop.XkbCommonNativeMethods;
namespace Avalonia.Wayland;
/// <summary>
/// Wraps an xkb_compose_table loaded from the system locale.
/// </summary>
sealed class XkbComposeTable : IDisposable
{
public IntPtr Handle { get; private set; }
private XkbComposeTable(IntPtr handle)
{
Handle = handle;
}
/// <summary>
/// Creates a compose table from the system locale, or returns null if unavailable.
/// </summary>
public static XkbComposeTable? TryCreate(XkbContext context)
{
var locale = Environment.GetEnvironmentVariable("LC_ALL")
?? Environment.GetEnvironmentVariable("LC_CTYPE")
?? Environment.GetEnvironmentVariable("LANG")
?? "C";
var handle = xkb_compose_table_new_from_locale(
context.Handle, locale, XKB_COMPOSE_COMPILE_NO_FLAGS);
return handle == IntPtr.Zero ? null : new XkbComposeTable(handle);
}
public void Dispose()
{
if (Handle != IntPtr.Zero)
{
xkb_compose_table_unref(Handle);
Handle = IntPtr.Zero;
}
}
}

28
src/Avalonia.Wayland/XkbContext.cs

@ -0,0 +1,28 @@
using System;
using static Avalonia.Wayland.Server.Interop.XkbCommonNativeMethods;
namespace Avalonia.Wayland;
/// <summary>
/// Wraps an xkb_context, shared by XkbCommonKeymap and XkbComposeTable.
/// </summary>
sealed class XkbContext : IDisposable
{
public IntPtr Handle { get; private set; }
public XkbContext()
{
Handle = xkb_context_new(XKB_CONTEXT_NO_FLAGS);
if (Handle == IntPtr.Zero)
throw new InvalidOperationException("Failed to create xkb context");
}
public void Dispose()
{
if (Handle != IntPtr.Zero)
{
xkb_context_unref(Handle);
Handle = IntPtr.Zero;
}
}
}

Some files were not shown because too many files changed in this diff

Loading…
Cancel
Save