// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System.Globalization;
using System.Runtime.InteropServices;
namespace Aspire.Tray;
/// <summary>Maps Plasma's per-output native tray coordinates into GTK's desktop coordinates.</summary>
internal static partial class LinuxPlasmaAnchor
{
internal static bool IsRequired(string? desktop, string? sessionType) =>
sessionType?.Equals("wayland", StringComparison.OrdinalIgnoreCase) == true
&& desktop?.Split(':', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Any(value => value.Equals("KDE", StringComparison.OrdinalIgnoreCase)) == true;
internal static Task<PanelRect> TranslateAsync(PanelRect anchor)
{
// Capture GTK objects on the UI thread. The bounded KWin call and its
// diagnostic parser run off-thread so stop/input remain responsive.
var display = LinuxPanelNative.gdk_display_get_default();
var primary = LinuxPanelNative.gdk_display_get_primary_monitor(display);
var monitors = Enumerable.Range(0, LinuxPanelNative.gdk_display_get_n_monitors(display)).Select(index =>
{
var monitor = LinuxPanelNative.gdk_display_get_monitor(display, index);
LinuxPanelNative.gdk_monitor_get_geometry(monitor, out var geometry);
return new Monitor(Marshal.PtrToStringUTF8(gdk_monitor_get_model(monitor)) ?? "",
new(geometry.X, geometry.Y, geometry.Width, geometry.Height), monitor == primary);
}).ToArray();
var pointer = gdk_seat_get_pointer(LinuxPanelNative.gdk_display_get_default_seat(display));
(double X, double Y)? position = null;
if (pointer != 0)
{
gdk_device_get_position(pointer, out var screen, out var x, out var y);
if (screen != 0)
{
position = (x, y);
}
}
return Task.Run(() => Map(anchor, ParseOutputs(ReadOutputInformation()), monitors, UsesNativeCoordinates(), position));
}
internal static PanelRect Map(PanelRect anchor, IReadOnlyList<Output> outputs, IReadOnlyList<Monitor> monitors,
bool nativeCoordinates, (double X, double Y)? pointer)
{
// Plasma uses the output's logical origin but native pixels within it:
// host = logicalOrigin + (logicalPoint - logicalOrigin) * outputScale.
// XWayland instead scales the whole desktop by a single global factor.
// GTK on Wayland already uses logical coordinates. Map through the matched
// output geometry, never through font DPI (which also includes text scaling).
// https://invent.kde.org/plasma/plasma-workspace/-/blob/master/applets/systemtray/systemtray.cpp
var candidates = new List<(PanelRect Bounds, Monitor Monitor)>();
foreach (var output in outputs)
{
var factor = nativeCoordinates ? output.Scale : 1;
var originScale = nativeCoordinates && outputs.Count == 1 ? factor : 1;
var native = new PanelRect(output.Logical.X * originScale, output.Logical.Y * originScale,
output.Logical.Width * factor, output.Logical.Height * factor);
if (!native.Contains(anchor.CenterX, anchor.CenterY))
{
continue;
}
var monitor = monitors.SingleOrDefault(monitor => monitor.Name == output.Name)
?? monitors.SingleOrDefault(monitor => monitor.Geometry.X == output.Logical.X
&& monitor.Geometry.Y == output.Logical.Y);
if (monitor is null)
{
throw new InvalidOperationException($"GTK did not expose the Plasma output '{output.Name}'.");
}
var scaleX = monitor.Geometry.Width / native.Width;
var scaleY = monitor.Geometry.Height / native.Height;
candidates.Add((new(monitor.Geometry.X + (anchor.X - native.X) * scaleX,
monitor.Geometry.Y + (anchor.Y - native.Y) * scaleY, anchor.Width * scaleX, anchor.Height * scaleY), monitor));
}
if (candidates.Count == 0)
{
throw new InvalidOperationException("The tray icon position does not belong to an active Plasma output.");
}
if (candidates.Count == 1)
{
return candidates[0].Bounds;
}
// Native output rectangles can overlap even when logical outputs do not.
// A pointer beside the translated icon disambiguates mouse activation;
// primary-output preference also supports keyboard activation without
// moving the flyout to an unrelated pointer elsewhere on the desktop.
if (pointer is { } point)
{
var nearest = candidates.MinBy(candidate => Math.Pow(candidate.Bounds.CenterX - point.X, 2)
+ Math.Pow(candidate.Bounds.CenterY - point.Y, 2));
if (Math.Abs(nearest.Bounds.CenterX - point.X) <= 4 && Math.Abs(nearest.Bounds.CenterY - point.Y) <= 4)
{
return nearest.Bounds;
}
}
return candidates.FirstOrDefault(candidate => candidate.Monitor.Primary, candidates[0]).Bounds;
}
private static bool UsesNativeCoordinates()
{
var home = Environment.GetEnvironmentVariable("XDG_CONFIG_HOME");
if (string.IsNullOrEmpty(home))
{
home = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".config");
}
var directories = Environment.GetEnvironmentVariable("XDG_CONFIG_DIRS");
if (string.IsNullOrEmpty(directories))
{
directories = "/etc/xdg";
}
foreach (var directory in new[] { home }.Concat(directories.Split(':', StringSplitOptions.RemoveEmptyEntries)))
{
try
{
using var reader = File.OpenText(Path.Combine(directory, "kdeglobals"));
if (ParseNativeCoordinates(reader) is { } enabled)
{
return enabled;
}
}
catch (Exception ex) when (ex is FileNotFoundException or DirectoryNotFoundException)
{
// An absent setting uses Plasma's default; other read failures
// must not silently select the wrong coordinate system.
}
}
return true;
}
internal static bool? ParseNativeCoordinates(TextReader reader)
{
// Plasma reads kdeglobals, not font DPI or kwinrc:
// [KScreen]
// XwaylandClientsScale=false
// When disabled, Activate contains logical coordinates instead of pixels.
// https://invent.kde.org/plasma/plasma-workspace/-/blob/master/applets/systemtray/systemtray.cpp
var section = false;
bool? result = null;
while (reader.ReadLine() is { } raw)
{
var line = raw.Trim();
if (line.StartsWith('['))
{
section = line is "[KScreen]" or "[KScreen][$i]";
}
else if (section)
{
var fields = line.Split('=', 2, StringSplitOptions.TrimEntries);
if (fields.Length == 2 && fields[0] is "XwaylandClientsScale" or "XwaylandClientsScale[$i]")
{
result = bool.TryParse(fields[1], out var enabled) ? enabled
: throw new InvalidOperationException("Plasma has an invalid XWayland client scaling setting.");
}
}
}
return result;
}
internal static IReadOnlyList<Output> ParseOutputs(string information)
{
// KWin's supportInformation output contains blocks such as:
// Screen 1:
// ---------
// Name: DP-5
// Enabled: 1
// Geometry: 1920,0,3072x1728
// Physical size: 697x392mm
// Scale: 1.25
// Other diagnostic sections/fields are unrelated. Disabled outputs need
// no geometry; enabled outputs must provide every coordinate-map field.
// https://invent.kde.org/plasma/kwin/-/blob/master/src/workspace.cpp
var outputs = new List<Output>();
using var reader = new StringReader(information);
string? name = null;
PanelRect? geometry = null;
double? scale = null;
var enabled = false;
var screens = false;
while (reader.ReadLine() is { } raw)
{
var line = raw.Trim();
if (line == "Screens")
{
screens = true;
continue;
}
if (!screens)
{
continue;
}
if (line == "Compositing" || (line.StartsWith("Screen ", StringComparison.Ordinal) && line.EndsWith(':')))
{
AddOutput();
name = null;
geometry = null;
scale = null;
enabled = false;
if (line == "Compositing")
{
break;
}
}
else if (line.StartsWith("Name: ", StringComparison.Ordinal))
{
name = line[6..];
}
else if (line.StartsWith("Enabled: ", StringComparison.Ordinal))
{
enabled = line[9..] switch
{
"1" => true,
"0" => false,
_ => throw new InvalidOperationException("KWin reported an invalid output enabled state.")
};
}
else if (line.StartsWith("Geometry: ", StringComparison.Ordinal))
{
var fields = line[10..].Split([',', 'x']);
if (fields.Length != 4 || !fields.All(value => double.TryParse(value, NumberStyles.Float,
CultureInfo.InvariantCulture, out var parsed) && double.IsFinite(parsed)))
{
throw new InvalidOperationException("KWin reported an invalid output geometry.");
}
var values = fields.Select(value => double.Parse(value, CultureInfo.InvariantCulture)).ToArray();
geometry = new(values[0], values[1], values[2], values[3]);
}
else if (line.StartsWith("Scale: ", StringComparison.Ordinal))
{
if (!double.TryParse(line[7..], NumberStyles.Float, CultureInfo.InvariantCulture, out var value)
|| !double.IsFinite(value) || value <= 0)
{
throw new InvalidOperationException("KWin reported an invalid output scale.");
}
scale = value;
}
}
AddOutput();
if (outputs.Count == 0)
{
throw new InvalidOperationException("KWin did not provide any active output geometry.");
}
return outputs;
void AddOutput()
{
if (!enabled)
{
return;
}
if (string.IsNullOrEmpty(name) || geometry is not { Width: > 0, Height: > 0 } bounds || scale is not { } factor)
{
throw new InvalidOperationException("KWin did not provide complete output geometry and scaling.");
}
outputs.Add(new(name, bounds, factor));
enabled = false;
}
}
private static string ReadOutputInformation()
{
var connection = g_bus_get_sync(2, 0, out var error);
if (connection == 0)
{
FreeError(error);
throw new InvalidOperationException("Could not connect to KWin for the tray icon position.");
}
try
{
// Read current output geometry on activation, not a cached primary
// output. Do not start a compositor or wait indefinitely.
var reply = g_dbus_connection_call_sync(connection, "org.kde.KWin", "/KWin", "org.kde.KWin",
"supportInformation", 0, 0, 1, 1000, 0, out error);
if (reply == 0)
{
FreeError(error);
throw new InvalidOperationException("KWin could not provide the tray output's coordinate mapping.");
}
try
{
if (g_variant_is_of_type(reply, "(s)") == 0)
{
throw new InvalidOperationException("KWin returned an invalid output-information response.");
}
var value = g_variant_get_child_value(reply, 0);
try
{
return Marshal.PtrToStringUTF8(g_variant_get_string(value, 0))
?? throw new InvalidOperationException("KWin returned no output information.");
}
finally
{
Gtk.g_variant_unref(value);
}
}
finally
{
Gtk.g_variant_unref(reply);
}
}
finally
{
Gtk.g_object_unref(connection);
}
}
private static void FreeError(nint error)
{
if (error != 0)
{
LinuxDesktop.g_error_free(error);
}
}
[LibraryImport("libgdk-3.so.0")] private static partial nint gdk_seat_get_pointer(nint seat);
[LibraryImport("libgdk-3.so.0")] private static partial void gdk_device_get_position(nint device, out nint screen, out int x, out int y);
[LibraryImport("libgdk-3.so.0")] private static partial nint gdk_monitor_get_model(nint monitor);
[LibraryImport("libgio-2.0.so.0")] private static partial nint g_bus_get_sync(int busType, nint cancellable, out nint error);
[LibraryImport("libgio-2.0.so.0", StringMarshalling = StringMarshalling.Utf8)]
private static partial nint g_dbus_connection_call_sync(nint connection, string name, string path, string interfaceName,
string method, nint parameters, nint replyType, int flags, int timeout, nint cancellable, out nint error);
[LibraryImport("libglib-2.0.so.0", StringMarshalling = StringMarshalling.Utf8)] private static partial int g_variant_is_of_type(nint value, string type);
[LibraryImport("libglib-2.0.so.0")] private static partial nint g_variant_get_child_value(nint value, nuint index);
[LibraryImport("libglib-2.0.so.0")] private static partial nint g_variant_get_string(nint value, nint length);
internal sealed record Output(string Name, PanelRect Logical, double Scale);
internal sealed record Monitor(string Name, PanelRect Geometry, bool Primary);
}