File: src\Aspire.Tray\Linux\LinuxTrayPanel.cs
Web Access
Project: src\tests\Aspire.Tray.Tests\Aspire.Tray.Tests.csproj (Aspire.Tray.Tests)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
 
namespace Aspire.Tray;
 
/// <summary>Hosts the shared Aspire panel in compositor-compatible transparent popup surfaces.</summary>
internal sealed class LinuxTrayPanel : IDisposable
{
    private readonly TrayController _controller;
    private readonly Action<PanelAction> _execute;
    private readonly TrayPanel _panel;
    private readonly GCHandle _handle;
    private readonly LinuxPanelBackend _backend;
    private readonly List<Surface> _surfaces = [];
    private readonly Dictionary<nint, Surface> _eventSurfaces = [];
    private LinuxPanelAccessibility? _accessibility;
    private nint _window;
    private nint _area;
    private nint _logo;
    private nint _seat;
    private PanelRect _workarea;
    private PanelRect _bounds;
    private double _scale = 1;
    private nint _monitor;
    private nuint _previousFocus;
    private int _anchorX;
    private int _anchorY;
    private bool _hasAnchor;
    private bool _grabbed;
    private long _hiddenAt;
    private bool _disposed;
    private Task<PanelRect>? _anchorRequest;
    private bool _openAfterAnchor;
 
    internal bool Visible { get; private set; }
 
    internal void Toggle()
    {
        if (Visible || Environment.TickCount64 - _hiddenAt < 250)
        {
            Hide();
            return;
        }
 
        Show();
    }
 
    internal void Poll()
    {
        if (_anchorRequest is { IsCompleted: true } request)
        {
            _anchorRequest = null;
            PanelRect? anchor = null;
            try
            {
                anchor = request.GetAwaiter().GetResult();
            }
            catch (Exception ex) when (ex is InvalidOperationException or IOException or UnauthorizedAccessException)
            {
                Console.Error.WriteLine($"Tray icon positioning failed ({ex.GetType().Name}).");
                if (_openAfterAnchor)
                {
                    // Accurate anchoring is optional; Settings, Quit and the error
                    // notice must remain reachable when KWin metadata is unavailable.
                    _controller.ReportActionError("Could not locate the tray icon. The panel was opened on the primary output.");
                }
            }
            if (_openAfterAnchor)
            {
                try
                {
                    _hasAnchor = false;
                    if (anchor is { } bounds)
                    {
                        SetAnchor(bounds);
                    }
                    Show();
                }
                catch (Exception ex) when (ex is InvalidOperationException or IOException or UnauthorizedAccessException)
                {
                    ReportFailure(ex);
                }
            }
        }
        if (Visible)
        {
            if (_backend == LinuxPanelBackend.X11 && !LinuxDesktopEnvironment.IsX11Composited())
            {
                ReportFailure(new InvalidOperationException("The X11 compositing manager stopped. Restart it before opening the Aspire panel."));
                return;
            }
            var display = LinuxPanelNative.gdk_display_get_default();
            if (LinuxPanelNative.gdk_display_get_n_monitors(display) != _surfaces.Count
                || _surfaces.Any(surface =>
                {
                    LinuxPanelNative.gdk_monitor_get_geometry(surface.Monitor, out var geometry);
                    return surface.Geometry != new PanelRect(geometry.X, geometry.Y, geometry.Width, geometry.Height)
                        || surface.DeviceScale != LinuxPanelNative.gdk_monitor_get_scale_factor(surface.Monitor);
                }))
            {
                Hide();
            }
            else if (_scale != ReadScale())
            {
                Position();
                Layout();
            }
        }
    }
 
    internal void SetAnchor(PanelRect anchor)
    {
        _anchorX = (int)Math.Round(anchor.CenterX);
        _anchorY = (int)Math.Round(anchor.Bottom);
        _hasAnchor = true;
    }
 
    internal void Activate(int x, int y)
    {
        if (Visible || _openAfterAnchor || Environment.TickCount64 - _hiddenAt < 250)
        {
            Hide();
            return;
        }
        try
        {
            if (LinuxPlasmaAnchor.IsRequired(Environment.GetEnvironmentVariable("XDG_CURRENT_DESKTOP"),
                Environment.GetEnvironmentVariable("XDG_SESSION_TYPE")))
            {
                _anchorRequest ??= LinuxPlasmaAnchor.TranslateAsync(new(x, y, 0, 0));
                _openAfterAnchor = true;
            }
            else
            {
                SetAnchor(new(x, y, 0, 0));
                Show();
            }
        }
        catch (Exception ex) when (ex is InvalidOperationException or IOException or UnauthorizedAccessException)
        {
            ReportFailure(ex);
        }
    }
 
    internal LinuxTrayPanel(TrayController controller, IReadOnlyList<LinuxFolderApplication> applications,
        Action<PanelAction> execute, LinuxPanelBackend backend)
    {
        _controller = controller;
        _execute = execute;
        _backend = backend;
        _panel = new(controller.State, TrayPanelPalette.Dark)
        {
            FileManagerName = "file manager",
            QuitVerb = "Quit",
            OpenInApplications = applications.Select(application => application.Title).ToArray()
        };
        _handle = GCHandle.Alloc(this);
    }
 
    internal void Show()
    {
        _openAfterAnchor = false;
        if (LinuxDesktopEnvironment.RequirePanelBackend() != _backend)
        {
            throw new InvalidOperationException("The desktop display backend changed. Restart Aspire Tray.");
        }
        _controller.PruneMissingPinnedAppHosts();
        _panel.Update(_controller.State);
        _panel.Reset();
        _panel.FocusFirst();
        Position();
        EnsureSurfaces();
        _panel.Palette = ReadPalette();
        Visible = true;
        Layout();
        if (_backend == LinuxPanelBackend.X11 && !_grabbed)
        {
            LinuxPanelNative.XGetInputFocus(LinuxPanelNative.gdk_x11_display_get_xdisplay(
                LinuxPanelNative.gdk_display_get_default()), out _previousFocus, out _);
        }
        foreach (var surface in _surfaces.Where(surface => !surface.Main))
        {
            Gtk.gtk_widget_show_all(surface.Window);
        }
        Gtk.gtk_widget_show_all(_window);
        _accessibility!.Focus(_panel.FocusedTarget);
    }
 
    private void GrabInput()
    {
        var nativeWindow = LinuxPanelNative.gtk_widget_get_window(_window);
        var display = LinuxPanelNative.gdk_display_get_default();
        LinuxPanelNative.XSetInputFocus(LinuxPanelNative.gdk_x11_display_get_xdisplay(display),
            LinuxPanelNative.gdk_x11_window_get_xid(nativeWindow), 2, LinuxPanelNative.gdk_x11_get_server_time(nativeWindow));
        _seat = LinuxPanelNative.gdk_display_get_default_seat(LinuxPanelNative.gdk_display_get_default());
        var result = LinuxPanelNative.gdk_seat_grab(_seat, nativeWindow, 8, 0, 0, 0, 0, 0);
        if (result != 0)
        {
            Hide();
            throw new InvalidOperationException($"Could not acquire popup input (GDK grab status {result}).");
        }
        _grabbed = true;
    }
 
    internal void Hide() => Hide(restoreFocus: true);
 
    private void Hide(bool restoreFocus)
    {
        _openAfterAnchor = false;
        if (!Visible)
        {
            return;
        }
        Visible = false;
        _hiddenAt = Environment.TickCount64;
        if (_grabbed)
        {
            _grabbed = false;
            LinuxPanelNative.gdk_seat_ungrab(_seat);
        }
        foreach (var surface in _surfaces.Where(surface => !surface.Destroyed))
        {
            LinuxPanelNative.gtk_widget_hide(surface.Window);
        }
        if (_backend == LinuxPanelBackend.X11 && restoreFocus && _previousFocus != 0)
        {
            var display = LinuxPanelNative.gdk_display_get_default();
            // The previous window may have closed while the panel was open. Let X11
            // revert focus normally in that case instead of treating BadWindow as fatal.
            LinuxPanelNative.gdk_x11_display_error_trap_push(display);
            LinuxPanelNative.XSetInputFocus(LinuxPanelNative.gdk_x11_display_get_xdisplay(display), _previousFocus, 2, 0);
            LinuxPanelNative.gdk_x11_display_error_trap_pop_ignored(display);
        }
    }
 
    internal void Update(TrayViewState state)
    {
        if (_panel.Update(state) && Visible)
        {
            Layout();
        }
    }
 
    internal void Apply(PanelResponse response)
    {
        if (response.Redraw && Visible)
        {
            Layout();
        }
        if (response.Action is { } action)
        {
            _execute(action);
        }
    }
 
    internal void ShowSettings(PanelSettings settings) => Apply(_panel.ShowSettings(settings));
 
    internal void UpdateSettings(PanelSettings settings) => Apply(_panel.UpdateSettings(settings));
 
    internal void ConfirmRemoveMissing(AppHostMenuItem host) => Apply(_panel.ConfirmRemoveMissing(host));
 
    private void EnsureSurfaces()
    {
        var display = LinuxPanelNative.gdk_display_get_default();
        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 (Monitor: monitor, Geometry: new PanelRect(geometry.X, geometry.Y, geometry.Width, geometry.Height),
                    DeviceScale: LinuxPanelNative.gdk_monitor_get_scale_factor(monitor));
            }).ToArray();
        if (monitors.Length == _surfaces.Count && monitors.Zip(_surfaces).All(pair =>
            pair.First.Monitor == pair.Second.Monitor && pair.First.Geometry == pair.Second.Geometry
            && pair.First.DeviceScale == pair.Second.DeviceScale
            && !pair.Second.Destroyed && pair.Second.Main == (pair.First.Monitor == _monitor)))
        {
            return;
        }
        Hide();
        DestroySurfaces();
        if (!monitors.Any(monitor => monitor.Monitor == _monitor))
        {
            throw new InvalidOperationException("The desktop did not provide an output for the Aspire panel.");
        }
        foreach (var monitor in monitors)
        {
            var surface = CreateSurface(monitor.Monitor, monitor.Geometry, monitor.DeviceScale, monitor.Monitor == _monitor);
            _surfaces.Add(surface);
            if (surface.Main)
            {
                _window = surface.Window;
                _area = surface.Area;
            }
        }
        var main = _surfaces.Single(surface => surface.Main);
        _accessibility = new(main.Fixed, Apply, _panel.Invoke, ReportFailure,
            widget => ConnectInput(widget, main), widget => _eventSurfaces.Remove(widget));
        if (_logo == 0)
        {
            _logo = Gtk.cairo_image_surface_create_from_png(Path.Combine(AppContext.BaseDirectory, "Aspire.png"));
            if (Gtk.cairo_surface_status(_logo) != 0)
            {
                throw new InvalidOperationException("Could not read the Aspire panel logo.");
            }
        }
    }
 
    private unsafe Surface CreateSurface(nint monitor, PanelRect geometry, int deviceScale, bool main)
    {
        var window = LinuxPanelNative.gtk_window_new(0);
        if (window == 0)
        {
            throw new InvalidOperationException("Could not create the Aspire panel's popup surface.");
        }
        Gtk.g_object_ref_sink(window);
        nint retainedMonitor = 0;
        try
        {
            var area = LinuxPanelNative.gtk_drawing_area_new();
            var container = LinuxPanelNative.gtk_fixed_new();
            retainedMonitor = Gtk.g_object_ref_sink(monitor);
            var surface = new Surface(window, area, container, retainedMonitor, geometry, deviceScale, main);
            Gtk.gtk_window_set_title(window, "Aspire");
            LinuxPanelNative.gtk_window_set_decorated(window, 0);
            LinuxPanelNative.gtk_window_set_resizable(window, 0);
            LinuxPanelNative.gtk_window_set_accept_focus(window, main ? 1 : 0);
            LinuxPanelNative.gtk_window_set_skip_taskbar_hint(window, 1);
            LinuxPanelNative.gtk_window_set_skip_pager_hint(window, 1);
            var visual = LinuxPanelNative.gdk_screen_get_rgba_visual(LinuxPanelNative.gtk_widget_get_screen(window));
            if (visual == 0)
            {
                throw new InvalidOperationException("The desktop does not support transparent Aspire panel surfaces.");
            }
            LinuxPanelNative.gtk_widget_set_visual(window, visual);
            LinuxPanelNative.gtk_widget_set_app_paintable(window, 1);
            LinuxPanelNative.gtk_widget_set_can_focus(area, main ? 1 : 0);
            Gtk.gtk_container_add(window, container);
            LinuxPanelNative.gtk_fixed_put(container, area, 0, 0);
            LinuxPanelNative.gtk_widget_set_size_request(area, (int)geometry.Width, (int)geometry.Height);
 
            // One transparent, input-receiving surface per output catches outside
            // clicks, including on another monitor. Only one draws the actual panel.
            // Layer-shell positions these without an unavailable Wayland parent/serial.
            // https://wmww.github.io/gtk-layer-shell/
            if (_backend == LinuxPanelBackend.Wayland)
            {
                LinuxLayerShell.Configure(window, monitor, main);
            }
            else
            {
                LinuxPanelNative.gtk_widget_realize(window);
                LinuxPanelNative.gdk_window_set_override_redirect(LinuxPanelNative.gtk_widget_get_window(window), 1);
                LinuxPanelNative.gtk_window_move(window, (int)geometry.X, (int)geometry.Y);
                LinuxPanelNative.gtk_window_resize(window, (int)geometry.Width, (int)geometry.Height);
            }
            _eventSurfaces.Add(window, surface);
            ConnectInput(area, surface);
            Gtk.g_signal_connect_data(area, "draw", (nint)(delegate* unmanaged[Cdecl]<nint, nint, nint, int>)&Draw,
                GCHandle.ToIntPtr(_handle), 0, 0);
            Gtk.g_signal_connect_data(window, "destroy", (nint)(delegate* unmanaged[Cdecl]<nint, nint, void>)&Destroyed,
                GCHandle.ToIntPtr(_handle), 0, 0);
            foreach (var signal in new[] { "key-press-event", "delete-event", "grab-broken-event", "focus-out-event", "map-event" })
            {
                Gtk.g_signal_connect_data(window, signal, (nint)(delegate* unmanaged[Cdecl]<nint, nint, nint, int>)&Input,
                    GCHandle.ToIntPtr(_handle), 0, 0);
            }
            ConnectInput(window, surface);
            return surface;
        }
        catch
        {
            Gtk.gtk_widget_destroy(window);
            Gtk.g_object_unref(window);
            if (retainedMonitor != 0)
            {
                Gtk.g_object_unref(retainedMonitor);
            }
            throw;
        }
    }
 
    private unsafe void ConnectInput(nint widget, Surface surface)
    {
        _eventSurfaces[widget] = surface;
        // Motion, button press/release, key press, leave, scroll and smooth-scroll masks.
        // https://docs.gtk.org/gdk3/flags.EventMask.html
        LinuxPanelNative.gtk_widget_add_events(widget, (1 << 2) | (1 << 8) | (1 << 9) | (1 << 10) | (1 << 12) | (1 << 21) | (1 << 23));
        var data = GCHandle.ToIntPtr(_handle);
        foreach (var signal in new[] { "motion-notify-event", "leave-notify-event", "button-press-event", "button-release-event", "scroll-event" })
        {
            Gtk.g_signal_connect_data(widget, signal, (nint)(delegate* unmanaged[Cdecl]<nint, nint, nint, int>)&Input, data, 0, 0);
        }
    }
 
    private TrayPanelPalette ReadPalette()
    {
        var context = LinuxPanelNative.gtk_widget_get_style_context(_area);
        return LinuxPanelNative.gtk_style_context_lookup_color(context, "theme_bg_color", out var color) != 0
            && (color.Red + color.Green + color.Blue) / 3 >= 0.5
            ? TrayPanelPalette.Light : TrayPanelPalette.Dark;
    }
 
    private void Position()
    {
        _scale = ReadScale();
        var display = LinuxPanelNative.gdk_display_get_default();
        if (LinuxPanelNative.gdk_display_get_n_monitors(display) == 0)
        {
            throw new InvalidOperationException("The desktop did not provide an output for the Aspire panel.");
        }
        if (!_hasAnchor)
        {
            var primary = LinuxPanelNative.gdk_display_get_primary_monitor(display);
            if (primary == 0)
            {
                primary = LinuxPanelNative.gdk_display_get_monitor(display, 0);
            }
            LinuxPanelNative.gdk_monitor_get_workarea(primary, out var initial);
            _anchorX = initial.X + initial.Width - 20;
            _anchorY = initial.Y;
            _hasAnchor = true;
        }
        _monitor = LinuxPanelNative.gdk_display_get_monitor_at_point(display, _anchorX, _anchorY);
        LinuxPanelNative.gdk_monitor_get_workarea(_monitor, out var workarea);
        _workarea = new(workarea.X, workarea.Y, workarea.Width, workarea.Height);
        if (workarea.Width < (TrayPanel.Width + 8) * _scale
            || workarea.Height < (TrayPanel.HeaderHeight + TrayPanel.FooterHeight + TrayPanel.RowHeight + 16) * _scale)
        {
            throw new InvalidOperationException("The output is too small to display the Aspire panel.");
        }
        _panel.MaxHeight = Math.Min(TrayPanel.DefaultMaxHeight, workarea.Height / _scale - 16);
    }
 
    private static double ReadScale()
    {
        // GTK already scales Cairo surfaces and event coordinates for integer output
        // scaling. Its font resolution carries the additional desktop/text scale,
        // including Xft.dpi on X11/XWayland and GDK_DPI_SCALE. Absolute Pango sizes
        // bypass that resolution, so apply it to the entire shared scene instead.
        // https://docs.gtk.org/gdk3/method.Screen.set_resolution.html
        return GetScale(LinuxPanelNative.gdk_screen_get_resolution(
            LinuxPanelNative.gdk_display_get_default_screen(LinuxPanelNative.gdk_display_get_default())));
    }
 
    internal static double GetScale(double resolution)
    {
        // GDK uses -1 when no font resolution has been set; its default is 96 DPI.
        // https://docs.gtk.org/gdk3/method.Screen.get_resolution.html
        if (resolution == -1)
        {
            return 1;
        }
        if (!double.IsFinite(resolution) || resolution <= 0)
        {
            throw new InvalidOperationException("GTK reported an invalid desktop font resolution.");
        }
 
        return resolution / 96;
    }
 
    internal static PanelRect Place(PanelRect workarea, double anchorX, double anchorY, double height, double scale)
    {
        var margin = 4 * scale;
        var width = TrayPanel.Width * scale;
        height *= scale;
        var left = workarea.X + margin;
        var right = Math.Max(left, workarea.Right - width - margin);
        var x = Math.Clamp(anchorX - width / 2, left, right);
        var y = anchorY >= workarea.Bottom ? workarea.Bottom - height - margin : Math.Max(workarea.Y + margin, anchorY + margin);
        y = Math.Clamp(y, workarea.Y + margin, Math.Max(workarea.Y + margin, workarea.Bottom - height - margin));
        return new(x, y, width, height);
    }
 
    internal static (double X, double Y) ToPanelPoint(PanelRect bounds, PanelRect geometry, double x, double y, double scale)
    {
        return ((x - bounds.X + geometry.X) / scale, (y - bounds.Y + geometry.Y) / scale);
    }
 
    private void Layout()
    {
        var height = (int)Math.Ceiling(_panel.Scene.Height);
        _bounds = Place(_workarea, _anchorX, _anchorY, height, _scale);
        var surface = _surfaces.Single(surface => surface.Main);
        _accessibility!.Update(_panel.Scene, _bounds.X - surface.Geometry.X, _bounds.Y - surface.Geometry.Y,
            _scale, _panel.FocusedTarget);
        LinuxPanelNative.gtk_widget_queue_draw(_area);
    }
 
    private int HandleInput(nint widget, nint nativeEvent)
    {
        if (!Visible || !_eventSurfaces.TryGetValue(widget, out var surface))
        {
            return 0;
        }
        LinuxPanelNative.gdk_event_get_coords(nativeEvent, out var x, out var y);
        var eventWidget = LinuxPanelNative.gtk_get_event_widget(nativeEvent);
        if (eventWidget != 0 && LinuxPanelNative.gtk_widget_translate_coordinates(eventWidget, surface.Area,
            (int)Math.Floor(x), (int)Math.Floor(y), out var translatedX, out var translatedY) != 0)
        {
            x = translatedX;
            y = translatedY;
        }
        (x, y) = ToPanelPoint(_bounds, surface.Geometry, x, y, _scale);
        switch (LinuxPanelNative.gdk_event_get_event_type(nativeEvent))
        {
            case 14: // GDK_MAP
                // Grabbing before the native window is mapped can fail with
                // GDK_GRAB_NOT_VIEWABLE.
                if (surface.Main && _backend == LinuxPanelBackend.X11 && !_grabbed)
                {
                    GrabInput();
                }
                return 0;
            case 0: // GDK_DELETE
                Hide();
                return 1;
            case 12: // A keyboard grab can synthesize focus-out without moving X11 focus.
                if (_backend == LinuxPanelBackend.X11 && surface.Main)
                {
                    LinuxPanelNative.XGetInputFocus(LinuxPanelNative.gdk_x11_display_get_xdisplay(
                        LinuxPanelNative.gdk_display_get_default()), out var focus, out _);
                    if (!_grabbed || focus == LinuxPanelNative.gdk_x11_window_get_xid(
                        LinuxPanelNative.gtk_widget_get_window(_window)))
                    {
                        return 0;
                    }
                }
                if (surface.Main)
                {
                    Hide(restoreFocus: false);
                }
                return 1;
            case 35: // GDK_GRAB_BROKEN: another popup took ownership.
                if (surface.Main)
                {
                    Hide(restoreFocus: false);
                }
                return 1;
            case 3: // GDK_MOTION_NOTIFY
                Apply(surface.Main ? _panel.PointerMoved(x, y) : _panel.PointerLeft());
                LinuxPanelNative.gtk_widget_set_tooltip_text(_area, _panel.HitTest(x, y)?.Tooltip);
                return 1;
            case 11: // GDK_LEAVE_NOTIFY
                Apply(_panel.PointerLeft());
                return 1;
            case 4 or 7: // GDK_BUTTON_PRESS / GDK_BUTTON_RELEASE
                if (LinuxPanelNative.gdk_event_get_event_type(nativeEvent) == 4
                    && (!surface.Main || !new PanelRect(0, 0, _panel.Scene.Width, _panel.Scene.Height).Contains(x, y)))
                {
                    Hide();
                    return 1;
                }
                LinuxPanelNative.gdk_event_get_button(nativeEvent, out var button);
                if (button != 1)
                {
                    return 0;
                }
                Apply(LinuxPanelNative.gdk_event_get_event_type(nativeEvent) == 4 ? _panel.PointerDown(x, y) : _panel.PointerUp(x, y));
                return 1;
            case 31: // GDK_SCROLL
                if (!surface.Main || !new PanelRect(0, 0, _panel.Scene.Width, _panel.Scene.Height).Contains(x, y))
                {
                    return 1;
                }
                if (LinuxPanelNative.gdk_event_get_scroll_deltas(nativeEvent, out _, out var delta) == 0)
                {
                    LinuxPanelNative.gdk_event_get_scroll_direction(nativeEvent, out var direction);
                    delta = direction switch { 0 => -1, 1 => 1, _ => 0 };
                }
                Apply(_panel.Scroll(-delta * 40));
                return 1;
            case 8: // GDK_KEY_PRESS
                LinuxPanelNative.gdk_event_get_keyval(nativeEvent, out var key);
                LinuxPanelNative.gdk_event_get_state(nativeEvent, out var modifiers);
                if (key == ',' && (modifiers & 4) != 0)
                {
                    _execute(new(PanelActionKind.Settings));
                    return 1;
                }
                var mapped = MapKey(key, (modifiers & 1) != 0);
                if (mapped is { } value)
                {
                    Apply(_panel.Key(value));
                    return 1;
                }
                return 0;
            default:
                return 0;
        }
    }
 
    internal static PanelKey? MapKey(uint key, bool shift) => key switch
    {
        0xFF52 => PanelKey.Up,
        0xFF54 => PanelKey.Down,
        0xFF09 => shift ? PanelKey.ShiftTab : PanelKey.Tab,
        0xFE20 => PanelKey.ShiftTab,
        0xFF0D or 0xFF8D => PanelKey.Enter,
        0x20 => PanelKey.Space,
        0xFF1B => PanelKey.Escape,
        0xFF50 => PanelKey.Home,
        0xFF57 => PanelKey.End,
        _ => null
    };
 
    private void Render(nint widget, nint context)
    {
        Gtk.cairo_set_operator(context, 0);
        LinuxPanelNative.cairo_paint(context);
        Gtk.cairo_set_operator(context, 2);
        if (!Visible || !_eventSurfaces.TryGetValue(widget, out var surface) || !surface.Main)
        {
            return;
        }
        LinuxPanelNative.cairo_save(context);
        LinuxPanelNative.cairo_translate(context, _bounds.X - surface.Geometry.X, _bounds.Y - surface.Geometry.Y);
        LinuxPanelNative.cairo_scale(context, _scale, _scale);
        foreach (var command in _panel.Scene.Commands)
        {
            RenderCommand(context, command);
        }
        LinuxPanelNative.cairo_restore(context);
        if (Gtk.cairo_status(context) != 0)
        {
            throw new InvalidOperationException("Could not render the Linux flyout.");
        }
    }
 
    private void RenderCommand(nint context, PanelCommand command)
    {
        switch (command)
        {
            case FillPathCommand fill:
                SetPath(context, fill.Path);
                SetColor(context, fill.Color);
                Gtk.cairo_fill(context);
                break;
            case StrokePathCommand stroke:
                SetPath(context, stroke.Path);
                SetColor(context, stroke.Color);
                LinuxPanelNative.cairo_set_line_width(context, stroke.Width);
                LinuxPanelNative.cairo_set_line_cap(context, 1);
                LinuxPanelNative.cairo_set_line_join(context, 1);
                LinuxPanelNative.cairo_stroke(context);
                break;
            case TextCommand text:
                RenderText(context, text);
                break;
            case LogoCommand logo:
                LinuxPanelNative.cairo_save(context);
                LinuxPanelNative.cairo_translate(context, logo.Bounds.X, logo.Bounds.Y);
                LinuxPanelNative.cairo_scale(context, logo.Bounds.Width / Gtk.cairo_image_surface_get_width(_logo),
                    logo.Bounds.Height / Gtk.cairo_image_surface_get_height(_logo));
                Gtk.cairo_set_source_surface(context, _logo, 0, 0);
                LinuxPanelNative.cairo_paint(context);
                LinuxPanelNative.cairo_restore(context);
                break;
            case AppIconCommand icon:
                foreach (var fallback in icon.Fallback)
                {
                    RenderCommand(context, fallback);
                }
                break;
            case PushClipCommand clip:
                LinuxPanelNative.cairo_save(context);
                Gtk.cairo_rectangle(context, clip.Bounds.X, clip.Bounds.Y, clip.Bounds.Width, clip.Bounds.Height);
                LinuxPanelNative.cairo_clip(context);
                break;
            case PopClipCommand:
                LinuxPanelNative.cairo_restore(context);
                break;
        }
    }
 
    private void RenderText(nint context, TextCommand text)
    {
        var layout = LinuxPanelNative.pango_cairo_create_layout(context);
        var font = LinuxPanelNative.pango_font_description_copy(LinuxPanelNative.pango_context_get_font_description(
            LinuxPanelNative.gtk_widget_get_pango_context(_area)));
        try
        {
            LinuxPanelNative.pango_font_description_set_absolute_size(font, PanelFonts.Size(text.Font) * 1024);
            LinuxPanelNative.pango_font_description_set_weight(font, PanelFonts.IsSemibold(text.Font) ? 600 : 400);
            LinuxPanelNative.pango_layout_set_font_description(layout, font);
            LinuxPanelNative.pango_layout_set_text(layout, text.Text, -1);
            LinuxPanelNative.pango_layout_set_width(layout, (int)Math.Round(text.Bounds.Width * 1024));
            LinuxPanelNative.pango_layout_set_height(layout, -text.MaxLines);
            LinuxPanelNative.pango_layout_set_wrap(layout, 2); // PANGO_WRAP_WORD_CHAR
            LinuxPanelNative.pango_layout_set_ellipsize(layout, text.Truncation == PanelTruncation.Middle ? 2 : 3);
            LinuxPanelNative.pango_layout_set_alignment(layout, text.Alignment switch
            {
                PanelTextAlignment.Center => 1,
                PanelTextAlignment.Trailing => 2,
                _ => 0
            });
            var scale = _scale * LinuxPanelNative.gtk_widget_get_scale_factor(_area);
            var y = text.MaxLines > 1 ? text.Bounds.Y
                : Math.Round(text.Baseline * scale) / scale - LinuxPanelNative.pango_layout_get_baseline(layout) / 1024d;
            LinuxPanelNative.cairo_save(context);
            Gtk.cairo_rectangle(context, text.Bounds.X, text.Bounds.Y, text.Bounds.Width, text.Bounds.Height);
            LinuxPanelNative.cairo_clip(context);
            SetColor(context, text.Color);
            LinuxPanelNative.cairo_move_to(context, text.Bounds.X, y);
            LinuxPanelNative.pango_cairo_show_layout(context, layout);
            LinuxPanelNative.cairo_restore(context);
        }
        finally
        {
            LinuxPanelNative.pango_font_description_free(font);
            Gtk.g_object_unref(layout);
        }
    }
 
    private static void SetColor(nint context, PanelColor color)
        => LinuxPanelNative.cairo_set_source_rgba(context, color.R / 255d, color.G / 255d, color.B / 255d, color.Alpha);
 
    private static void SetPath(nint context, PanelPath path)
    {
        LinuxPanelNative.cairo_new_path(context);
        foreach (var segment in path.Segments)
        {
            switch (segment.Verb)
            {
                case PanelPathVerb.MoveTo:
                    LinuxPanelNative.cairo_move_to(context, segment.X3, segment.Y3);
                    break;
                case PanelPathVerb.LineTo:
                    LinuxPanelNative.cairo_line_to(context, segment.X3, segment.Y3);
                    break;
                case PanelPathVerb.CubicTo:
                    LinuxPanelNative.cairo_curve_to(context, segment.X1, segment.Y1, segment.X2, segment.Y2, segment.X3, segment.Y3);
                    break;
                case PanelPathVerb.Close:
                    LinuxPanelNative.cairo_close_path(context);
                    break;
            }
        }
    }
 
    [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])]
    private static int Draw(nint widget, nint context, nint data)
        => Dispatch(data, panel => { panel.Render(widget, context); return 1; });
 
    [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])]
    private static int Input(nint widget, nint nativeEvent, nint data)
        => Dispatch(data, panel => panel.HandleInput(widget, nativeEvent));
 
    [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])]
    private static void Destroyed(nint widget, nint data)
        => Dispatch(data, panel =>
        {
            if (panel._eventSurfaces.TryGetValue(widget, out var surface))
            {
                surface.Destroyed = true;
                panel.Hide(restoreFocus: false);
            }
            return 0;
        });
 
    private static int Dispatch(nint data, Func<LinuxTrayPanel, int> callback)
    {
        var panel = (LinuxTrayPanel)GCHandle.FromIntPtr(data).Target!;
        try
        {
            return panel._disposed ? 0 : callback(panel);
        }
        catch (Exception ex)
        {
            panel.ReportFailure(ex);
            return 1;
        }
    }
 
    private void ReportFailure(Exception error)
    {
        Console.Error.WriteLine($"Linux flyout failed ({error.GetType().Name}).");
        _controller.ReportActionError(error is InvalidOperationException or FileNotFoundException
            ? error.Message : "The Linux flyout could not complete the requested action.");
        // Never keep an input-catching surface mapped after a native callback
        // fails. The status notice remains available when the panel reopens.
        Hide();
    }
 
    public void Dispose()
    {
        if (_disposed)
        {
            return;
        }
        Hide();
        _disposed = true;
        if (_anchorRequest is { } request)
        {
            // The worker owns no GTK objects. Do not delay shutdown for a lookup;
            // observe failures without retaining or reopening the disposed panel.
            _ = request.ContinueWith(static completed =>
            {
                Console.Error.WriteLine($"Tray icon positioning failed during shutdown ({completed.Exception!.GetBaseException().GetType().Name}).");
            }, CancellationToken.None, TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously,
                TaskScheduler.Default);
            _anchorRequest = null;
        }
        DestroySurfaces();
        if (_logo != 0)
        {
            Gtk.cairo_surface_destroy(_logo);
        }
        _handle.Free();
    }
 
    private void DestroySurfaces()
    {
        _accessibility?.Dispose();
        _accessibility = null;
        _eventSurfaces.Clear();
        foreach (var surface in _surfaces)
        {
            Gtk.gtk_widget_destroy(surface.Window);
            Gtk.g_object_unref(surface.Window);
            Gtk.g_object_unref(surface.Monitor);
        }
        _surfaces.Clear();
        _window = _area = 0;
    }
 
    private sealed record Surface(nint Window, nint Area, nint Fixed, nint Monitor, PanelRect Geometry, int DeviceScale, bool Main)
    {
        public bool Destroyed { get; set; }
    }
}
 
internal static partial class LinuxPanelNative
{
    [LibraryImport("libgtk-3.so.0")] internal static partial nint gtk_fixed_new();
    [LibraryImport("libgtk-3.so.0")] internal static partial void gtk_fixed_put(nint container, nint widget, int x, int y);
    [LibraryImport("libgtk-3.so.0")] internal static partial void gtk_fixed_move(nint container, nint widget, int x, int y);
    [LibraryImport("libgtk-3.so.0")] internal static partial void gtk_widget_realize(nint widget);
    [LibraryImport("libgtk-3.so.0")] internal static partial void gtk_window_set_accept_focus(nint window, int accept);
    [LibraryImport("libgtk-3.so.0")] internal static partial nint gtk_get_event_widget(nint nativeEvent);
    [LibraryImport("libgtk-3.so.0")] internal static partial int gtk_widget_translate_coordinates(nint source, nint destination, int x, int y, out int translatedX, out int translatedY);
    [LibraryImport("libgdk-3.so.0")] internal static partial int gdk_display_get_n_monitors(nint display);
    [LibraryImport("libgdk-3.so.0")] internal static partial nint gdk_display_get_monitor(nint display, int index);
    [LibraryImport("libgdk-3.so.0")] internal static partial nint gdk_display_get_primary_monitor(nint display);
    [LibraryImport("libgdk-3.so.0")] internal static partial void gdk_monitor_get_geometry(nint monitor, out Rectangle geometry);
    [LibraryImport("libgdk-3.so.0")] internal static partial int gdk_monitor_get_scale_factor(nint monitor);
    [LibraryImport("libgdk-3.so.0")] internal static partial nint gdk_display_get_default_screen(nint display);
    [LibraryImport("libgdk-3.so.0")] internal static partial double gdk_screen_get_resolution(nint screen);
    [LibraryImport("libgdk-3.so.0")] internal static partial void gdk_window_set_override_redirect(nint window, int redirect);
    [LibraryImport("libgdk-3.so.0")] internal static partial nint gdk_x11_display_get_xdisplay(nint display);
    [LibraryImport("libgdk-3.so.0")] internal static partial nuint gdk_x11_window_get_xid(nint window);
    [LibraryImport("libgdk-3.so.0")] internal static partial void gdk_x11_display_error_trap_push(nint display);
    [LibraryImport("libgdk-3.so.0")] internal static partial void gdk_x11_display_error_trap_pop_ignored(nint display);
    [LibraryImport("libX11.so.6")] internal static partial int XGetInputFocus(nint display, out nuint focus, out int revert);
    [LibraryImport("libX11.so.6")] internal static partial int XSetInputFocus(nint display, nuint focus, int revert, nuint time);
    [StructLayout(LayoutKind.Sequential)]
    internal struct Rectangle
    {
        public int X, Y, Width, Height;
    }
 
    [StructLayout(LayoutKind.Sequential)]
    internal struct Rgba
    {
        public double Red, Green, Blue, Alpha;
    }
 
    [LibraryImport("libgtk-3.so.0")] internal static partial nint gtk_window_new(int type);
    [LibraryImport("libgtk-3.so.0")] internal static partial nint gtk_drawing_area_new();
    [LibraryImport("libgtk-3.so.0")] internal static partial void gtk_window_set_decorated(nint window, int decorated);
    [LibraryImport("libgtk-3.so.0")] internal static partial void gtk_window_set_resizable(nint window, int resizable);
    [LibraryImport("libgtk-3.so.0")] internal static partial void gtk_window_set_skip_taskbar_hint(nint window, int skip);
    [LibraryImport("libgtk-3.so.0")] internal static partial void gtk_window_set_skip_pager_hint(nint window, int skip);
    [LibraryImport("libgtk-3.so.0")] internal static partial void gtk_window_move(nint window, int x, int y);
    [LibraryImport("libgtk-3.so.0")] internal static partial void gtk_window_resize(nint window, int width, int height);
    [LibraryImport("libgdk-3.so.0")] internal static partial uint gdk_x11_get_server_time(nint window);
    [LibraryImport("libgtk-3.so.0")] internal static partial nint gtk_widget_get_window(nint widget);
    [LibraryImport("libgtk-3.so.0")] internal static partial nint gtk_widget_get_screen(nint widget);
    [LibraryImport("libgtk-3.so.0")] internal static partial void gtk_widget_set_visual(nint widget, nint visual);
    [LibraryImport("libgtk-3.so.0")] internal static partial void gtk_widget_set_app_paintable(nint widget, int enabled);
    [LibraryImport("libgtk-3.so.0")] internal static partial void gtk_widget_set_can_focus(nint widget, int canFocus);
    [LibraryImport("libgtk-3.so.0")] internal static partial void gtk_widget_add_events(nint widget, int events);
    [LibraryImport("libgtk-3.so.0")] internal static partial void gtk_widget_grab_focus(nint widget);
    [LibraryImport("libgtk-3.so.0")] internal static partial void gtk_widget_set_size_request(nint widget, int width, int height);
    [LibraryImport("libgtk-3.so.0")] internal static partial void gtk_widget_queue_draw(nint widget);
    [LibraryImport("libgtk-3.so.0")] internal static partial void gtk_widget_hide(nint widget);
    [LibraryImport("libgtk-3.so.0")] internal static partial int gtk_widget_get_scale_factor(nint widget);
    [LibraryImport("libgtk-3.so.0")] internal static partial nint gtk_widget_get_style_context(nint widget);
    [LibraryImport("libgtk-3.so.0")] internal static partial nint gtk_widget_get_pango_context(nint widget);
    [LibraryImport("libgtk-3.so.0", StringMarshalling = StringMarshalling.Utf8)] internal static partial void gtk_widget_set_tooltip_text(nint widget, string? text);
    [LibraryImport("libgtk-3.so.0", StringMarshalling = StringMarshalling.Utf8)] internal static partial int gtk_style_context_lookup_color(nint context, string name, out Rgba color);
    [LibraryImport("libgdk-3.so.0")] internal static partial nint gdk_screen_get_rgba_visual(nint screen);
    [LibraryImport("libgdk-3.so.0")] internal static partial nint gdk_display_get_default();
    [LibraryImport("libgdk-3.so.0")] internal static partial nint gdk_display_get_default_seat(nint display);
    [LibraryImport("libgdk-3.so.0")] internal static partial int gdk_seat_grab(nint seat, nint window, int capabilities, int ownerEvents, nint cursor, nint nativeEvent, nint prepare, nint data);
    [LibraryImport("libgdk-3.so.0")] internal static partial void gdk_seat_ungrab(nint seat);
    [LibraryImport("libgdk-3.so.0")] internal static partial nint gdk_display_get_monitor_at_point(nint display, int x, int y);
    [LibraryImport("libgdk-3.so.0")] internal static partial void gdk_monitor_get_workarea(nint monitor, out Rectangle workarea);
    [LibraryImport("libgdk-3.so.0")] internal static partial int gdk_event_get_event_type(nint nativeEvent);
    [LibraryImport("libgdk-3.so.0")] internal static partial int gdk_event_get_coords(nint nativeEvent, out double x, out double y);
    [LibraryImport("libgdk-3.so.0")] internal static partial int gdk_event_get_button(nint nativeEvent, out uint button);
    [LibraryImport("libgdk-3.so.0")] internal static partial int gdk_event_get_keyval(nint nativeEvent, out uint key);
    [LibraryImport("libgdk-3.so.0")] internal static partial int gdk_event_get_state(nint nativeEvent, out uint state);
    [LibraryImport("libgdk-3.so.0")] internal static partial int gdk_event_get_scroll_deltas(nint nativeEvent, out double x, out double y);
    [LibraryImport("libgdk-3.so.0")] internal static partial int gdk_event_get_scroll_direction(nint nativeEvent, out int direction);
    [LibraryImport("libcairo.so.2")] internal static partial void cairo_save(nint context);
    [LibraryImport("libcairo.so.2")] internal static partial void cairo_restore(nint context);
    [LibraryImport("libcairo.so.2")] internal static partial void cairo_paint(nint context);
    [LibraryImport("libcairo.so.2")] internal static partial void cairo_clip(nint context);
    [LibraryImport("libcairo.so.2")] internal static partial void cairo_new_path(nint context);
    [LibraryImport("libcairo.so.2")] internal static partial void cairo_close_path(nint context);
    [LibraryImport("libcairo.so.2")] internal static partial void cairo_move_to(nint context, double x, double y);
    [LibraryImport("libcairo.so.2")] internal static partial void cairo_line_to(nint context, double x, double y);
    [LibraryImport("libcairo.so.2")] internal static partial void cairo_curve_to(nint context, double x1, double y1, double x2, double y2, double x3, double y3);
    [LibraryImport("libcairo.so.2")] internal static partial void cairo_translate(nint context, double x, double y);
    [LibraryImport("libcairo.so.2")] internal static partial void cairo_scale(nint context, double x, double y);
    [LibraryImport("libcairo.so.2")] internal static partial void cairo_set_source_rgba(nint context, double red, double green, double blue, double alpha);
    [LibraryImport("libcairo.so.2")] internal static partial void cairo_set_line_width(nint context, double width);
    [LibraryImport("libcairo.so.2")] internal static partial void cairo_set_line_cap(nint context, int cap);
    [LibraryImport("libcairo.so.2")] internal static partial void cairo_set_line_join(nint context, int join);
    [LibraryImport("libcairo.so.2")] internal static partial void cairo_stroke(nint context);
    [LibraryImport("libpangocairo-1.0.so.0")] internal static partial nint pango_cairo_create_layout(nint context);
    [LibraryImport("libpangocairo-1.0.so.0")] internal static partial void pango_cairo_show_layout(nint context, nint layout);
    [LibraryImport("libpango-1.0.so.0")] internal static partial nint pango_context_get_font_description(nint context);
    [LibraryImport("libpango-1.0.so.0")] internal static partial nint pango_font_description_copy(nint description);
    [LibraryImport("libpango-1.0.so.0")] internal static partial void pango_font_description_free(nint description);
    [LibraryImport("libpango-1.0.so.0")] internal static partial void pango_font_description_set_absolute_size(nint description, double size);
    [LibraryImport("libpango-1.0.so.0")] internal static partial void pango_font_description_set_weight(nint description, int weight);
    [LibraryImport("libpango-1.0.so.0")] internal static partial void pango_layout_set_font_description(nint layout, nint description);
    [LibraryImport("libpango-1.0.so.0", StringMarshalling = StringMarshalling.Utf8)] internal static partial void pango_layout_set_text(nint layout, string text, int length);
    [LibraryImport("libpango-1.0.so.0")] internal static partial void pango_layout_set_width(nint layout, int width);
    [LibraryImport("libpango-1.0.so.0")] internal static partial void pango_layout_set_height(nint layout, int height);
    [LibraryImport("libpango-1.0.so.0")] internal static partial void pango_layout_set_wrap(nint layout, int wrap);
    [LibraryImport("libpango-1.0.so.0")] internal static partial void pango_layout_set_ellipsize(nint layout, int ellipsize);
    [LibraryImport("libpango-1.0.so.0")] internal static partial void pango_layout_set_alignment(nint layout, int alignment);
    [LibraryImport("libpango-1.0.so.0")] internal static partial int pango_layout_get_baseline(nint layout);
}