diff --git a/Hosting/LinuxMauiAppBuilderExtensions.cs b/Hosting/LinuxMauiAppBuilderExtensions.cs index 5fc43fa..e52cbe5 100644 --- a/Hosting/LinuxMauiAppBuilderExtensions.cs +++ b/Hosting/LinuxMauiAppBuilderExtensions.cs @@ -71,6 +71,7 @@ public static class LinuxMauiAppBuilderExtensions builder.Services.TryAddSingleton(); builder.Services.TryAddSingleton(); builder.Services.TryAddSingleton(); + builder.Services.TryAddSingleton(_ => MonitorService.Instance); // Register GTK host service builder.Services.TryAddSingleton(_ => GtkHostService.Instance); diff --git a/Interop/X11.cs b/Interop/X11.cs index 9eeecbc..a414acc 100644 --- a/Interop/X11.cs +++ b/Interop/X11.cs @@ -11,6 +11,13 @@ internal static partial class X11 public const int ZPixmap = 2; + // Event types + public const int ClientMessage = 33; + + // Event masks for XSendEvent + public const long SubstructureRedirectMask = 1L << 20; + public const long SubstructureNotifyMask = 1L << 19; + [LibraryImport(LibX11)] public static partial IntPtr XOpenDisplay(IntPtr displayName); @@ -72,6 +79,9 @@ internal static partial class X11 [LibraryImport(LibX11)] public static partial int XResizeWindow(IntPtr display, IntPtr window, uint width, uint height); + [LibraryImport(LibX11)] + public static partial int XIconifyWindow(IntPtr display, IntPtr window, int screen); + [LibraryImport(LibX11)] public static partial int XMoveResizeWindow(IntPtr display, IntPtr window, int x, int y, uint width, uint height); diff --git a/Interop/XRandR.cs b/Interop/XRandR.cs new file mode 100644 index 0000000..4df5152 --- /dev/null +++ b/Interop/XRandR.cs @@ -0,0 +1,139 @@ +// 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.InteropServices; + +namespace Microsoft.Maui.Platform.Linux.Interop; + +/// +/// XRandR (X Resize and Rotate) extension interop for multi-monitor support. +/// +internal static partial class XRandR +{ + private const string LibXrandr = "libXrandr.so.2"; + + // RROutput and RRCrtc are XIDs (unsigned long) + // RRMode is also an XID + + [LibraryImport(LibXrandr)] + public static partial IntPtr XRRGetScreenResources(IntPtr display, IntPtr window); + + [LibraryImport(LibXrandr)] + public static partial IntPtr XRRGetScreenResourcesCurrent(IntPtr display, IntPtr window); + + [LibraryImport(LibXrandr)] + public static partial void XRRFreeScreenResources(IntPtr resources); + + [LibraryImport(LibXrandr)] + public static partial IntPtr XRRGetOutputInfo(IntPtr display, IntPtr resources, ulong output); + + [LibraryImport(LibXrandr)] + public static partial void XRRFreeOutputInfo(IntPtr outputInfo); + + [LibraryImport(LibXrandr)] + public static partial IntPtr XRRGetCrtcInfo(IntPtr display, IntPtr resources, ulong crtc); + + [LibraryImport(LibXrandr)] + public static partial void XRRFreeCrtcInfo(IntPtr crtcInfo); + + [LibraryImport(LibXrandr)] + public static partial int XRRQueryExtension(IntPtr display, out int eventBase, out int errorBase); + + [LibraryImport(LibXrandr)] + public static partial int XRRQueryVersion(IntPtr display, out int major, out int minor); + + [LibraryImport(LibXrandr)] + public static partial void XRRSelectInput(IntPtr display, IntPtr window, int mask); + + // RRNotify mask values + public const int RRScreenChangeNotifyMask = 1 << 0; + public const int RRCrtcChangeNotifyMask = 1 << 1; + public const int RROutputChangeNotifyMask = 1 << 2; + public const int RROutputPropertyNotifyMask = 1 << 3; + + // Connection status + public const int RR_Connected = 0; + public const int RR_Disconnected = 1; + public const int RR_UnknownConnection = 2; +} + +/// +/// XRRScreenResources structure layout. +/// +[StructLayout(LayoutKind.Sequential)] +internal struct XRRScreenResources +{ + public ulong Timestamp; + public ulong ConfigTimestamp; + public int NCrtc; + public IntPtr Crtcs; // RRCrtc* (array of ulongs) + public int NOutput; + public IntPtr Outputs; // RROutput* (array of ulongs) + public int NMode; + public IntPtr Modes; // XRRModeInfo* +} + +/// +/// XRROutputInfo structure layout. +/// +[StructLayout(LayoutKind.Sequential)] +internal struct XRROutputInfo +{ + public ulong Timestamp; + public ulong Crtc; // RRCrtc - current CRTC (0 if not connected) + public IntPtr Name; // char* + public int NameLen; + public ulong MmWidth; // Physical width in mm + public ulong MmHeight; // Physical height in mm + public ushort Connection; // RRConnection status + public ushort SubpixelOrder; + public int NCrtc; + public IntPtr Crtcs; // RRCrtc* - possible CRTCs + public int NClone; + public IntPtr Clones; // RROutput* + public int NMode; + public int NPreferred; + public IntPtr Modes; // RRMode* +} + +/// +/// XRRCrtcInfo structure layout. +/// +[StructLayout(LayoutKind.Sequential)] +internal struct XRRCrtcInfo +{ + public ulong Timestamp; + public int X; + public int Y; + public uint Width; + public uint Height; + public ulong Mode; // RRMode - current mode + public ushort Rotation; + public int NOutput; + public IntPtr Outputs; // RROutput* + public ushort Rotations; // Possible rotations + public int NPossible; + public IntPtr Possible; // RROutput* +} + +/// +/// XRRModeInfo structure layout. +/// +[StructLayout(LayoutKind.Sequential)] +internal struct XRRModeInfo +{ + public ulong Id; // RRMode + public uint Width; + public uint Height; + public ulong DotClock; + public uint HSyncStart; + public uint HSyncEnd; + public uint HTotal; + public uint HSkew; + public uint VSyncStart; + public uint VSyncEnd; + public uint VTotal; + public IntPtr Name; // char* + public uint NameLength; + public ulong ModeFlags; +} diff --git a/Services/DeviceDisplayService.cs b/Services/DeviceDisplayService.cs index ce15034..359ab01 100644 --- a/Services/DeviceDisplayService.cs +++ b/Services/DeviceDisplayService.cs @@ -51,6 +51,32 @@ public class DeviceDisplayService : IDeviceDisplay { try { + // Try to use MonitorService for accurate XRandR-based info + var primaryMonitor = MonitorService.Instance.PrimaryMonitor; + if (primaryMonitor != null) + { + double scaleFactor = GetScaleFactor(); + // If scale factor not set via env, use monitor's DPI-based scale + if (scaleFactor == 1.0 && primaryMonitor.ScaleFactor > 1.0) + { + scaleFactor = Math.Round(primaryMonitor.ScaleFactor * 4) / 4; // Round to nearest 0.25 + } + + DisplayOrientation orientation = (primaryMonitor.Width <= primaryMonitor.Height) + ? DisplayOrientation.Portrait + : DisplayOrientation.Landscape; + + _mainDisplayInfo = new DisplayInfo( + primaryMonitor.Width, + primaryMonitor.Height, + scaleFactor, + orientation, + DisplayRotation.Rotation0, + (float)primaryMonitor.RefreshRate); + return; + } + + // Fall back to GDK IntPtr screen = GdkNative.gdk_screen_get_default(); if (screen != IntPtr.Zero) { diff --git a/Services/MonitorInfo.cs b/Services/MonitorInfo.cs new file mode 100644 index 0000000..3008632 --- /dev/null +++ b/Services/MonitorInfo.cs @@ -0,0 +1,104 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.Maui.Platform.Linux.Services; + +/// +/// Represents information about a display monitor. +/// +public record MonitorInfo +{ + /// + /// Gets the unique identifier for this monitor (XRandR output ID). + /// + public ulong Id { get; init; } + + /// + /// Gets the monitor name (e.g., "HDMI-1", "DP-2", "eDP-1"). + /// + public string Name { get; init; } = string.Empty; + + /// + /// Gets whether this is the primary monitor. + /// + public bool IsPrimary { get; init; } + + /// + /// Gets the X position of the monitor in the virtual desktop. + /// + public int X { get; init; } + + /// + /// Gets the Y position of the monitor in the virtual desktop. + /// + public int Y { get; init; } + + /// + /// Gets the width in pixels. + /// + public int Width { get; init; } + + /// + /// Gets the height in pixels. + /// + public int Height { get; init; } + + /// + /// Gets the physical width in millimeters. + /// + public int PhysicalWidthMm { get; init; } + + /// + /// Gets the physical height in millimeters. + /// + public int PhysicalHeightMm { get; init; } + + /// + /// Gets the refresh rate in Hz. + /// + public double RefreshRate { get; init; } + + /// + /// Gets the horizontal DPI. + /// + public double DpiX + { + get + { + if (PhysicalWidthMm <= 0) return 96.0; + return Width / (PhysicalWidthMm / 25.4); + } + } + + /// + /// Gets the vertical DPI. + /// + public double DpiY + { + get + { + if (PhysicalHeightMm <= 0) return 96.0; + return Height / (PhysicalHeightMm / 25.4); + } + } + + /// + /// Gets the average DPI. + /// + public double Dpi => (DpiX + DpiY) / 2.0; + + /// + /// Gets the scale factor based on DPI (1.0 = 96 DPI). + /// + public double ScaleFactor => Dpi / 96.0; + + /// + /// Gets the bounds rectangle. + /// + public (int X, int Y, int Width, int Height) Bounds => (X, Y, Width, Height); + + public override string ToString() + { + return $"{Name}: {Width}x{Height}+{X}+{Y} @ {RefreshRate:F1}Hz ({Dpi:F0} DPI){(IsPrimary ? " [Primary]" : "")}"; + } +} diff --git a/Services/MonitorService.cs b/Services/MonitorService.cs new file mode 100644 index 0000000..82fcbb0 --- /dev/null +++ b/Services/MonitorService.cs @@ -0,0 +1,376 @@ +// 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.InteropServices; +using Microsoft.Maui.Platform.Linux.Interop; + +namespace Microsoft.Maui.Platform.Linux.Services; + +/// +/// Service for querying and monitoring display configuration using XRandR. +/// +public class MonitorService : IDisposable +{ + private static MonitorService? _instance; + private static readonly object _lock = new(); + + private IntPtr _display; + private IntPtr _rootWindow; + private List _monitors = new(); + private bool _initialized; + private bool _disposed; + private int _eventBase; + private int _errorBase; + + /// + /// Gets the singleton instance of the monitor service. + /// + public static MonitorService Instance + { + get + { + if (_instance == null) + { + lock (_lock) + { + _instance ??= new MonitorService(); + } + } + return _instance; + } + } + + /// + /// Gets the list of connected monitors. + /// + public IReadOnlyList Monitors + { + get + { + EnsureInitialized(); + return _monitors.AsReadOnly(); + } + } + + /// + /// Gets the primary monitor. + /// + public MonitorInfo? PrimaryMonitor + { + get + { + EnsureInitialized(); + return _monitors.FirstOrDefault(m => m.IsPrimary) ?? _monitors.FirstOrDefault(); + } + } + + /// + /// Gets the total virtual desktop bounds (union of all monitors). + /// + public (int X, int Y, int Width, int Height) VirtualDesktopBounds + { + get + { + EnsureInitialized(); + if (_monitors.Count == 0) + return (0, 0, 1920, 1080); // Default fallback + + int minX = _monitors.Min(m => m.X); + int minY = _monitors.Min(m => m.Y); + int maxX = _monitors.Max(m => m.X + m.Width); + int maxY = _monitors.Max(m => m.Y + m.Height); + + return (minX, minY, maxX - minX, maxY - minY); + } + } + + /// + /// Event raised when monitor configuration changes. + /// + public event EventHandler? ConfigurationChanged; + + private MonitorService() + { + } + + private void EnsureInitialized() + { + if (_initialized) return; + + lock (_lock) + { + if (_initialized) return; + + try + { + _display = X11.XOpenDisplay(IntPtr.Zero); + if (_display == IntPtr.Zero) + { + Console.WriteLine("[MonitorService] Failed to open X11 display"); + _initialized = true; + return; + } + + int screen = X11.XDefaultScreen(_display); + _rootWindow = X11.XRootWindow(_display, screen); + + // Check if XRandR is available + if (XRandR.XRRQueryExtension(_display, out _eventBase, out _errorBase) == 0) + { + Console.WriteLine("[MonitorService] XRandR extension not available"); + _initialized = true; + return; + } + + if (XRandR.XRRQueryVersion(_display, out int major, out int minor) == 0) + { + Console.WriteLine("[MonitorService] Failed to query XRandR version"); + _initialized = true; + return; + } + + Console.WriteLine($"[MonitorService] XRandR {major}.{minor} available"); + + RefreshMonitors(); + _initialized = true; + } + catch (Exception ex) + { + Console.WriteLine($"[MonitorService] Initialization failed: {ex.Message}"); + _initialized = true; + } + } + } + + /// + /// Refreshes the monitor list from the system. + /// + public void RefreshMonitors() + { + if (_display == IntPtr.Zero) return; + + var newMonitors = new List(); + + IntPtr resources = IntPtr.Zero; + try + { + resources = XRandR.XRRGetScreenResourcesCurrent(_display, _rootWindow); + if (resources == IntPtr.Zero) + { + Console.WriteLine("[MonitorService] Failed to get screen resources"); + return; + } + + var res = Marshal.PtrToStructure(resources); + + // Get array of output IDs + var outputIds = new ulong[res.NOutput]; + for (int i = 0; i < res.NOutput; i++) + { + outputIds[i] = (ulong)Marshal.ReadInt64(res.Outputs, i * 8); + } + + // Track which monitor is at 0,0 (likely primary) + MonitorInfo? primaryCandidate = null; + + foreach (var outputId in outputIds) + { + IntPtr outputInfo = IntPtr.Zero; + IntPtr crtcInfo = IntPtr.Zero; + + try + { + outputInfo = XRandR.XRRGetOutputInfo(_display, resources, outputId); + if (outputInfo == IntPtr.Zero) continue; + + var output = Marshal.PtrToStructure(outputInfo); + + // Skip disconnected outputs + if (output.Connection != XRandR.RR_Connected) + continue; + + // Skip outputs without a CRTC (not currently displaying) + if (output.Crtc == 0) + continue; + + crtcInfo = XRandR.XRRGetCrtcInfo(_display, resources, output.Crtc); + if (crtcInfo == IntPtr.Zero) continue; + + var crtc = Marshal.PtrToStructure(crtcInfo); + + // Get output name + string name = output.Name != IntPtr.Zero && output.NameLen > 0 + ? Marshal.PtrToStringAnsi(output.Name, output.NameLen) ?? $"Output-{outputId}" + : $"Output-{outputId}"; + + // Calculate refresh rate from mode info + double refreshRate = GetRefreshRate(resources, crtc.Mode); + + var monitor = new MonitorInfo + { + Id = outputId, + Name = name, + X = crtc.X, + Y = crtc.Y, + Width = (int)crtc.Width, + Height = (int)crtc.Height, + PhysicalWidthMm = (int)output.MmWidth, + PhysicalHeightMm = (int)output.MmHeight, + RefreshRate = refreshRate, + IsPrimary = false // Will be set below + }; + + newMonitors.Add(monitor); + + // Track the monitor at 0,0 as primary candidate + if (crtc.X == 0 && crtc.Y == 0) + { + primaryCandidate = monitor; + } + } + finally + { + if (crtcInfo != IntPtr.Zero) + XRandR.XRRFreeCrtcInfo(crtcInfo); + if (outputInfo != IntPtr.Zero) + XRandR.XRRFreeOutputInfo(outputInfo); + } + } + + // Set primary monitor (the one at 0,0 or the first one) + if (newMonitors.Count > 0) + { + var primary = primaryCandidate ?? newMonitors[0]; + var index = newMonitors.IndexOf(primary); + if (index >= 0) + { + newMonitors[index] = primary with { IsPrimary = true }; + } + } + + var oldMonitors = _monitors; + _monitors = newMonitors; + + // Log detected monitors + Console.WriteLine($"[MonitorService] Detected {_monitors.Count} monitor(s):"); + foreach (var monitor in _monitors) + { + Console.WriteLine($" {monitor}"); + } + + // Notify if configuration changed + if (!MonitorListsEqual(oldMonitors, newMonitors)) + { + ConfigurationChanged?.Invoke(this, new MonitorConfigurationChangedEventArgs(_monitors)); + } + } + finally + { + if (resources != IntPtr.Zero) + XRandR.XRRFreeScreenResources(resources); + } + } + + private double GetRefreshRate(IntPtr resources, ulong modeId) + { + if (modeId == 0) return 60.0; // Default + + var res = Marshal.PtrToStructure(resources); + + for (int i = 0; i < res.NMode; i++) + { + var modePtr = res.Modes + i * Marshal.SizeOf(); + var mode = Marshal.PtrToStructure(modePtr); + + if (mode.Id == modeId) + { + if (mode.HTotal > 0 && mode.VTotal > 0 && mode.DotClock > 0) + { + return (double)mode.DotClock / (mode.HTotal * mode.VTotal); + } + break; + } + } + + return 60.0; // Default fallback + } + + private bool MonitorListsEqual(List a, List b) + { + if (a.Count != b.Count) return false; + + for (int i = 0; i < a.Count; i++) + { + if (a[i].Id != b[i].Id || + a[i].X != b[i].X || + a[i].Y != b[i].Y || + a[i].Width != b[i].Width || + a[i].Height != b[i].Height) + { + return false; + } + } + + return true; + } + + /// + /// Gets the monitor containing the specified point. + /// + public MonitorInfo? GetMonitorAt(int x, int y) + { + EnsureInitialized(); + return _monitors.FirstOrDefault(m => + x >= m.X && x < m.X + m.Width && + y >= m.Y && y < m.Y + m.Height); + } + + /// + /// Gets the monitor containing the center of the specified rectangle. + /// + public MonitorInfo? GetMonitorFromRect(int x, int y, int width, int height) + { + int centerX = x + width / 2; + int centerY = y + height / 2; + return GetMonitorAt(centerX, centerY); + } + + /// + /// Gets the monitor by name (e.g., "HDMI-1", "DP-2"). + /// + public MonitorInfo? GetMonitorByName(string name) + { + EnsureInitialized(); + return _monitors.FirstOrDefault(m => + m.Name.Equals(name, StringComparison.OrdinalIgnoreCase)); + } + + public void Dispose() + { + if (!_disposed) + { + if (_display != IntPtr.Zero) + { + X11.XCloseDisplay(_display); + _display = IntPtr.Zero; + } + _disposed = true; + } + } +} + +/// +/// Event args for monitor configuration changes. +/// +public class MonitorConfigurationChangedEventArgs : EventArgs +{ + /// + /// Gets the updated list of monitors. + /// + public IReadOnlyList Monitors { get; } + + public MonitorConfigurationChangedEventArgs(IReadOnlyList monitors) + { + Monitors = monitors; + } +} diff --git a/Window/X11Window.cs b/Window/X11Window.cs index e2c11e0..5004e34 100644 --- a/Window/X11Window.cs +++ b/Window/X11Window.cs @@ -321,6 +321,84 @@ public class X11Window : IDisposable 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. ///