From 111d5603712a2a0c2cec460e52cd62a89376cd1e Mon Sep 17 00:00:00 2001
From: 0x5BFA <62196528+0x5bfa@users.noreply.github.com>
Date: Tue, 25 Aug 2026 01:56:12 +0900
Subject: [PATCH] Fix Linux X11 lifecycle and interop
---
.github/workflows/ci.yml | 32 ++
DesktopFlyouts.slnx | 3 +
Directory.Build.targets | 2 +-
Directory.Packages.props | 3 +
src/DesktopFlyouts.Shared/DesktopFlyout.cs | 7 +
.../DesktopFlyoutIsland.cs | 6 +-
.../DesktopFlyoutIslandTemplateSettings.cs | 4 +-
.../DesktopFlyouts.Shared.projitems | 4 +-
.../DesktopMenuFlyout.Uno.cs | 3 +-
.../DesktopMenuFlyout.cs | 1 +
.../Helpers/GeneralHelpers.X11.cs | 180 ++++---
.../Helpers/WindowHelpers.X11.cs | 98 +++-
.../Helpers/X11ScreenGeometry.cs | 102 ++++
.../Helpers/X11WindowActivation.cs | 14 +
.../SystemTrayIcon.X11.cs | 506 +++++++++++++-----
src/DesktopFlyouts.Shared/X11PInvoke.cs | 70 ++-
.../XamlIslandHostWindow.X11.cs | 333 +++++++-----
.../DesktopFlyouts.Uno.csproj | 44 +-
...DesktopFlyoutIslandTemplateSettings.Uno.cs | 22 +
.../DesktopFlyouts.X11.Tests.csproj | 25 +
.../X11InteropLayoutTests.cs | 49 ++
.../X11ScreenGeometryTests.cs | 57 ++
.../X11WindowActivationTests.cs | 30 ++
23 files changed, 1194 insertions(+), 401 deletions(-)
create mode 100644 src/DesktopFlyouts.Shared/Helpers/X11ScreenGeometry.cs
create mode 100644 src/DesktopFlyouts.Shared/Helpers/X11WindowActivation.cs
create mode 100644 src/DesktopFlyouts.Uno/GeneratorBackfill/DesktopFlyoutIslandTemplateSettings.Uno.cs
create mode 100644 tests/DesktopFlyouts.X11.Tests/DesktopFlyouts.X11.Tests.csproj
create mode 100644 tests/DesktopFlyouts.X11.Tests/X11InteropLayoutTests.cs
create mode 100644 tests/DesktopFlyouts.X11.Tests/X11ScreenGeometryTests.cs
create mode 100644 tests/DesktopFlyouts.X11.Tests/X11WindowActivationTests.cs
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 9084dc6..fbc41cf 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -25,6 +25,8 @@ env:
WASDK_APP_PROJ_PATH: '${{ github.workspace }}\samples\DesktopFlyouts.Wasdk.Sample.App\DesktopFlyouts.Wasdk.Sample.App.csproj'
UWP_APP_PROJ_PATH: '${{ github.workspace }}\samples\DesktopFlyouts.Uwp.Sample.App\DesktopFlyouts.Uwp.Sample.App.csproj'
UNO_APP_PROJ_PATH: '${{ github.workspace }}\samples\DesktopFlyouts.Uno.Sample.App\DesktopFlyouts.Uno.Sample.App.csproj'
+ UNO_LIB_PROJ_PATH: '${{ github.workspace }}/src/DesktopFlyouts.Uno/DesktopFlyouts.Uno.csproj'
+ X11_TEST_PROJ_PATH: '${{ github.workspace }}/tests/DesktopFlyouts.X11.Tests/DesktopFlyouts.X11.Tests.csproj'
jobs:
build-wasdk:
@@ -109,3 +111,33 @@ jobs:
- name: Build the sample app
run: dotnet build $env:UNO_APP_PROJ_PATH -c $env:CONFIGURATION -a $env:PLATFORM --no-restore
+
+ build-test-uno-linux:
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout the repository
+ uses: actions/checkout@v6
+ with:
+ fetch-depth: 0
+
+ - name: Setup .NET
+ uses: actions/setup-dotnet@v5
+ with:
+ dotnet-version: |
+ 9.0.x
+ 10.0.x
+
+ - name: Restore the Uno library
+ run: dotnet restore "$UNO_LIB_PROJ_PATH"
+
+ - name: Build the Uno library on Linux
+ run: dotnet build "$UNO_LIB_PROJ_PATH" -c Release --no-restore
+
+ - name: Run X11 unit tests
+ run: dotnet test "$X11_TEST_PROJ_PATH" -c Release
+
+ - name: Check changed-file whitespace
+ if: github.event_name == 'pull_request'
+ shell: bash
+ run: git diff --check "$(git merge-base HEAD "origin/$GITHUB_BASE_REF")"..HEAD
diff --git a/DesktopFlyouts.slnx b/DesktopFlyouts.slnx
index d04044e..d1a9b3f 100644
--- a/DesktopFlyouts.slnx
+++ b/DesktopFlyouts.slnx
@@ -65,5 +65,8 @@
+
+
+
diff --git a/Directory.Build.targets b/Directory.Build.targets
index fae66ae..6df7011 100644
--- a/Directory.Build.targets
+++ b/Directory.Build.targets
@@ -5,7 +5,7 @@
Debug;Release
x86;x64;arm64
- win-x86;win-x64;win-arm64
+ win-x86;win-x64;win-arm64
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 4824bf8..1ac4bac 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -20,9 +20,12 @@
+
+
+
diff --git a/src/DesktopFlyouts.Shared/DesktopFlyout.cs b/src/DesktopFlyouts.Shared/DesktopFlyout.cs
index 48a5a76..f2ec82c 100644
--- a/src/DesktopFlyouts.Shared/DesktopFlyout.cs
+++ b/src/DesktopFlyouts.Shared/DesktopFlyout.cs
@@ -1319,7 +1319,11 @@ private void HostWindow_SystemSettingsChanged(object? sender, EventArgs e)
}
///
+#if HAS_UNO
+ public new void Dispose()
+#else
public void Dispose()
+#endif
{
if (_disposed)
return;
@@ -1349,6 +1353,9 @@ public void Dispose()
_host?.Dispose();
IsOpen = false;
+#if HAS_UNO
+ base.Dispose();
+#endif
GC.SuppressFinalize(this);
}
}
diff --git a/src/DesktopFlyouts.Shared/DesktopFlyoutIsland.cs b/src/DesktopFlyouts.Shared/DesktopFlyoutIsland.cs
index 549cc01..c2684d4 100644
--- a/src/DesktopFlyouts.Shared/DesktopFlyoutIsland.cs
+++ b/src/DesktopFlyouts.Shared/DesktopFlyoutIsland.cs
@@ -117,7 +117,7 @@ private void DesktopFlyoutIsland_LayoutUpdated(object? sender, object e)
private void UpdateTemplateSettings()
{
-#if WASDK && !HAS_UNO
+#if WASDK
TemplateSettings.BackdropCornerRadius = new(
GetBackdropCornerRadius(CornerRadius.TopLeft),
GetBackdropCornerRadius(CornerRadius.TopRight),
@@ -126,12 +126,13 @@ private void UpdateTemplateSettings()
#endif
}
-#if WASDK && !HAS_UNO
+#if WASDK
private static double GetBackdropCornerRadius(double cornerRadius)
{
return Math.Max(0D, cornerRadius > 0D ? cornerRadius - 1D : 0D);
}
+#if !HAS_UNO
internal void UpdateOwnerBackdrop()
{
TemplateSettings.SystemBackdrop = _owner is not null && _owner.TryGetTarget(out var owner)
@@ -143,6 +144,7 @@ internal void ClearOwnerBackdrop()
{
TemplateSettings.SystemBackdrop = null;
}
+#endif
#endif
private static bool AreClose(Size first, Size second)
diff --git a/src/DesktopFlyouts.Shared/DesktopFlyoutIslandTemplateSettings.cs b/src/DesktopFlyouts.Shared/DesktopFlyoutIslandTemplateSettings.cs
index b8eb0ef..6d4ee1f 100644
--- a/src/DesktopFlyouts.Shared/DesktopFlyoutIslandTemplateSettings.cs
+++ b/src/DesktopFlyouts.Shared/DesktopFlyoutIslandTemplateSettings.cs
@@ -20,7 +20,7 @@ namespace DesktopFlyouts
///
public partial class DesktopFlyoutIslandTemplateSettings : DependencyObject
{
-#if WASDK && !HAS_UNO
+#if WASDK
///
/// Gets the corner radius used by backdrop elements inside the island template.
///
@@ -28,12 +28,14 @@ public partial class DesktopFlyoutIslandTemplateSettings : DependencyObject
[GeneratedDependencyProperty]
public partial CornerRadius BackdropCornerRadius { get; internal set; }
+#if !HAS_UNO
///
/// Gets the library-created system backdrop used by backdrop elements inside the island template.
///
/// The owning flyout's generated system backdrop for this island.
[GeneratedDependencyProperty]
public partial SystemBackdrop? SystemBackdrop { get; internal set; }
+#endif
#endif
}
}
diff --git a/src/DesktopFlyouts.Shared/DesktopFlyouts.Shared.projitems b/src/DesktopFlyouts.Shared/DesktopFlyouts.Shared.projitems
index 2aecb33..28f8297 100644
--- a/src/DesktopFlyouts.Shared/DesktopFlyouts.Shared.projitems
+++ b/src/DesktopFlyouts.Shared/DesktopFlyouts.Shared.projitems
@@ -39,6 +39,8 @@
+
+
@@ -47,4 +49,4 @@
-
\ No newline at end of file
+
diff --git a/src/DesktopFlyouts.Shared/DesktopMenuFlyout.Uno.cs b/src/DesktopFlyouts.Shared/DesktopMenuFlyout.Uno.cs
index 428fd2f..92a00f4 100644
--- a/src/DesktopFlyouts.Shared/DesktopMenuFlyout.Uno.cs
+++ b/src/DesktopFlyouts.Shared/DesktopMenuFlyout.Uno.cs
@@ -309,7 +309,7 @@ private static double Clamp(double value, double min, double max)
}
///
- public void Dispose()
+ public new void Dispose()
{
if (_disposed)
return;
@@ -332,6 +332,7 @@ public void Dispose()
_host?.Dispose();
IsOpen = false;
+ base.Dispose();
GC.SuppressFinalize(this);
}
}
diff --git a/src/DesktopFlyouts.Shared/DesktopMenuFlyout.cs b/src/DesktopFlyouts.Shared/DesktopMenuFlyout.cs
index 2f830aa..aae89b9 100644
--- a/src/DesktopFlyouts.Shared/DesktopMenuFlyout.cs
+++ b/src/DesktopFlyouts.Shared/DesktopMenuFlyout.cs
@@ -7,6 +7,7 @@
#if UWP
+using Windows.Graphics;
using Windows.UI.Xaml;
using Windows.UI.Xaml.Controls;
using Windows.UI.Xaml.Markup;
diff --git a/src/DesktopFlyouts.Shared/Helpers/GeneralHelpers.X11.cs b/src/DesktopFlyouts.Shared/Helpers/GeneralHelpers.X11.cs
index c419d6a..87217c7 100644
--- a/src/DesktopFlyouts.Shared/Helpers/GeneralHelpers.X11.cs
+++ b/src/DesktopFlyouts.Shared/Helpers/GeneralHelpers.X11.cs
@@ -1,92 +1,140 @@
#if HAS_UNO
-// Real implementation: detects system theme via D-Bus org.freedesktop.portal.Settings.
+// Detects the system theme via D-Bus org.freedesktop.portal.Settings.
// https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.Settings.html
-using System;
-using System.Threading.Tasks;
-using Tmds.DBus.Protocol;
using DesktopFlyouts.DBus;
+using Tmds.DBus.Protocol;
+
+namespace DesktopFlyouts;
-namespace DesktopFlyouts
+internal static class GeneralHelpers
{
- internal static class GeneralHelpers
+ private const string Service = "org.freedesktop.portal.Desktop";
+ private const string ObjectPath = "/org/freedesktop/portal/desktop";
+
+ private static readonly object SyncRoot = new();
+ private static bool _isTaskbarLight;
+ private static Task? _initializationTask;
+ private static DBusConnection? _connection;
+ private static IDisposable? _settingsWatch;
+
+ static GeneralHelpers()
{
- private const string Service = "org.freedesktop.portal.Desktop";
- private const string ObjectPath = "/org/freedesktop/portal/desktop";
+ AppDomain.CurrentDomain.ProcessExit += (_, _) => DisposeResources();
+ }
+
+ internal static event EventHandler? SystemSettingsChanged;
+
+ internal static bool IsTaskbarLight()
+ {
+ ThrowHelper.ThrowIfNotLinux();
+
+ lock (SyncRoot)
+ {
+ _initializationTask ??= InitializeAsync();
+ return _isTaskbarLight;
+ }
+ }
- private static bool? _isTaskbarLight;
- private static readonly object _lock = new();
- private static bool _initialized;
+ internal static bool IsTaskbarColorPrevalenceEnabled()
+ {
+ // Linux desktop environments do not have a Windows-style "accent color on taskbar" setting.
+ return false;
+ }
- internal static bool IsTaskbarLight()
+ private static async Task InitializeAsync()
+ {
+ DBusConnection? connection = null;
+ IDisposable? settingsWatch = null;
+ try
{
- ThrowHelper.ThrowIfNotLinux();
+ var sessionAddress = DBusAddress.Session;
+ if (sessionAddress is null)
+ return;
+
+ connection = new DBusConnection(sessionAddress);
+ await connection.ConnectAsync();
- if (!_initialized)
+ var desktopService = new DBusService(connection, Service);
+ var settings = desktopService.CreateSettings(ObjectPath);
+ if (await settings.GetVersionAsync() < 2)
+ return;
+
+ var result = await settings.ReadOneAsync(
+ "org.freedesktop.appearance",
+ "color-scheme");
+ UpdateTheme(result.GetUInt32());
+
+ settingsWatch = await settings.WatchSettingChangedAsync(tuple =>
{
- _ = InitAsync().ConfigureAwait(false);
- }
+ if (tuple is { Namespace: "org.freedesktop.appearance", Key: "color-scheme" })
+ UpdateTheme(tuple.Value.GetUInt32());
+ });
- lock (_lock)
+ lock (SyncRoot)
{
- return _isTaskbarLight ?? false;
+ _connection = connection;
+ _settingsWatch = settingsWatch;
+ connection = null;
+ settingsWatch = null;
}
}
-
- internal static bool IsTaskbarColorPrevalenceEnabled()
+ catch
{
- // Linux desktop environments do not have a Windows-style "accent color on taskbar" setting.
- return false;
+ // D-Bus or the portal is unavailable. Keep the dark fallback for this process.
}
+ finally
+ {
+ settingsWatch?.Dispose();
+ connection?.Dispose();
+ }
+ }
- private static async Task InitAsync()
+ private static void UpdateTheme(uint colorScheme)
+ {
+ // 0 = no preference, 1 = dark, 2 = light.
+ var isLight = colorScheme != 1;
+ var changed = false;
+ lock (SyncRoot)
{
- try
+ if (_isTaskbarLight != isLight)
{
- var sessionAddress = DBusAddress.Session;
- if (sessionAddress is null)
- return;
-
- var connection = new DBusConnection(sessionAddress);
- await connection.ConnectAsync();
-
- var desktopService = new DBusService(connection, Service);
- var settings = desktopService.CreateSettings(ObjectPath);
-
- var version = await settings.GetVersionAsync();
- if (version < 2)
- return;
-
- var result = await settings.ReadOneAsync("org.freedesktop.appearance", "color-scheme");
- var colorScheme = result.GetUInt32();
- // 0 = no preference, 1 = dark, 2 = light
- lock (_lock)
- {
- _isTaskbarLight = colorScheme != 1;
- _initialized = true;
- }
-
- _ = settings.WatchSettingChangedAsync(tuple =>
- {
- if (tuple is { Namespace: "org.freedesktop.appearance", Key: "color-scheme" })
- {
- lock (_lock)
- {
- _isTaskbarLight = tuple.Value.GetUInt32() != 1;
- }
- }
- });
- }
- catch
- {
- // D-Bus not available or portal not present; fall back to default (dark).
- lock (_lock)
- {
- _isTaskbarLight = false;
- _initialized = true;
- }
+ _isTaskbarLight = isLight;
+ changed = true;
}
}
+
+ if (changed)
+ SystemSettingsChanged?.Invoke(null, EventArgs.Empty);
+ }
+
+ private static void DisposeResources()
+ {
+ IDisposable? settingsWatch;
+ DBusConnection? connection;
+ lock (SyncRoot)
+ {
+ settingsWatch = _settingsWatch;
+ connection = _connection;
+ _settingsWatch = null;
+ _connection = null;
+ }
+
+ try
+ {
+ settingsWatch?.Dispose();
+ }
+ catch
+ {
+ }
+
+ try
+ {
+ connection?.Dispose();
+ }
+ catch
+ {
+ }
}
}
#endif
diff --git a/src/DesktopFlyouts.Shared/Helpers/WindowHelpers.X11.cs b/src/DesktopFlyouts.Shared/Helpers/WindowHelpers.X11.cs
index 1586c97..84d2d66 100644
--- a/src/DesktopFlyouts.Shared/Helpers/WindowHelpers.X11.cs
+++ b/src/DesktopFlyouts.Shared/Helpers/WindowHelpers.X11.cs
@@ -4,7 +4,7 @@
// _NET_WM_STRUT_PARTIAL on child windows identifies individual panel positions.
using System;
-using System.Diagnostics;
+using System.Collections.Generic;
using System.Drawing;
using System.Runtime.InteropServices;
using static DesktopFlyouts.X11PInvoke;
@@ -52,17 +52,15 @@ internal static Rectangle GetFlyoutWorkAreaRect(Point? anchorPoint = null)
return new Rectangle(0, 0, 1920, 1080);
}
- // Read _NET_WORKAREA (4 CARDINALs: left, top, width, height)
+ var hasMonitor = TryReadMonitorRect(display, rootWindow, anchorPoint, out var monitorRect);
+ if (!hasMonitor && TryReadScreenDimensions(display, out var screenRect))
+ monitorRect = screenRect;
+
if (TryReadNetWorkArea(display, rootWindow, out var workArea))
- {
- return workArea;
- }
+ return X11ScreenGeometry.ApplyWorkArea(monitorRect, workArea);
- // Fallback: full screen
- if (TryReadScreenDimensions(display, out var screenRect))
- {
- return screenRect;
- }
+ if (monitorRect.Width > 0 && monitorRect.Height > 0)
+ return monitorRect;
// all methods failed, using default 1920x1080
return new Rectangle(0, 0, 1920, 1080);
@@ -90,14 +88,11 @@ internal static bool TryGetTaskbarInfoForPoint(Point point, out Rectangle rect,
if (rootWindow == IntPtr.Zero)
return false;
- // Enumerate child windows and check _NET_WM_STRUT_PARTIAL / _NET_WM_STRUT
- if (!TryReadNetWorkArea(display, rootWindow, out var workArea))
+ // Strut coordinates are relative to the complete root window, not an
+ // individual XRandR monitor.
+ if (!TryReadScreenDimensions(display, out var screenRect))
return false;
- var screenRect = workArea;
- if (TryReadScreenDimensions(display, out var full))
- screenRect = full;
-
return TryFindPanelAtPoint(display, rootWindow, point, screenRect, out rect, out edge);
}
finally
@@ -111,18 +106,62 @@ private static bool TryReadNetWorkArea(IntPtr display, IntPtr rootWindow, out Re
workArea = default;
var netWorkAreaAtom = XInternAtom(display, "_NET_WORKAREA", false);
- if (ReadCardinals(display, rootWindow, netWorkAreaAtom, out var cardinals) && cardinals.Length >= 4)
+ if (!ReadCardinals(display, rootWindow, netWorkAreaAtom, out var cardinals))
+ return false;
+
+ nuint currentDesktop = 0;
+ var currentDesktopAtom = XInternAtom(display, "_NET_CURRENT_DESKTOP", false);
+ if (ReadCardinals(display, rootWindow, currentDesktopAtom, out var desktopValues) &&
+ desktopValues.Length > 0)
+ currentDesktop = desktopValues[0];
+
+ return X11ScreenGeometry.TrySelectWorkArea(cardinals, currentDesktop, out workArea);
+ }
+
+ private static bool TryReadMonitorRect(
+ IntPtr display,
+ IntPtr rootWindow,
+ Point? anchorPoint,
+ out Rectangle monitorRect)
+ {
+ monitorRect = default;
+ nint monitorsPointer = 0;
+ try
{
- workArea = new Rectangle(
- (int)cardinals[0],
- (int)cardinals[1],
- (int)cardinals[2],
- (int)cardinals[3]);
- return workArea.Width > 0 && workArea.Height > 0;
- }
+ monitorsPointer = XRRGetMonitors(display, rootWindow, true, out var monitorCount);
+ if (monitorsPointer is 0 || monitorCount <= 0)
+ return false;
- // _NET_WORKAREA not available or empty
- return false;
+ var monitorSize = Marshal.SizeOf();
+ var monitors = new List(monitorCount);
+ for (var i = 0; i < monitorCount; i++)
+ {
+ var monitor = Marshal.PtrToStructure(
+ monitorsPointer + (i * monitorSize));
+ if (monitor.width <= 0 || monitor.height <= 0)
+ continue;
+
+ monitors.Add(new X11MonitorGeometry(
+ new Rectangle(monitor.x, monitor.y, monitor.width, monitor.height),
+ monitor.primary != 0));
+ }
+
+ monitorRect = X11ScreenGeometry.SelectMonitor(monitors, anchorPoint);
+ return monitorRect.Width > 0 && monitorRect.Height > 0;
+ }
+ catch (DllNotFoundException)
+ {
+ return false;
+ }
+ catch (EntryPointNotFoundException)
+ {
+ return false;
+ }
+ finally
+ {
+ if (monitorsPointer is not 0)
+ XRRFreeMonitors(monitorsPointer);
+ }
}
private static bool TryReadScreenDimensions(IntPtr display, out Rectangle screenRect)
@@ -306,11 +345,12 @@ private static bool ReadCardinals(IntPtr display, nint window, nint atom, out nu
var result = XGetWindowProperty(
display, (nuint)window, (nuint)atom,
0, 256,
- false, 6, // CARDINAL = 6
- out var actualType, out _,
+ false, XA_CARDINAL,
+ out var actualType, out var actualFormat,
out var nItems, out _, out var prop);
- if (result != 0 || actualType is 0 || nItems is 0 || prop is 0)
+ if (result != 0 || actualType != XA_CARDINAL || actualFormat != 32 ||
+ nItems is 0 || nItems > int.MaxValue || prop is 0)
return false;
try
diff --git a/src/DesktopFlyouts.Shared/Helpers/X11ScreenGeometry.cs b/src/DesktopFlyouts.Shared/Helpers/X11ScreenGeometry.cs
new file mode 100644
index 0000000..b369344
--- /dev/null
+++ b/src/DesktopFlyouts.Shared/Helpers/X11ScreenGeometry.cs
@@ -0,0 +1,102 @@
+#if HAS_UNO
+using System.Drawing;
+
+namespace DesktopFlyouts;
+
+internal readonly record struct X11MonitorGeometry(Rectangle Bounds, bool IsPrimary);
+
+internal static class X11ScreenGeometry
+{
+ internal static Rectangle SelectMonitor(
+ IReadOnlyList monitors,
+ Point? anchorPoint)
+ {
+ if (monitors.Count is 0)
+ return default;
+
+ if (anchorPoint is { } anchor)
+ {
+ foreach (var monitor in monitors)
+ {
+ if (monitor.Bounds.Contains(anchor))
+ return monitor.Bounds;
+ }
+
+ var nearest = monitors[0].Bounds;
+ var nearestDistance = GetSquaredDistance(anchor, nearest);
+ for (var i = 1; i < monitors.Count; i++)
+ {
+ var candidate = monitors[i].Bounds;
+ var candidateDistance = GetSquaredDistance(anchor, candidate);
+ if (candidateDistance < nearestDistance)
+ {
+ nearest = candidate;
+ nearestDistance = candidateDistance;
+ }
+ }
+
+ return nearest;
+ }
+
+ foreach (var monitor in monitors)
+ {
+ if (monitor.IsPrimary)
+ return monitor.Bounds;
+ }
+
+ return monitors[0].Bounds;
+ }
+
+ internal static bool TrySelectWorkArea(
+ ReadOnlySpan values,
+ nuint currentDesktop,
+ out Rectangle workArea)
+ {
+ workArea = default;
+ if (values.Length < 4)
+ return false;
+
+ var desktopOffset = currentDesktop <= int.MaxValue / 4
+ ? (int)currentDesktop * 4
+ : 0;
+ if (desktopOffset < 0 || desktopOffset + 3 >= values.Length)
+ desktopOffset = 0;
+
+ workArea = new Rectangle(
+ ToCoordinate(values[desktopOffset]),
+ ToCoordinate(values[desktopOffset + 1]),
+ ToDimension(values[desktopOffset + 2]),
+ ToDimension(values[desktopOffset + 3]));
+ return workArea.Width > 0 && workArea.Height > 0;
+ }
+
+ internal static Rectangle ApplyWorkArea(Rectangle monitor, Rectangle workArea)
+ {
+ if (monitor.Width <= 0 || monitor.Height <= 0)
+ return workArea;
+
+ if (workArea.Width <= 0 || workArea.Height <= 0)
+ return monitor;
+
+ var intersection = Rectangle.Intersect(monitor, workArea);
+ return intersection.Width > 0 && intersection.Height > 0
+ ? intersection
+ : monitor;
+ }
+
+ private static long GetSquaredDistance(Point point, Rectangle rectangle)
+ {
+ var nearestX = Math.Clamp(point.X, rectangle.Left, rectangle.Right - 1);
+ var nearestY = Math.Clamp(point.Y, rectangle.Top, rectangle.Bottom - 1);
+ var deltaX = (long)point.X - nearestX;
+ var deltaY = (long)point.Y - nearestY;
+ return (deltaX * deltaX) + (deltaY * deltaY);
+ }
+
+ private static int ToCoordinate(nuint value)
+ => unchecked((int)(uint)value);
+
+ private static int ToDimension(nuint value)
+ => value > int.MaxValue ? int.MaxValue : (int)value;
+}
+#endif
diff --git a/src/DesktopFlyouts.Shared/Helpers/X11WindowActivation.cs b/src/DesktopFlyouts.Shared/Helpers/X11WindowActivation.cs
new file mode 100644
index 0000000..3d69fe0
--- /dev/null
+++ b/src/DesktopFlyouts.Shared/Helpers/X11WindowActivation.cs
@@ -0,0 +1,14 @@
+#if HAS_UNO
+namespace DesktopFlyouts;
+
+internal static class X11WindowActivation
+{
+ internal static bool UsesOverrideRedirect(DesktopFlyoutActivationMode activationMode)
+ => activationMode is DesktopFlyoutActivationMode.NeverActivate;
+
+ internal static bool ShouldRequestActivation(
+ DesktopFlyoutActivationMode activationMode,
+ bool activate)
+ => activate && activationMode is DesktopFlyoutActivationMode.Activate;
+}
+#endif
diff --git a/src/DesktopFlyouts.Shared/SystemTrayIcon.X11.cs b/src/DesktopFlyouts.Shared/SystemTrayIcon.X11.cs
index 8789311..e901e2f 100644
--- a/src/DesktopFlyouts.Shared/SystemTrayIcon.X11.cs
+++ b/src/DesktopFlyouts.Shared/SystemTrayIcon.X11.cs
@@ -19,14 +19,19 @@ namespace DesktopFlyouts;
public class SystemTrayIcon : IDisposable
{
readonly string _id;
+ readonly object _lifecycleLock = new();
+ readonly SynchronizationContext? _eventContext;
DBusConnection? _connection;
DBus.DBus? _dBus;
StatusNotifierWatcher? _statusNotifierWatcher;
X11StatusNotifierItemHandler? _sniHandler;
IDisposable? _serviceWatchDisposable;
- string? _sysTrayServiceName;
+ CancellationTokenSource? _connectionCancellation;
+ int _connectionGeneration;
bool _isDisposed;
bool _serviceConnected;
+ bool _isRegistered;
+ bool _registrationPending;
bool _isVisible;
(int, int, byte[]) _currentIcon = (1, 1, new byte[] { 255, 0, 0, 0 });
@@ -48,8 +53,8 @@ public SystemTrayIcon(string iconPath, string tooltip, System.Guid id)
/// The tooltip text.
/// The stable identifier for the tray icon.
///
- /// Construction prepares the icon resources and begins D-Bus initialization. The
- /// icon is not registered with the StatusNotifierWatcher until is called.
+ /// Construction prepares the icon resources. D-Bus initialization and registration
+ /// begin when is called.
///
public SystemTrayIcon(string iconPath, string tooltip, string id)
{
@@ -57,44 +62,37 @@ public SystemTrayIcon(string iconPath, string tooltip, string id)
_id = id;
_iconPath = iconPath;
_tooltip = tooltip;
+ _eventContext = SynchronizationContext.Current;
- // Render the initial icon synchronously so that any SetIcon() call
- // during async initialization is not overwritten by the captured path.
_currentIcon = RenderIcon(iconPath);
-
- _ = InitAsync();
-
- async Task InitAsync()
- {
- await InitializeAsync();
- _sniHandler!.ActivationDelegate += OnActivation;
- _sniHandler!.ContextMenuDelegate += OnContextMenu;
- _sniHandler!.SecondaryActivateDelegate += OnSecondaryActivate;
- _sniHandler!.ScrollDelegate += OnScroll;
- }
}
void OnActivation(int x, int y)
{
- LeftClicked?.Invoke(this, new MouseEventReceivedEventArgs(new(x, y)));
+ RaiseOnCapturedContext(() =>
+ LeftClicked?.Invoke(this, new MouseEventReceivedEventArgs(new(x, y))));
}
void OnContextMenu(int x, int y)
{
- RightClicked?.Invoke(this, new MouseEventReceivedEventArgs(new(x, y)));
+ RaiseOnCapturedContext(() =>
+ RightClicked?.Invoke(this, new MouseEventReceivedEventArgs(new(x, y))));
}
void OnSecondaryActivate(int x, int y)
{
- MiddleClicked?.Invoke(this, new MouseEventReceivedEventArgs(new(x, y)));
+ RaiseOnCapturedContext(() =>
+ MiddleClicked?.Invoke(this, new MouseEventReceivedEventArgs(new(x, y))));
}
void OnScroll(int delta, string orientation)
{
- Scrolled?.Invoke(this, new MouseScrollEventReceivedEventArgs(
- delta,
- orientation == "horizontal" ? MouseScrollOrientation.Horizontal : MouseScrollOrientation.Vertical
- ));
+ RaiseOnCapturedContext(() =>
+ Scrolled?.Invoke(this, new MouseScrollEventReceivedEventArgs(
+ delta,
+ orientation == "horizontal"
+ ? MouseScrollOrientation.Horizontal
+ : MouseScrollOrientation.Vertical)));
}
// ─── Public API ────────────────────────────────────────────────
@@ -109,15 +107,24 @@ void OnScroll(int delta, string orientation)
///
public string Tooltip
{
- get => _tooltip;
+ get
+ {
+ lock (_lifecycleLock)
+ return _tooltip;
+ }
set
{
- if (_isDisposed)
- throw new ObjectDisposedException(nameof(SystemTrayIcon));
+ X11StatusNotifierItemHandler? handler;
+ lock (_lifecycleLock)
+ {
+ if (_isDisposed)
+ throw new ObjectDisposedException(nameof(SystemTrayIcon));
- _tooltip = value;
- if (_sniHandler?.Connection is not null)
- _sniHandler.SetTitleAndTooltip(value ?? "");
+ _tooltip = value;
+ handler = _isRegistered ? _sniHandler : null;
+ }
+
+ handler?.SetTitleAndTooltip(value);
}
}
@@ -136,8 +143,25 @@ public string Tooltip
///
public void SetIcon(string iconPath)
{
- _iconPath = iconPath;
- UpdateIcon(iconPath);
+ lock (_lifecycleLock)
+ {
+ if (_isDisposed)
+ throw new ObjectDisposedException(nameof(SystemTrayIcon));
+ }
+
+ var icon = RenderIcon(iconPath);
+ X11StatusNotifierItemHandler? handler;
+ lock (_lifecycleLock)
+ {
+ if (_isDisposed)
+ throw new ObjectDisposedException(nameof(SystemTrayIcon));
+
+ _iconPath = iconPath;
+ _currentIcon = icon;
+ handler = _isRegistered ? _sniHandler : null;
+ }
+
+ handler?.SetIcon(icon);
}
///
@@ -149,12 +173,35 @@ public void SetIcon(string iconPath)
///
public void Show()
{
- if (_isDisposed)
- throw new ObjectDisposedException(nameof(SystemTrayIcon));
+ int generation;
+ bool initialize;
+ bool register;
+ CancellationToken initializationToken = default;
+ lock (_lifecycleLock)
+ {
+ if (_isDisposed)
+ throw new ObjectDisposedException(nameof(SystemTrayIcon));
- _isVisible = true;
- if (_serviceConnected)
- _ = CreateTrayIconAsync();
+ _isVisible = true;
+ initialize = _connection is null && _connectionCancellation is null;
+ if (initialize)
+ {
+ _connectionCancellation = new CancellationTokenSource();
+ generation = ++_connectionGeneration;
+ initializationToken = _connectionCancellation.Token;
+ }
+ else
+ {
+ generation = _connectionGeneration;
+ }
+
+ register = _serviceConnected && !_isRegistered && !_registrationPending;
+ }
+
+ if (initialize)
+ _ = InitializeAsync(generation, initializationToken);
+ else if (register)
+ _ = CreateTrayIconAsync(generation);
}
///
@@ -166,11 +213,7 @@ public void Show()
///
public void Destroy()
{
- if (_isDisposed)
- throw new ObjectDisposedException(nameof(SystemTrayIcon));
-
- _isVisible = false;
- DestroyTrayIcon();
+ CloseConnection(disposeObject: false);
}
///
@@ -183,32 +226,15 @@ public void Destroy()
///
public void Dispose()
{
- if (_isDisposed)
- return;
-
- _isDisposed = true;
- _isVisible = false;
-
- DestroyTrayIcon();
-
- _serviceWatchDisposable?.Dispose();
- _serviceWatchDisposable = null;
- _connection?.Dispose();
- _connection = null;
- _dBus = null;
- _statusNotifierWatcher = null;
- _serviceConnected = false;
-
+ CloseConnection(disposeObject: true);
GC.SuppressFinalize(this);
}
- ~SystemTrayIcon() => Dispose();
-
///
/// Gets or sets whether the tray icon is visible.
///
/// if the tray icon is visible; otherwise,
- /// . The default is .
+ /// . The default is .
///
/// If the tray icon is already registered with the StatusNotifierWatcher, setting this property
/// updates the icon state immediately. Otherwise, the value is recorded and applied when
@@ -216,7 +242,11 @@ public void Dispose()
///
public bool IsVisible
{
- get => _isVisible;
+ get
+ {
+ lock (_lifecycleLock)
+ return _isVisible;
+ }
set
{
if (value) Show();
@@ -267,147 +297,355 @@ public bool IsVisible
// ─── D-Bus Initialization ─────────────────────────────────────
- async Task InitializeAsync(CancellationToken cancellationToken = default)
+ async Task InitializeAsync(int generation, CancellationToken cancellationToken)
{
- if (_isDisposed)
- throw new ObjectDisposedException(nameof(SystemTrayIcon));
-
- _connection = new DBusConnection(DBusAddress.Session!);
- await _connection.ConnectAsync();
+ DBusConnection? connection = null;
+ X11StatusNotifierItemHandler? handler = null;
+ var attached = false;
+ try
+ {
+ var sessionAddress = DBusAddress.Session;
+ if (sessionAddress is null)
+ return;
+
+ connection = new DBusConnection(sessionAddress);
+ await connection.ConnectAsync();
+ cancellationToken.ThrowIfCancellationRequested();
+
+ var dBus = new DBus.DBus(connection, "org.freedesktop.DBus", "/org/freedesktop/DBus");
+ handler = new X11StatusNotifierItemHandler(connection, _id, _id);
+ handler.ActivationDelegate += OnActivation;
+ handler.ContextMenuDelegate += OnContextMenu;
+ handler.SecondaryActivateDelegate += OnSecondaryActivate;
+ handler.ScrollDelegate += OnScroll;
+ connection.AddMethodHandler(handler);
+
+ lock (_lifecycleLock)
+ {
+ if (_isDisposed || !_isVisible || generation != _connectionGeneration)
+ return;
- _dBus = new(_connection, "org.freedesktop.DBus", "/org/freedesktop/DBus");
+ _connection = connection;
+ _dBus = dBus;
+ _sniHandler = handler;
+ attached = true;
+ }
- _sniHandler = new X11StatusNotifierItemHandler(_connection, _id, _id);
+ connection = null;
+ handler = null;
+ await WatchAsync(generation, dBus, cancellationToken);
+ }
+ catch (OperationCanceledException)
+ {
+ }
+ catch
+ {
+ }
+ finally
+ {
+ if (!attached)
+ {
+ DetachHandler(handler);
+ try
+ {
+ if (connection is not null && handler is not null)
+ connection.RemoveMethodHandler(handler.Path);
+ }
+ catch
+ {
+ }
- await WatchAsync(cancellationToken);
+ connection?.Dispose();
+ lock (_lifecycleLock)
+ {
+ if (generation == _connectionGeneration && _connection is null)
+ {
+ _connectionCancellation?.Dispose();
+ _connectionCancellation = null;
+ }
+ }
+ }
+ }
}
- async Task WatchAsync(CancellationToken cancellationToken)
+ async Task WatchAsync(
+ int generation,
+ DBus.DBus dBus,
+ CancellationToken cancellationToken)
{
+ IDisposable? watch = null;
try
{
- _serviceWatchDisposable = await _dBus!.WatchNameOwnerChangedAsync(
+ watch = await dBus.WatchNameOwnerChangedAsync(
change =>
{
if (change.A0 == "org.kde.StatusNotifierWatcher")
- OnNameChange(change.A0, change.A2);
+ OnNameChange(generation, change.A0, change.A2);
},
emitOnCapturedContext: false);
+ cancellationToken.ThrowIfCancellationRequested();
- var nameOwner = await _dBus.GetNameOwnerAsync("org.kde.StatusNotifierWatcher");
- OnNameChange("org.kde.StatusNotifierWatcher", nameOwner);
+ lock (_lifecycleLock)
+ {
+ if (_isDisposed || generation != _connectionGeneration)
+ return;
+
+ _serviceWatchDisposable = watch;
+ watch = null;
+ }
+
+ try
+ {
+ var nameOwner = await dBus.GetNameOwnerAsync("org.kde.StatusNotifierWatcher");
+ OnNameChange(generation, "org.kde.StatusNotifierWatcher", nameOwner);
+ }
+ catch (DBusErrorReplyException ex) when (
+ ex.ErrorName == "org.freedesktop.DBus.Error.NameHasNoOwner")
+ {
+ }
}
- catch (DBusErrorReplyException ex) when (ex.ErrorName == "org.freedesktop.DBus.Error.NameHasNoOwner")
+ catch (OperationCanceledException)
{
}
catch
{
- _serviceWatchDisposable = null;
+ }
+ finally
+ {
+ watch?.Dispose();
}
}
- void OnNameChange(string name, string? newOwner)
+ void OnNameChange(int generation, string name, string? newOwner)
{
- if (_isDisposed || _connection is null || name != "org.kde.StatusNotifierWatcher")
- return;
-
- if (!_serviceConnected && !string.IsNullOrEmpty(newOwner))
+ var register = false;
+ lock (_lifecycleLock)
{
- _serviceConnected = true;
- _statusNotifierWatcher = new StatusNotifierWatcher(_connection, "org.kde.StatusNotifierWatcher", "/StatusNotifierWatcher");
-
- // Re-register the handler on the existing connection.
- // No need to DestroyTrayIcon() here — the handler was already
- // removed when the watcher went away, and DestroyTrayIcon()
- // would dispose the connection we still need.
- _connection.RemoveMethodHandler(_sniHandler!.Path);
- _connection.AddMethodHandler(_sniHandler);
+ if (_isDisposed || generation != _connectionGeneration || _connection is null ||
+ name != "org.kde.StatusNotifierWatcher")
+ return;
- if (_isVisible)
- _ = CreateTrayIconAsync();
- }
- else if (_serviceConnected && string.IsNullOrEmpty(newOwner))
- {
- DestroyTrayIcon();
- _serviceConnected = false;
+ if (!string.IsNullOrEmpty(newOwner))
+ {
+ _serviceConnected = true;
+ _isRegistered = false;
+ _registrationPending = false;
+ _statusNotifierWatcher = new StatusNotifierWatcher(
+ _connection,
+ "org.kde.StatusNotifierWatcher",
+ "/StatusNotifierWatcher");
+ register = _isVisible;
+ }
+ else
+ {
+ _serviceConnected = false;
+ _isRegistered = false;
+ _registrationPending = false;
+ _statusNotifierWatcher = null;
+ }
}
+
+ if (register)
+ _ = CreateTrayIconAsync(generation);
}
- async Task CreateTrayIconAsync()
+ async Task CreateTrayIconAsync(int generation)
{
- if (_connection is null || !_serviceConnected || _isDisposed || _statusNotifierWatcher is null)
- return;
+ DBusConnection connection;
+ StatusNotifierWatcher watcher;
+ X11StatusNotifierItemHandler handler;
+ lock (_lifecycleLock)
+ {
+ if (_isDisposed || !_isVisible || !_serviceConnected || _isRegistered ||
+ _registrationPending ||
+ generation != _connectionGeneration || _connection is null ||
+ _statusNotifierWatcher is null || _sniHandler is null)
+ return;
+
+ _registrationPending = true;
+ connection = _connection;
+ watcher = _statusNotifierWatcher;
+ handler = _sniHandler;
+ }
try
{
- _connection.RemoveMethodHandler(_sniHandler!.Path);
- _connection.AddMethodHandler(_sniHandler);
+ await watcher.RegisterStatusNotifierItemAsync(connection.UniqueName!);
- await RegisterWithStatusNotifierWatcherAsync();
+ string tooltip;
+ (int, int, byte[]) icon;
+ CancellationToken cancellationToken;
+ lock (_lifecycleLock)
+ {
+ if (_isDisposed || !_isVisible || !_serviceConnected ||
+ generation != _connectionGeneration ||
+ !ReferenceEquals(connection, _connection) ||
+ !ReferenceEquals(watcher, _statusNotifierWatcher))
+ return;
+
+ _registrationPending = false;
+ _isRegistered = true;
+ tooltip = _tooltip;
+ icon = _currentIcon;
+ cancellationToken = _connectionCancellation?.Token ?? default;
+ }
- ReEmitSignalsForGnomeShellAsync();
+ handler.SetTitleAndTooltip(tooltip);
+ handler.SetIcon(icon);
+ _ = ReEmitSignalsForGnomeShellAsync(generation, cancellationToken);
}
catch
{
+ lock (_lifecycleLock)
+ {
+ if (generation == _connectionGeneration)
+ _registrationPending = false;
+ }
}
}
- async Task RegisterWithStatusNotifierWatcherAsync()
+ async Task ReEmitSignalsForGnomeShellAsync(
+ int generation,
+ CancellationToken cancellationToken)
{
- _sysTrayServiceName = _connection!.UniqueName!;
-
try
{
- await _statusNotifierWatcher!.RegisterStatusNotifierItemAsync(_sysTrayServiceName);
+ await Task.Delay(GnomeShellInitialDelayMs, cancellationToken);
+ ReEmitIconIfCurrent(generation);
+
+ await Task.Delay(GnomeShellSecondDelayMs, cancellationToken);
+ ReEmitIconIfCurrent(generation);
+ }
+ catch (OperationCanceledException)
+ {
}
catch
{
- throw;
}
-
- _sniHandler!.SetTitleAndTooltip(_tooltip);
- _sniHandler.SetIcon(_currentIcon);
}
- void ReEmitSignalsForGnomeShellAsync()
+ void ReEmitIconIfCurrent(int generation)
{
- _ = Task.Run(async () =>
+ X11StatusNotifierItemHandler? handler;
+ (int, int, byte[]) icon;
+ lock (_lifecycleLock)
{
- await Task.Delay(GnomeShellInitialDelayMs);
- if (!_isDisposed && _sniHandler?.Connection is not null)
- _sniHandler.SetIcon(_currentIcon);
+ if (_isDisposed || !_isVisible || !_isRegistered ||
+ generation != _connectionGeneration)
+ return;
+
+ handler = _sniHandler;
+ icon = _currentIcon;
+ }
- await Task.Delay(GnomeShellSecondDelayMs);
- if (!_isDisposed && _sniHandler?.Connection is not null)
- _sniHandler.SetIcon(_currentIcon);
- });
+ handler?.SetIcon(icon);
}
- void DestroyTrayIcon()
+ void CloseConnection(bool disposeObject)
{
- if (_sniHandler is null)
- return;
+ CancellationTokenSource? cancellation;
+ IDisposable? watch;
+ DBusConnection? connection;
+ X11StatusNotifierItemHandler? handler;
+ lock (_lifecycleLock)
+ {
+ if (_isDisposed)
+ {
+ if (disposeObject)
+ return;
+ throw new ObjectDisposedException(nameof(SystemTrayIcon));
+ }
+
+ if (disposeObject)
+ _isDisposed = true;
+
+ _isVisible = false;
+ _connectionGeneration++;
+ cancellation = _connectionCancellation;
+ watch = _serviceWatchDisposable;
+ connection = _connection;
+ handler = _sniHandler;
+ _connectionCancellation = null;
+ _serviceWatchDisposable = null;
+ _connection = null;
+ _dBus = null;
+ _statusNotifierWatcher = null;
+ _sniHandler = null;
+ _serviceConnected = false;
+ _isRegistered = false;
+ _registrationPending = false;
+ }
try
{
- _connection?.RemoveMethodHandler(_sniHandler.Path);
+ cancellation?.Cancel();
+ }
+ catch
+ {
+ }
+ cancellation?.Dispose();
+
+ DetachHandler(handler);
+ try
+ {
+ if (connection is not null && handler is not null)
+ connection.RemoveMethodHandler(handler.Path);
+ }
+ catch
+ {
+ }
+
+ try
+ {
+ watch?.Dispose();
}
catch
{
}
- }
- // ─── Icon Management ──────────────────────────────────────────
+ try
+ {
+ // Closing the unique D-Bus name is the StatusNotifierItem protocol's
+ // unregister operation. The watcher removes the item automatically.
+ connection?.Dispose();
+ }
+ catch
+ {
+ }
+ }
- void UpdateIcon(string iconPath)
+ void DetachHandler(X11StatusNotifierItemHandler? handler)
{
- if (_isDisposed)
- throw new ObjectDisposedException(nameof(SystemTrayIcon));
+ if (handler is null)
+ return;
- _currentIcon = RenderIcon(iconPath);
+ handler.ActivationDelegate -= OnActivation;
+ handler.ContextMenuDelegate -= OnContextMenu;
+ handler.SecondaryActivateDelegate -= OnSecondaryActivate;
+ handler.ScrollDelegate -= OnScroll;
+ }
- if (_sniHandler?.Connection is not null)
- _sniHandler.SetIcon(_currentIcon);
+ void RaiseOnCapturedContext(Action action)
+ {
+ if (_eventContext is null || ReferenceEquals(SynchronizationContext.Current, _eventContext))
+ {
+ bool invoke;
+ lock (_lifecycleLock)
+ invoke = !_isDisposed;
+ if (invoke)
+ action();
+ }
+ else
+ {
+ _eventContext.Post(_ =>
+ {
+ bool invoke;
+ lock (_lifecycleLock)
+ invoke = !_isDisposed;
+ if (invoke)
+ action();
+ }, null);
+ }
}
// ─── Icon Rendering ───────────────────────────────────────────
diff --git a/src/DesktopFlyouts.Shared/X11PInvoke.cs b/src/DesktopFlyouts.Shared/X11PInvoke.cs
index bb0780b..7f65837 100644
--- a/src/DesktopFlyouts.Shared/X11PInvoke.cs
+++ b/src/DesktopFlyouts.Shared/X11PInvoke.cs
@@ -7,6 +7,7 @@ partial class X11PInvoke
{
private const string LibX11 = "libX11.so.6";
private const string LibXext = "libXext.so.6";
+ private const string LibXrandr = "libXrandr.so.2";
[LibraryImport(LibX11)]
public static partial nint XOpenDisplay(nint display);
@@ -20,10 +21,32 @@ partial class X11PInvoke
[LibraryImport(LibX11, StringMarshalling = StringMarshalling.Utf8)]
public static partial nint XInternAtom(nint display, string atomName, [MarshalAs(UnmanagedType.Bool)] bool onlyIfExists);
- [LibraryImport(LibX11)]
- public static partial int XChangeProperty(
+ [LibraryImport(LibX11, EntryPoint = "XChangeProperty")]
+ private static partial int XChangePropertyNative(
nint display, nint window, nint property, nint type,
- int format, PropertyMode mode, byte[] data, int nelements);
+ int format, PropertyMode mode, nint data, int nelements);
+
+ public static unsafe int XChangeProperty32(
+ nint display,
+ nint window,
+ nint property,
+ nint type,
+ PropertyMode mode,
+ ReadOnlySpan data)
+ {
+ fixed (nuint* dataPointer = data)
+ {
+ return XChangePropertyNative(
+ display,
+ window,
+ property,
+ type,
+ 32,
+ mode,
+ (nint)dataPointer,
+ data.Length);
+ }
+ }
[LibraryImport(LibX11)]
public static partial int XMapWindow(nint display, nint window);
@@ -75,8 +98,8 @@ public static partial int XTranslateCoordinates(
[LibraryImport(LibXext)]
public static partial int XShapeCombineRegion(nint display, nint window, int destKind,
int xOff, int yOff, nint srcRegion, int op);
-
-
+
+
[LibraryImport(LibX11)]
public static partial nint XCreateRegion();
@@ -89,6 +112,19 @@ public static partial int XShapeCombineRegion(nint display, nint window, int des
[LibraryImport(LibX11)]
public static partial int XSetInputFocus(nint display, nint focus, int revertTo, nint time);
+ [LibraryImport(LibX11)]
+ public static partial int XGetInputFocus(nint display, out nint focusReturn, out int revertToReturn);
+
+ [LibraryImport(LibXrandr)]
+ public static partial nint XRRGetMonitors(
+ nint display,
+ nint window,
+ [MarshalAs(UnmanagedType.Bool)] bool getActive,
+ out int monitorCount);
+
+ [LibraryImport(LibXrandr)]
+ public static partial void XRRFreeMonitors(nint monitors);
+
[LibraryImport(LibX11)]
public static partial int XGetWindowProperty(
nint display,
@@ -126,13 +162,25 @@ public struct XSetWindowAttributes
{
public nint background_pixmap, background_pixel, border_pixmap, border_pixel;
public int bit_gravity, win_gravity, backing_store;
- public uint backing_planes, backing_pixel;
+ public nuint backing_planes, backing_pixel;
public int save_under;
public nint event_mask, do_not_propagate_mask;
public int override_redirect;
public nint colormap, cursor;
}
+ [StructLayout(LayoutKind.Sequential)]
+ public struct XRRMonitorInfo
+ {
+ public nuint name;
+ public int primary;
+ public int automatic;
+ public int noutput;
+ public int x, y, width, height;
+ public int mwidth, mheight;
+ public nint outputs;
+ }
+
[StructLayout(LayoutKind.Explicit)]
public struct XEvent
{
@@ -144,7 +192,7 @@ public struct XEvent
public struct XClientMessageEvent
{
public int type;
- public long serial;
+ public nuint serial;
public int send_event;
public nint display;
public nint window;
@@ -152,7 +200,7 @@ public struct XClientMessageEvent
public int format;
public nint ptr1, ptr2, ptr3, ptr4, ptr5;
}
-
+
[StructLayout(LayoutKind.Sequential)]
public struct XRegionRectangle
{
@@ -162,15 +210,19 @@ public struct XRegionRectangle
public const int ClientMessage = 33;
public const long StructureNotifyMask = 1L << 17;
+ public const long SubstructureNotifyMask = 1L << 19;
+ public const long SubstructureRedirectMask = 1L << 20;
public const long FocusChangeMask = 1L << 21;
public const long PropertyChangeMask = 1L << 22;
public const nint CWBackPixmap = 1 << 0;
public const nint CWOverrideRedirect = 1 << 9;
// Predefined X11 atom ID for type ATOM.
public const nint XA_ATOM = 4;
+ public const nuint XA_CARDINAL = 6;
+ public const nuint XA_WINDOW = 33;
// background_pixmap values
public const nint None = 0; // no background — transparent under compositors
public const nint ParentRelative = 1; // copy parent's background
}
-#endif
\ No newline at end of file
+#endif
diff --git a/src/DesktopFlyouts.Shared/XamlIslandHostWindow.X11.cs b/src/DesktopFlyouts.Shared/XamlIslandHostWindow.X11.cs
index cbe4cd1..d04bbd8 100644
--- a/src/DesktopFlyouts.Shared/XamlIslandHostWindow.X11.cs
+++ b/src/DesktopFlyouts.Shared/XamlIslandHostWindow.X11.cs
@@ -28,6 +28,7 @@ internal partial class XamlIslandHostWindow : IDisposable
private bool _disposed;
private bool _focused;
private bool _focusMonitoring;
+ private nint _previousActiveWindow;
private readonly DispatcherTimer _focusTimer;
private DesktopFlyoutActivationMode _activationMode = DesktopFlyoutActivationMode.Activate;
@@ -41,8 +42,23 @@ internal Rect WindowSize
return default;
var attrs = new XWindowAttributes();
- XGetWindowAttributes(_display, _x11Window, ref attrs);
- return new Rect(0, 0, attrs.Width, attrs.Height);
+ if (XGetWindowAttributes(_display, _x11Window, ref attrs) is 0)
+ return default;
+
+ var rootWindow = XDefaultRootWindow(_display);
+ if (rootWindow is not 0 &&
+ XTranslateCoordinates(
+ _display,
+ _x11Window,
+ rootWindow,
+ 0,
+ 0,
+ out var rootX,
+ out var rootY,
+ out _) is not 0)
+ return new Rect(rootX, rootY, attrs.Width, attrs.Height);
+
+ return new Rect(attrs.X, attrs.Y, attrs.Width, attrs.Height);
}
}
@@ -80,7 +96,8 @@ internal XamlIslandHostWindow()
_window = new TransparentWindow();
_window.Title = "DesktopFlyoutHost";
- var nativeWindow = (X11NativeWindow)Uno.UI.Xaml.WindowHelper.GetNativeWindow(_window);
+ var nativeWindow = Uno.UI.Xaml.WindowHelper.GetNativeWindow(_window) as X11NativeWindow
+ ?? throw new InvalidOperationException("Uno did not create an X11 native window.");
if ((_display = XOpenDisplay(0)) is 0)
throw new InvalidOperationException("Failed to open X11 display.");
@@ -111,6 +128,7 @@ internal XamlIslandHostWindow()
// Subscribe to window events.
_window.Closed += OnWindowClosed;
_window.Activated += OnWindowActivated;
+ GeneralHelpers.SystemSettingsChanged += GeneralHelpers_SystemSettingsChanged;
_focusTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(100) };
_focusTimer.Tick += FocusTimer_Tick;
@@ -128,21 +146,27 @@ private void ConfigureWindow()
if (_display is 0 || _x11Window is 0)
return;
- // Set override_redirect on BOTH windows so the WM doesn't reposition or decorate them.
- // Uno creates two windows (RootX11Window + TopX11Window child) and maps both in ShowCore().
- // Both need override_redirect to prevent KWin from grabbing and repositioning.
+ // NeverActivate windows bypass the window manager so it cannot assign focus.
+ // Activate and NoActivateOnOpen remain WM-managed and differ only in whether
+ // an activation request is sent when they are mapped.
foreach (var window in managedWindows)
- SetOverrideRedirect(window, true);
+ SetOverrideRedirect(window, X11WindowActivation.UsesOverrideRedirect(_activationMode));
// Remove window decorations by setting Motif WM hints (no title, no resize, no close).
- var motifHints = new byte[5 * 4]; // 5 x uint32
- BitConverter.TryWriteBytes(new Span(motifHints, 0, 4), 2); // MWM_HINTS_DECORATIONS
- BitConverter.TryWriteBytes(new Span(motifHints, 4, 4), 0); // no decorations
- BitConverter.TryWriteBytes(new Span(motifHints, 8, 4), 0); // functions
- BitConverter.TryWriteBytes(new Span(motifHints, 12, 4), 0); // input_mode
- BitConverter.TryWriteBytes(new Span(motifHints, 16, 4), 0); // status
- XChangeProperty(_display, _x11Window, _motifWmHintsAtom,
- _motifWmHintsAtom, 32, PropertyMode.Replace, motifHints, 5);
+ ReadOnlySpan motifHints = [
+ 2, // MWM_HINTS_DECORATIONS
+ 0, // functions
+ 0, // decorations
+ 0, // input_mode
+ 0, // status
+ ];
+ XChangeProperty32(
+ _display,
+ _x11Window,
+ _motifWmHintsAtom,
+ _motifWmHintsAtom,
+ PropertyMode.Replace,
+ motifHints);
// Set EWMH hints on BOTH windows — task managers may read either.
foreach (var wnd in managedWindows)
@@ -156,23 +180,30 @@ private void ConfigureWindow()
private void SetNetWmState(nint window)
{
- var stateAtoms = new int[]
- {
- (int)_netWmStateSkipTaskbarAtom,
- (int)_netWmStateSkipPagerAtom,
- (int)_netWmStateAboveAtom,
- };
- var stateBytes = new byte[stateAtoms.Length * 4];
- Buffer.BlockCopy(stateAtoms, 0, stateBytes, 0, stateBytes.Length);
- XChangeProperty(_display, window, _netWmStateAtom, XA_ATOM, 32, PropertyMode.Replace, stateBytes, stateAtoms.Length);
+ ReadOnlySpan stateAtoms = [
+ (nuint)_netWmStateSkipTaskbarAtom,
+ (nuint)_netWmStateSkipPagerAtom,
+ (nuint)_netWmStateAboveAtom,
+ ];
+ XChangeProperty32(
+ _display,
+ window,
+ _netWmStateAtom,
+ XA_ATOM,
+ PropertyMode.Replace,
+ stateAtoms);
}
private void SetNetWmWindowType(nint window)
{
- var typeAtomArr = new int[] { (int)_netWmWindowTypeDockAtom };
- var typeBytes = new byte[4];
- Buffer.BlockCopy(typeAtomArr, 0, typeBytes, 0, 4);
- XChangeProperty(_display, window, _netWmWindowTypeAtom, XA_ATOM, 32, PropertyMode.Replace, typeBytes, 1);
+ ReadOnlySpan typeAtoms = [(nuint)_netWmWindowTypeDockAtom];
+ XChangeProperty32(
+ _display,
+ window,
+ _netWmWindowTypeAtom,
+ XA_ATOM,
+ PropertyMode.Replace,
+ typeAtoms);
}
internal void SetContent(object content)
@@ -182,18 +213,19 @@ internal void SetContent(object content)
internal void PreserveActivationState()
{
- // On X11, activation state preservation is handled at the window manager level.
- // This is a no-op for now; the flyout window will activate normally.
+ _previousActiveWindow = TryGetActiveWindow(out var activeWindow) &&
+ !managedWindows.Contains(activeWindow)
+ ? activeWindow
+ : 0;
}
internal void RestoreActivationState()
{
- // On X11, restore activation by raising the window.
- if (_display is 0 || _x11Window is 0)
+ if (_display is 0 || _previousActiveWindow is 0)
return;
- XRaiseWindow(_display, _x11Window);
- XFlush(_display);
+ if (!TryGetActiveWindow(out var activeWindow) || activeWindow != _previousActiveWindow)
+ RequestActivation(_previousActiveWindow);
}
internal void MoveAndResize(RectInt32 rect, bool activate = true)
@@ -212,28 +244,8 @@ internal void MoveAndResize(RectInt32 rect, bool activate = true)
XMoveWindow(_display, _x11Window, rect.X, rect.Y);
XSync(_display, false);
- if (activate)
- {
- // Send _NET_ACTIVE_WINDOW message to request activation.
- var xclient = new XEvent
- {
- type = ClientMessage,
- xclient = new XClientMessageEvent
- {
- type = ClientMessage,
- display = _display,
- window = _x11Window,
- message_type = _netActiveWindowAtom,
- format = 32,
- ptr1 = (nint)2, // _NET_WM_STATE_REQUEST
- ptr2 = 0,
- ptr3 = 0,
- }
- };
- var rootWindow = XDefaultRootWindow(_display);
- XSendEvent(_display, rootWindow, false,
- (nint)(StructureNotifyMask | FocusChangeMask), ref xclient);
- }
+ if (X11WindowActivation.ShouldRequestActivation(_activationMode, activate))
+ RequestActivation(_x11Window);
XSync(_display, false);
}
@@ -268,10 +280,9 @@ internal void Maximize(System.Drawing.Rectangle workArea, bool activate = true)
XMoveWindow(_display, _x11Window, x, y);
XSync(_display, false);
- if (activate)
- {
- XRaiseWindow(_display, _x11Window);
- }
+ XRaiseWindow(_display, _x11Window);
+ if (X11WindowActivation.ShouldRequestActivation(_activationMode, activate))
+ RequestActivation(_x11Window);
XSync(_display, false);
}
@@ -325,10 +336,10 @@ internal ValueTask UpdateWindowVisibility(bool isVisible, bool activate = true)
SetWindowOpacity(wnd, 0);
XFlush(_display);
- // Re-apply override_redirect immediately before map on BOTH windows.
- // Uno's ShowCore() maps both windows; KWin grabs TopX11Window if it lacks override_redirect.
+ // Re-apply the activation policy immediately before Uno maps both its
+ // root and rendering windows.
foreach (var window in managedWindows)
- SetOverrideRedirect(window, true);
+ SetOverrideRedirect(window, X11WindowActivation.UsesOverrideRedirect(_activationMode));
// Capture current position so we can re-apply it after map.
var currentAttrs = new XWindowAttributes();
@@ -339,7 +350,7 @@ internal ValueTask UpdateWindowVisibility(bool isVisible, bool activate = true)
// Map both RootX11Window and TopX11Window (child where Skia renders).
foreach (var wnd in managedWindows)
XMapWindow(_display, wnd);
-
+
// Re-apply position after map — KWin overrides our position during XMapWindow.
XMoveWindow(_display, _x11Window, savedX, savedY);
@@ -350,10 +361,9 @@ internal ValueTask UpdateWindowVisibility(bool isVisible, bool activate = true)
SetNetWmWindowType(wnd);
}
- if (activate)
- {
- XRaiseWindow(_display, _x11Window);
- }
+ XRaiseWindow(_display, _x11Window);
+ if (X11WindowActivation.ShouldRequestActivation(_activationMode, activate))
+ RequestActivation(_x11Window);
XSync(_display, false);
// Restore opacity after Skia has presented at least one frame.
@@ -438,17 +448,15 @@ internal void SetActivationMode(DesktopFlyoutActivationMode activationMode)
if (_display is 0 || _x11Window is 0)
return;
- // For NeverActivate, we set override_redirect so the WM doesn't manage focus.
foreach (var window in managedWindows)
- SetOverrideRedirect(window, activationMode == DesktopFlyoutActivationMode.NeverActivate);
+ SetOverrideRedirect(window, X11WindowActivation.UsesOverrideRedirect(activationMode));
XFlush(_display);
}
internal bool NavigateFocus(object reason)
{
- // On X11, focus navigation is handled by the window manager.
- // Attempt to set input focus to the flyout window.
- if (_display is 0 || _x11Window is 0)
+ if (_activationMode is DesktopFlyoutActivationMode.NeverActivate ||
+ _display is 0 || _x11Window is 0)
return false;
XSetInputFocus(_display, _x11Window, 1 /* RevertToParent */, 0 /* CurrentTime */);
@@ -458,9 +466,14 @@ internal bool NavigateFocus(object reason)
private void SetWindowOpacity(nint window, uint opacity)
{
- var data = BitConverter.GetBytes(opacity);
- XChangeProperty(_display, window, _netWmOpacityAtom, 6 /* XA_CARDINAL */, 32,
- PropertyMode.Replace, data, 1);
+ ReadOnlySpan data = [opacity];
+ XChangeProperty32(
+ _display,
+ window,
+ _netWmOpacityAtom,
+ (nint)XA_CARDINAL,
+ PropertyMode.Replace,
+ data);
}
public void Dispose()
@@ -475,6 +488,7 @@ public void Dispose()
_window.Closed -= OnWindowClosed;
_window.Activated -= OnWindowActivated;
+ GeneralHelpers.SystemSettingsChanged -= GeneralHelpers_SystemSettingsChanged;
if (_display is not 0 && _x11Window is not 0)
{
@@ -486,7 +500,7 @@ public void Dispose()
XCloseDisplay(_display);
}
- _window.Content = null;
+ _window.Content = null;
_window.Close();
GC.SuppressFinalize(this);
@@ -500,6 +514,81 @@ private void SetOverrideRedirect(nint window, bool enabled)
XChangeWindowAttributes(_display, window, CWOverrideRedirect, ref attrs);
}
+ private void RequestActivation(nint window)
+ {
+ if (window is 0)
+ return;
+
+ _ = TryGetActiveWindow(out var currentActiveWindow);
+ var clientMessage = new XEvent
+ {
+ type = ClientMessage,
+ xclient = new XClientMessageEvent
+ {
+ type = ClientMessage,
+ display = _display,
+ window = window,
+ message_type = _netActiveWindowAtom,
+ format = 32,
+ ptr1 = 1, // normal application
+ ptr2 = 0, // CurrentTime
+ ptr3 = currentActiveWindow,
+ }
+ };
+ var rootWindow = XDefaultRootWindow(_display);
+ if (rootWindow is not 0)
+ {
+ XSendEvent(
+ _display,
+ rootWindow,
+ false,
+ (nint)(SubstructureRedirectMask | SubstructureNotifyMask),
+ ref clientMessage);
+ XFlush(_display);
+ }
+ }
+
+ private bool TryGetActiveWindow(out nint activeWindow)
+ {
+ activeWindow = 0;
+ var rootWindow = XDefaultRootWindow(_display);
+ if (rootWindow is not 0)
+ {
+ var status = XGetWindowProperty(
+ _display,
+ (nuint)rootWindow,
+ (nuint)_netActiveWindowAtom,
+ 0,
+ 1,
+ false,
+ 0,
+ out var actualType,
+ out var actualFormat,
+ out var itemCount,
+ out _,
+ out var property);
+
+ if (status is 0 && actualType == XA_WINDOW && actualFormat is 32 &&
+ itemCount > 0 && property is not 0)
+ {
+ try
+ {
+ activeWindow = Marshal.ReadIntPtr(property);
+ return activeWindow is not 0;
+ }
+ finally
+ {
+ XFree(property);
+ }
+ }
+
+ if (property is not 0)
+ XFree(property);
+ }
+
+ return XGetInputFocus(_display, out activeWindow, out _) is not 0 && activeWindow is not 0;
+ }
+
internal void StartFocusMonitoring()
{
if (_focusMonitoring || _disposed || _display is 0 || _x11Window is 0 || _windowInactivated is null)
@@ -517,32 +606,7 @@ internal void StartFocusMonitoring()
private bool IsOurWindowActive()
{
- var rootWindow = XDefaultRootWindow(_display);
- if (rootWindow is 0)
- return false;
-
- var status = XGetWindowProperty(
- _display,
- (nuint)rootWindow,
- (nuint)_netActiveWindowAtom,
- 0, 1, false,
- 0 /* AnyPropertyType */,
- out var actualType, out _,
- out var nItems, out _,
- out var prop);
-
- if (status != 0 || actualType is not 33 /* XA_WINDOW */ || nItems < 1 || prop is 0)
- return false;
-
- try
- {
- var activeWindow = Marshal.ReadIntPtr(prop);
- return activeWindow == _x11Window;
- }
- finally
- {
- XFree(prop);
- }
+ return TryGetActiveWindow(out var activeWindow) && managedWindows.Contains(activeWindow);
}
internal void StopFocusMonitoring()
@@ -564,38 +628,14 @@ private void FocusTimer_Tick(object? sender, object e)
try
{
- var rootWindow = XDefaultRootWindow(_display);
- if (rootWindow is 0)
+ if (!TryGetActiveWindow(out var activeWindow))
return;
- // Read _NET_ACTIVE_WINDOW from the root window (type WINDOW, 32-bit).
- // AnyPropertyType (0) = return data regardless of the property's actual type.
- var status = XGetWindowProperty(
- _display,
- (nuint)rootWindow,
- (nuint)_netActiveWindowAtom,
- 0, 1, false,
- 0 /* AnyPropertyType */,
- out var actualType, out _,
- out var nItems, out _,
- out var prop);
-
- if (status != 0 || actualType is not 33 /* XA_WINDOW */ || nItems < 1 || prop is 0)
- return;
+ var wasFocused = _focused;
+ _focused = managedWindows.Contains(activeWindow);
- try
- {
- var activeWindow = Marshal.ReadIntPtr(prop);
- var wasFocused = _focused;
- _focused = activeWindow == _x11Window;
-
- if (wasFocused && !_focused)
- _windowInactivated?.Invoke(this, EventArgs.Empty);
- }
- finally
- {
- XFree(prop);
- }
+ if (wasFocused && !_focused)
+ _windowInactivated?.Invoke(this, EventArgs.Empty);
}
catch (ObjectDisposedException)
{
@@ -612,10 +652,37 @@ private void OnWindowClosed(object? sender, WindowEventArgs args)
_windowInactivated?.Invoke(this, EventArgs.Empty);
}
+ private void GeneralHelpers_SystemSettingsChanged(object? sender, EventArgs args)
+ {
+ if (_disposed)
+ return;
+
+ if (_window.DispatcherQueue.HasThreadAccess)
+ {
+ SystemSettingsChanged?.Invoke(this, EventArgs.Empty);
+ }
+ else
+ {
+ _window.DispatcherQueue.TryEnqueue(() =>
+ {
+ if (!_disposed)
+ SystemSettingsChanged?.Invoke(this, EventArgs.Empty);
+ });
+ }
+ }
+
private void OnWindowActivated(object? sender, WindowActivatedEventArgs args)
{
- if (args.WindowActivationState is Windows.UI.Core.CoreWindowActivationState.Deactivated)
+ if (_activationMode is DesktopFlyoutActivationMode.NeverActivate)
+ return;
+
+ if (args.WindowActivationState is not Windows.UI.Core.CoreWindowActivationState.Deactivated)
+ {
+ _focused = true;
+ }
+ else if (_focused)
{
+ _focused = false;
_windowInactivated?.Invoke(this, EventArgs.Empty);
}
}
diff --git a/src/DesktopFlyouts.Uno/DesktopFlyouts.Uno.csproj b/src/DesktopFlyouts.Uno/DesktopFlyouts.Uno.csproj
index 77c5127..b4dfdd1 100644
--- a/src/DesktopFlyouts.Uno/DesktopFlyouts.Uno.csproj
+++ b/src/DesktopFlyouts.Uno/DesktopFlyouts.Uno.csproj
@@ -12,7 +12,7 @@
UnoFeatures let's you quickly add and manage implicit package references based on the features you want to use.
https://aka.platform.uno/singleproject-features
-->
-
+
SkiaRenderer;
@@ -45,33 +45,29 @@
Shell System TrayIcon DesktopFlyouts DesktopFlyout UWP WinUI MUX WASDK WindowsAppSDK Uno Platform Windows
icon-128.png
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
\ No newline at end of file
+
diff --git a/src/DesktopFlyouts.Uno/GeneratorBackfill/DesktopFlyoutIslandTemplateSettings.Uno.cs b/src/DesktopFlyouts.Uno/GeneratorBackfill/DesktopFlyoutIslandTemplateSettings.Uno.cs
new file mode 100644
index 0000000..d314c9d
--- /dev/null
+++ b/src/DesktopFlyouts.Uno/GeneratorBackfill/DesktopFlyoutIslandTemplateSettings.Uno.cs
@@ -0,0 +1,22 @@
+// Manual dependency property implementation for Uno.
+// The GeneratedDependencyProperty source generator does not run under Uno.Sdk.
+
+using Microsoft.UI.Xaml;
+
+namespace DesktopFlyouts;
+
+public partial class DesktopFlyoutIslandTemplateSettings
+{
+ public partial CornerRadius BackdropCornerRadius
+ {
+ get => (CornerRadius)GetValue(BackdropCornerRadiusProperty);
+ internal set => SetValue(BackdropCornerRadiusProperty, value);
+ }
+
+ public static readonly DependencyProperty BackdropCornerRadiusProperty =
+ DependencyProperty.Register(
+ nameof(BackdropCornerRadius),
+ typeof(CornerRadius),
+ typeof(DesktopFlyoutIslandTemplateSettings),
+ new PropertyMetadata(default(CornerRadius)));
+}
diff --git a/tests/DesktopFlyouts.X11.Tests/DesktopFlyouts.X11.Tests.csproj b/tests/DesktopFlyouts.X11.Tests/DesktopFlyouts.X11.Tests.csproj
new file mode 100644
index 0000000..153eb0f
--- /dev/null
+++ b/tests/DesktopFlyouts.X11.Tests/DesktopFlyouts.X11.Tests.csproj
@@ -0,0 +1,25 @@
+
+
+ net10.0
+ false
+ true
+ false
+ false
+ enable
+ enable
+ $(DefineConstants);HAS_UNO
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/DesktopFlyouts.X11.Tests/X11InteropLayoutTests.cs b/tests/DesktopFlyouts.X11.Tests/X11InteropLayoutTests.cs
new file mode 100644
index 0000000..96ca900
--- /dev/null
+++ b/tests/DesktopFlyouts.X11.Tests/X11InteropLayoutTests.cs
@@ -0,0 +1,49 @@
+using System.Runtime.InteropServices;
+using Xunit;
+
+namespace DesktopFlyouts.Tests;
+
+public class X11InteropLayoutTests
+{
+ [Fact]
+ public void XSetWindowAttributesMatchesNativeLayout()
+ {
+ if (IntPtr.Size is 8)
+ {
+ Assert.Equal(112, Marshal.SizeOf());
+ AssertOffset("backing_planes", 48);
+ AssertOffset("backing_pixel", 56);
+ AssertOffset("save_under", 64);
+ AssertOffset("event_mask", 72);
+ AssertOffset("override_redirect", 88);
+ AssertOffset("cursor", 104);
+ }
+ else
+ {
+ Assert.Equal(60, Marshal.SizeOf());
+ AssertOffset("backing_planes", 28);
+ AssertOffset("backing_pixel", 32);
+ AssertOffset("save_under", 36);
+ AssertOffset("event_mask", 40);
+ AssertOffset("override_redirect", 48);
+ AssertOffset("cursor", 56);
+ }
+ }
+
+ [Fact]
+ public void XRandRMonitorInfoMatchesNativeLayout()
+ {
+ Assert.Equal(IntPtr.Size is 8 ? 56 : 44, Marshal.SizeOf());
+ AssertOffset("outputs", IntPtr.Size is 8 ? 48 : 40);
+ }
+
+ [Fact]
+ public void XClientMessageEventMatchesNativeLayout()
+ {
+ Assert.Equal(IntPtr.Size is 8 ? 96 : 48, Marshal.SizeOf());
+ AssertOffset("ptr1", IntPtr.Size is 8 ? 56 : 28);
+ }
+
+ private static void AssertOffset(string fieldName, int expected)
+ => Assert.Equal(expected, Marshal.OffsetOf(fieldName).ToInt32());
+}
diff --git a/tests/DesktopFlyouts.X11.Tests/X11ScreenGeometryTests.cs b/tests/DesktopFlyouts.X11.Tests/X11ScreenGeometryTests.cs
new file mode 100644
index 0000000..04fa9bd
--- /dev/null
+++ b/tests/DesktopFlyouts.X11.Tests/X11ScreenGeometryTests.cs
@@ -0,0 +1,57 @@
+using System.Drawing;
+using Xunit;
+
+namespace DesktopFlyouts.Tests;
+
+public class X11ScreenGeometryTests
+{
+ private static readonly X11MonitorGeometry[] Monitors =
+ [
+ new(new Rectangle(-1920, 0, 1920, 1080), false),
+ new(new Rectangle(0, 0, 2560, 1440), true),
+ ];
+
+ [Fact]
+ public void SelectMonitorUsesPrimaryWithoutAnchor()
+ {
+ Assert.Equal(new Rectangle(0, 0, 2560, 1440),
+ X11ScreenGeometry.SelectMonitor(Monitors, null));
+ }
+
+ [Fact]
+ public void SelectMonitorUsesAnchorIncludingNegativeCoordinates()
+ {
+ Assert.Equal(new Rectangle(-1920, 0, 1920, 1080),
+ X11ScreenGeometry.SelectMonitor(Monitors, new Point(-800, 500)));
+ }
+
+ [Fact]
+ public void SelectMonitorUsesNearestMonitorForOffscreenAnchor()
+ {
+ Assert.Equal(new Rectangle(0, 0, 2560, 1440),
+ X11ScreenGeometry.SelectMonitor(Monitors, new Point(4000, 500)));
+ }
+
+ [Fact]
+ public void SelectWorkAreaUsesCurrentVirtualDesktop()
+ {
+ nuint[] values =
+ [
+ 0, 0, 2560, 1400,
+ unchecked((nuint)(uint)-1920), 24, 4480, 1056,
+ ];
+
+ Assert.True(X11ScreenGeometry.TrySelectWorkArea(values, 1, out var workArea));
+ Assert.Equal(new Rectangle(-1920, 24, 4480, 1056), workArea);
+ }
+
+ [Fact]
+ public void ApplyWorkAreaClipsSelectedMonitor()
+ {
+ var monitor = new Rectangle(0, 0, 2560, 1440);
+ var workArea = new Rectangle(-1920, 24, 4480, 1380);
+
+ Assert.Equal(new Rectangle(0, 24, 2560, 1380),
+ X11ScreenGeometry.ApplyWorkArea(monitor, workArea));
+ }
+}
diff --git a/tests/DesktopFlyouts.X11.Tests/X11WindowActivationTests.cs b/tests/DesktopFlyouts.X11.Tests/X11WindowActivationTests.cs
new file mode 100644
index 0000000..cbf6ebc
--- /dev/null
+++ b/tests/DesktopFlyouts.X11.Tests/X11WindowActivationTests.cs
@@ -0,0 +1,30 @@
+using Xunit;
+
+namespace DesktopFlyouts.Tests;
+
+public class X11WindowActivationTests
+{
+ [Theory]
+ [InlineData(DesktopFlyoutActivationMode.Activate, false)]
+ [InlineData(DesktopFlyoutActivationMode.NoActivateOnOpen, false)]
+ [InlineData(DesktopFlyoutActivationMode.NeverActivate, true)]
+ public void OverrideRedirectIsReservedForNeverActivate(
+ DesktopFlyoutActivationMode mode,
+ bool expected)
+ {
+ Assert.Equal(expected, X11WindowActivation.UsesOverrideRedirect(mode));
+ }
+
+ [Theory]
+ [InlineData(DesktopFlyoutActivationMode.Activate, true, true)]
+ [InlineData(DesktopFlyoutActivationMode.Activate, false, false)]
+ [InlineData(DesktopFlyoutActivationMode.NoActivateOnOpen, true, false)]
+ [InlineData(DesktopFlyoutActivationMode.NeverActivate, true, false)]
+ public void ActivationRequestsRespectMode(
+ DesktopFlyoutActivationMode mode,
+ bool activate,
+ bool expected)
+ {
+ Assert.Equal(expected, X11WindowActivation.ShouldRequestActivation(mode, activate));
+ }
+}