// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using Microsoft.Maui.Platform.Linux.Interop;
using Microsoft.Maui.Platform.Linux.Input;
using Microsoft.Maui.Platform.Linux.Services;
using SkiaSharp;
using Svg.Skia;
namespace Microsoft.Maui.Platform.Linux.Window;
///
/// X11 window implementation for Linux.
///
public class X11Window : IDisposable
{
private IntPtr _display;
private IntPtr _window;
private IntPtr _wmDeleteMessage;
private int _screen;
private bool _disposed;
private bool _isRunning;
private int _width;
private int _height;
// Cursor handles
private IntPtr _arrowCursor;
private IntPtr _handCursor;
private IntPtr _textCursor;
private IntPtr _currentCursor;
private CursorType _currentCursorType = CursorType.Arrow;
private static int _eventCounter;
///
/// Gets the native display handle.
///
public IntPtr Display => _display;
///
/// Gets the native window handle.
///
public IntPtr Handle => _window;
///
/// Gets the window width.
///
public int Width => _width;
///
/// Gets the window height.
///
public int Height => _height;
///
/// Gets whether the window is running.
///
public bool IsRunning => _isRunning;
///
/// Event raised when a key is pressed.
///
public event EventHandler? KeyDown;
///
/// Event raised when a key is released.
///
public event EventHandler? KeyUp;
///
/// Event raised when text is input.
///
public event EventHandler? TextInput;
///
/// Event raised when the pointer moves.
///
public event EventHandler? PointerMoved;
///
/// Event raised when a pointer button is pressed.
///
public event EventHandler? PointerPressed;
///
/// Event raised when a pointer button is released.
///
public event EventHandler? PointerReleased;
///
/// Event raised when the mouse wheel is scrolled.
///
public event EventHandler? Scroll;
///
/// Event raised when the window needs to be redrawn.
///
public event EventHandler? Exposed;
///
/// Event raised when the window is resized.
///
public event EventHandler<(int Width, int Height)>? Resized;
///
/// Event raised when the window close is requested.
///
public event EventHandler? CloseRequested;
///
/// Event raised when the window gains focus.
///
public event EventHandler? FocusGained;
///
/// Event raised when the window loses focus.
///
public event EventHandler? FocusLost;
///
/// Creates a new X11 window.
///
public X11Window(string title, int width, int height)
{
_width = width;
_height = height;
// Open display
_display = X11.XOpenDisplay(IntPtr.Zero);
if (_display == IntPtr.Zero)
throw new InvalidOperationException("Failed to open X11 display. Is X11 running?");
_screen = X11.XDefaultScreen(_display);
var rootWindow = X11.XRootWindow(_display, _screen);
// Create window
_window = X11.XCreateSimpleWindow(
_display,
rootWindow,
0, 0,
(uint)width, (uint)height,
0,
0,
0xFFFFFF // White background
);
if (_window == IntPtr.Zero)
throw new InvalidOperationException("Failed to create X11 window");
// Set window title
X11.XStoreName(_display, _window, title);
// Set WM_CLASS for desktop integration (taskbar icon matching)
// Use application name from environment or process path for proper desktop matching
string? appName = Environment.GetEnvironmentVariable("APPIMAGE_NAME");
if (string.IsNullOrEmpty(appName))
{
appName = System.IO.Path.GetFileNameWithoutExtension(Environment.ProcessPath ?? "MauiApp");
}
string wmClass = appName.Replace(" ", "").Replace("_", "");
SetWMClass(wmClass, wmClass);
// Select input events
X11.XSelectInput(_display, _window,
XEventMask.KeyPressMask |
XEventMask.KeyReleaseMask |
XEventMask.ButtonPressMask |
XEventMask.ButtonReleaseMask |
XEventMask.PointerMotionMask |
XEventMask.EnterWindowMask |
XEventMask.LeaveWindowMask |
XEventMask.ExposureMask |
XEventMask.StructureNotifyMask |
XEventMask.FocusChangeMask);
// Set up WM_DELETE_WINDOW protocol for proper close handling
_wmDeleteMessage = X11.XInternAtom(_display, "WM_DELETE_WINDOW", false);
// Initialize cursors
_arrowCursor = X11.XCreateFontCursor(_display, 68); // XC_left_ptr
_handCursor = X11.XCreateFontCursor(_display, 60); // XC_hand2
_textCursor = X11.XCreateFontCursor(_display, 152); // XC_xterm
_currentCursor = _arrowCursor;
}
///
/// Sets the cursor type for this window.
///
public void SetCursor(CursorType cursorType)
{
if (_currentCursorType != cursorType)
{
_currentCursorType = cursorType;
IntPtr cursor = cursorType switch
{
CursorType.Hand => _handCursor,
CursorType.Text => _textCursor,
_ => _arrowCursor,
};
if (cursor != _currentCursor)
{
_currentCursor = cursor;
X11.XDefineCursor(_display, _window, _currentCursor);
X11.XFlush(_display);
}
}
}
///
/// Sets the WM_CLASS property for desktop integration.
/// This allows the desktop to match the window to its .desktop file.
///
public void SetWMClass(string resName, string resClass)
{
IntPtr namePtr = IntPtr.Zero;
IntPtr classPtr = IntPtr.Zero;
try
{
namePtr = System.Runtime.InteropServices.Marshal.StringToHGlobalAnsi(resName);
classPtr = System.Runtime.InteropServices.Marshal.StringToHGlobalAnsi(resClass);
var classHint = new XClassHint
{
res_name = namePtr,
res_class = classPtr
};
X11.XSetClassHint(_display, _window, ref classHint);
DiagnosticLog.Debug("X11Window", $"Set WM_CLASS: {resName}, {resClass}");
}
finally
{
if (namePtr != IntPtr.Zero)
System.Runtime.InteropServices.Marshal.FreeHGlobal(namePtr);
if (classPtr != IntPtr.Zero)
System.Runtime.InteropServices.Marshal.FreeHGlobal(classPtr);
}
}
///
/// Sets the window icon from a file. Supports both raster images and SVG.
///
public unsafe void SetIcon(string iconPath)
{
if (string.IsNullOrEmpty(iconPath) || !System.IO.File.Exists(iconPath))
{
DiagnosticLog.Warn("X11Window", "Icon file not found: " + iconPath);
return;
}
DiagnosticLog.Debug("X11Window", "SetIcon called: " + iconPath);
try
{
SKBitmap? bitmap = null;
// Handle SVG icons
if (iconPath.EndsWith(".svg", StringComparison.OrdinalIgnoreCase))
{
DiagnosticLog.Debug("X11Window", "Loading SVG icon");
using var svg = new SKSvg();
svg.Load(iconPath);
if (svg.Picture != null)
{
var cullRect = svg.Picture.CullRect;
float scale = 48f / Math.Max(cullRect.Width, cullRect.Height);
int scaledWidth = (int)(cullRect.Width * scale);
int scaledHeight = (int)(cullRect.Height * scale);
bitmap = new SKBitmap(scaledWidth, scaledHeight, false);
using var canvas = new SKCanvas(bitmap);
canvas.Clear(SKColors.Transparent);
canvas.Scale(scale);
canvas.DrawPicture(svg.Picture);
}
}
else
{
DiagnosticLog.Debug("X11Window", "Loading raster icon");
bitmap = SKBitmap.Decode(iconPath);
}
if (bitmap == null)
{
DiagnosticLog.Warn("X11Window", "Failed to load icon: " + iconPath);
return;
}
DiagnosticLog.Debug("X11Window", $"Loaded bitmap: {bitmap.Width}x{bitmap.Height}");
// Scale to 64x64 if needed
int targetSize = 64;
if (bitmap.Width != targetSize || bitmap.Height != targetSize)
{
var scaled = new SKBitmap(targetSize, targetSize, false);
bitmap.ScalePixels(scaled, SKFilterQuality.High);
bitmap.Dispose();
bitmap = scaled;
}
int width = bitmap.Width;
int height = bitmap.Height;
int dataSize = 2 + width * height;
uint[] iconData = new uint[dataSize];
iconData[0] = (uint)width;
iconData[1] = (uint)height;
for (int y = 0; y < height; y++)
{
for (int x = 0; x < width; x++)
{
var pixel = bitmap.GetPixel(x, y);
iconData[2 + y * width + x] = (uint)((pixel.Alpha << 24) | (pixel.Red << 16) | (pixel.Green << 8) | pixel.Blue);
}
}
bitmap.Dispose();
IntPtr property = X11.XInternAtom(_display, "_NET_WM_ICON", false);
IntPtr type = X11.XInternAtom(_display, "CARDINAL", false);
fixed (uint* data = iconData)
{
X11.XChangeProperty(_display, _window, property, type, 32, 0, (nint)data, dataSize);
}
X11.XFlush(_display);
DiagnosticLog.Debug("X11Window", $"Set window icon: {width}x{height}");
}
catch (Exception ex)
{
DiagnosticLog.Error("X11Window", "Failed to set icon", ex);
}
}
///
/// Shows the window.
///
public void Show()
{
X11.XMapWindow(_display, _window);
X11.XFlush(_display);
_isRunning = true;
}
///
/// Hides the window.
///
public void Hide()
{
X11.XUnmapWindow(_display, _window);
X11.XFlush(_display);
}
///
/// Sets the window title.
///
public void SetTitle(string title)
{
X11.XStoreName(_display, _window, title);
}
///
/// Resizes the window.
///
public void Resize(int width, int height)
{
X11.XResizeWindow(_display, _window, (uint)width, (uint)height);
X11.XFlush(_display);
}
///
/// Moves the window to the specified position.
///
public void SetPosition(int x, int y)
{
X11.XMoveWindow(_display, _window, x, y);
X11.XFlush(_display);
}
///
/// Maximizes the window.
///
public void Maximize()
{
SendWindowStateEvent(true, "_NET_WM_STATE_MAXIMIZED_VERT", "_NET_WM_STATE_MAXIMIZED_HORZ");
}
///
/// Minimizes (iconifies) the window.
///
public void Minimize()
{
X11.XIconifyWindow(_display, _window, _screen);
X11.XFlush(_display);
}
///
/// Restores the window from maximized or minimized state.
///
public void Restore()
{
// Remove maximized state
SendWindowStateEvent(false, "_NET_WM_STATE_MAXIMIZED_VERT", "_NET_WM_STATE_MAXIMIZED_HORZ");
// Map window if it was minimized
X11.XMapWindow(_display, _window);
X11.XFlush(_display);
}
///
/// Sets fullscreen mode.
///
public void SetFullscreen(bool fullscreen)
{
SendWindowStateEvent(fullscreen, "_NET_WM_STATE_FULLSCREEN");
}
private void SendWindowStateEvent(bool add, params string[] stateNames)
{
var wmState = X11.XInternAtom(_display, "_NET_WM_STATE", false);
var rootWindow = X11.XRootWindow(_display, _screen);
foreach (var stateName in stateNames)
{
var stateAtom = X11.XInternAtom(_display, stateName, false);
var xev = new XEvent();
xev.ClientMessageEvent.Type = X11.ClientMessage;
xev.ClientMessageEvent.Window = _window;
xev.ClientMessageEvent.MessageType = wmState;
xev.ClientMessageEvent.Format = 32;
// data.l[0] = action (0=remove, 1=add, 2=toggle)
// data.l[1] = first property
// data.l[2] = second property (optional)
// data.l[3] = source indication (1 = normal application)
xev.ClientMessageEvent.Data.L0 = add ? 1 : 0;
xev.ClientMessageEvent.Data.L1 = (long)stateAtom;
xev.ClientMessageEvent.Data.L2 = 0;
xev.ClientMessageEvent.Data.L3 = 1;
X11.XSendEvent(_display, rootWindow, false,
X11.SubstructureRedirectMask | X11.SubstructureNotifyMask,
ref xev);
}
X11.XFlush(_display);
}
///
/// Processes pending X11 events.
///
public void ProcessEvents()
{
int pending = X11.XPending(_display);
if (pending > 0)
{
if (_eventCounter % 100 == 0)
{
DiagnosticLog.Debug("X11Window", $"ProcessEvents: {pending} pending events");
}
_eventCounter++;
while (X11.XPending(_display) > 0)
{
X11.XNextEvent(_display, out var xEvent);
HandleEvent(ref xEvent);
}
}
}
///
/// Runs the event loop.
///
public void Run()
{
_isRunning = true;
while (_isRunning)
{
X11.XNextEvent(_display, out var xEvent);
HandleEvent(ref xEvent);
}
}
///
/// Stops the event loop.
///
public void Stop()
{
_isRunning = false;
}
private void HandleEvent(ref XEvent xEvent)
{
switch (xEvent.Type)
{
case XEventType.KeyPress:
HandleKeyPress(ref xEvent.KeyEvent);
break;
case XEventType.KeyRelease:
HandleKeyRelease(ref xEvent.KeyEvent);
break;
case XEventType.ButtonPress:
HandleButtonPress(ref xEvent.ButtonEvent);
break;
case XEventType.ButtonRelease:
HandleButtonRelease(ref xEvent.ButtonEvent);
break;
case XEventType.MotionNotify:
HandleMotion(ref xEvent.MotionEvent);
break;
case XEventType.Expose:
if (xEvent.ExposeEvent.Count == 0)
{
Exposed?.Invoke(this, EventArgs.Empty);
}
break;
case XEventType.ConfigureNotify:
HandleConfigure(ref xEvent.ConfigureEvent);
break;
case XEventType.FocusIn:
FocusGained?.Invoke(this, EventArgs.Empty);
break;
case XEventType.FocusOut:
FocusLost?.Invoke(this, EventArgs.Empty);
break;
case XEventType.ClientMessage:
if (xEvent.ClientMessageEvent.Data.L0 == (long)_wmDeleteMessage)
{
CloseRequested?.Invoke(this, EventArgs.Empty);
_isRunning = false;
}
break;
}
}
private void HandleKeyPress(ref XKeyEvent keyEvent)
{
var keysym = KeyMapping.GetKeysym(_display, keyEvent.Keycode, (keyEvent.State & 0x01) != 0);
var key = KeyMapping.FromKeysym(keysym);
var modifiers = KeyMapping.GetModifiers(keyEvent.State);
KeyDown?.Invoke(this, new KeyEventArgs(key, modifiers));
// Generate text input for printable characters, but NOT when Control or Alt is held
// (those are keyboard shortcuts, not text input)
bool isControlHeld = (keyEvent.State & 0x04) != 0; // ControlMask
bool isAltHeld = (keyEvent.State & 0x08) != 0; // Mod1Mask (Alt)
if (keysym >= 32 && keysym <= 126 && !isControlHeld && !isAltHeld)
{
TextInput?.Invoke(this, new TextInputEventArgs(((char)keysym).ToString()));
}
}
private void HandleKeyRelease(ref XKeyEvent keyEvent)
{
var keysym = KeyMapping.GetKeysym(_display, keyEvent.Keycode, (keyEvent.State & 0x01) != 0);
var key = KeyMapping.FromKeysym(keysym);
var modifiers = KeyMapping.GetModifiers(keyEvent.State);
KeyUp?.Invoke(this, new KeyEventArgs(key, modifiers));
}
private void HandleButtonPress(ref XButtonEvent buttonEvent)
{
// Buttons 4 and 5 are scroll wheel
if (buttonEvent.Button == 4)
{
Scroll?.Invoke(this, new ScrollEventArgs(buttonEvent.X, buttonEvent.Y, 0, -1));
return;
}
if (buttonEvent.Button == 5)
{
Scroll?.Invoke(this, new ScrollEventArgs(buttonEvent.X, buttonEvent.Y, 0, 1));
return;
}
var button = MapButton(buttonEvent.Button);
PointerPressed?.Invoke(this, new PointerEventArgs(buttonEvent.X, buttonEvent.Y, button));
}
private void HandleButtonRelease(ref XButtonEvent buttonEvent)
{
// Ignore scroll wheel releases
if (buttonEvent.Button == 4 || buttonEvent.Button == 5)
return;
var button = MapButton(buttonEvent.Button);
PointerReleased?.Invoke(this, new PointerEventArgs(buttonEvent.X, buttonEvent.Y, button));
}
private void HandleMotion(ref XMotionEvent motionEvent)
{
PointerMoved?.Invoke(this, new PointerEventArgs(motionEvent.X, motionEvent.Y));
}
private void HandleConfigure(ref XConfigureEvent configureEvent)
{
if (configureEvent.Width != _width || configureEvent.Height != _height)
{
_width = configureEvent.Width;
_height = configureEvent.Height;
Resized?.Invoke(this, (_width, _height));
}
}
private static PointerButton MapButton(uint button) => button switch
{
1 => PointerButton.Left,
2 => PointerButton.Middle,
3 => PointerButton.Right,
8 => PointerButton.XButton1,
9 => PointerButton.XButton2,
_ => PointerButton.None
};
///
/// Gets the X11 file descriptor for use with select/poll.
///
public int GetFileDescriptor()
{
return X11.XConnectionNumber(_display);
}
#region IDisposable
protected virtual void Dispose(bool disposing)
{
if (!_disposed)
{
// Free cursor resources before closing the display
if (_display != IntPtr.Zero)
{
if (_arrowCursor != IntPtr.Zero) X11.XFreeCursor(_display, _arrowCursor);
if (_handCursor != IntPtr.Zero) X11.XFreeCursor(_display, _handCursor);
if (_textCursor != IntPtr.Zero) X11.XFreeCursor(_display, _textCursor);
_arrowCursor = IntPtr.Zero;
_handCursor = IntPtr.Zero;
_textCursor = IntPtr.Zero;
}
if (_window != IntPtr.Zero)
{
X11.XDestroyWindow(_display, _window);
_window = IntPtr.Zero;
}
if (_display != IntPtr.Zero)
{
X11.XCloseDisplay(_display);
_display = IntPtr.Zero;
}
_disposed = true;
}
}
///
/// Draws pixel data to the window.
///
///
/// Draws pixel data to the window.
///
public void DrawPixels(IntPtr pixels, int width, int height, int stride)
{
if (_display == IntPtr.Zero || _window == IntPtr.Zero) return;
var gc = X11.XDefaultGC(_display, _screen);
var visual = X11.XDefaultVisual(_display, _screen);
var depth = X11.XDefaultDepth(_display, _screen);
// Allocate unmanaged memory and copy the pixel data
var dataSize = height * stride;
var unmanagedData = System.Runtime.InteropServices.Marshal.AllocHGlobal(dataSize);
try
{
// Copy pixel data to unmanaged memory
unsafe
{
Buffer.MemoryCopy((void*)pixels, (void*)unmanagedData, dataSize, dataSize);
}
// Create XImage from the unmanaged pixel data
var image = X11.XCreateImage(
_display,
visual,
(uint)depth,
X11.ZPixmap,
0,
unmanagedData,
(uint)width,
(uint)height,
32,
stride);
if (image != IntPtr.Zero)
{
X11.XPutImage(_display, _window, gc, image, 0, 0, 0, 0, (uint)width, (uint)height);
X11.XDestroyImage(image); // This will free unmanagedData
}
else
{
// If XCreateImage failed, free the memory ourselves
System.Runtime.InteropServices.Marshal.FreeHGlobal(unmanagedData);
}
}
catch
{
System.Runtime.InteropServices.Marshal.FreeHGlobal(unmanagedData);
throw;
}
X11.XFlush(_display);
}
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
~X11Window()
{
Dispose(false);
}
#endregion
}