// 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 System.Text.RegularExpressions; namespace Microsoft.Maui.Platform.Linux.Services; /// /// Provides HiDPI and display scaling detection for Linux. /// public class HiDpiService { private const float DefaultDpi = 96f; private float _scaleFactor = 1.0f; private float _dpi = DefaultDpi; private bool _initialized; /// /// Gets the current scale factor. /// public float ScaleFactor => _scaleFactor; /// /// Gets the current DPI. /// public float Dpi => _dpi; /// /// Event raised when scale factor changes. /// public event EventHandler? ScaleChanged; /// /// Initializes the HiDPI detection service. /// public void Initialize() { if (_initialized) return; _initialized = true; DetectScaleFactor(); } /// /// Detects the current scale factor using multiple methods. /// public void DetectScaleFactor() { float scale = 1.0f; float dpi = DefaultDpi; // Try multiple detection methods in order of preference if (TryGetEnvironmentScale(out float envScale)) { scale = envScale; } else if (TryGetGnomeScale(out float gnomeScale, out float gnomeDpi)) { scale = gnomeScale; dpi = gnomeDpi; } else if (TryGetKdeScale(out float kdeScale)) { scale = kdeScale; } else if (TryGetX11Scale(out float x11Scale, out float x11Dpi)) { scale = x11Scale; dpi = x11Dpi; } else if (TryGetXrandrScale(out float xrandrScale)) { scale = xrandrScale; } UpdateScale(scale, dpi); } private void UpdateScale(float scale, float dpi) { if (Math.Abs(_scaleFactor - scale) > 0.01f || Math.Abs(_dpi - dpi) > 0.01f) { var oldScale = _scaleFactor; _scaleFactor = scale; _dpi = dpi; ScaleChanged?.Invoke(this, new ScaleChangedEventArgs(oldScale, scale, dpi)); } } /// /// Gets scale from environment variables. /// private static bool TryGetEnvironmentScale(out float scale) { scale = 1.0f; // GDK_SCALE (GTK3/4) var gdkScale = Environment.GetEnvironmentVariable("GDK_SCALE"); if (!string.IsNullOrEmpty(gdkScale) && float.TryParse(gdkScale, out float gdk)) { scale = gdk; return true; } // GDK_DPI_SCALE (GTK3/4) var gdkDpiScale = Environment.GetEnvironmentVariable("GDK_DPI_SCALE"); if (!string.IsNullOrEmpty(gdkDpiScale) && float.TryParse(gdkDpiScale, out float gdkDpi)) { scale = gdkDpi; return true; } // QT_SCALE_FACTOR var qtScale = Environment.GetEnvironmentVariable("QT_SCALE_FACTOR"); if (!string.IsNullOrEmpty(qtScale) && float.TryParse(qtScale, out float qt)) { scale = qt; return true; } // QT_SCREEN_SCALE_FACTORS (can be per-screen) var qtScreenScales = Environment.GetEnvironmentVariable("QT_SCREEN_SCALE_FACTORS"); if (!string.IsNullOrEmpty(qtScreenScales)) { // Format: "screen1=1.5;screen2=2.0" or just "1.5" var first = qtScreenScales.Split(';')[0]; if (first.Contains('=')) { first = first.Split('=')[1]; } if (float.TryParse(first, out float qtScreen)) { scale = qtScreen; return true; } } return false; } /// /// Gets scale from GNOME settings. /// private static bool TryGetGnomeScale(out float scale, out float dpi) { scale = 1.0f; dpi = DefaultDpi; try { // Try gsettings for GNOME var result = RunCommand("gsettings", "get org.gnome.desktop.interface scaling-factor"); if (!string.IsNullOrEmpty(result)) { var match = Regex.Match(result, @"uint32\s+(\d+)"); if (match.Success && int.TryParse(match.Groups[1].Value, out int gnomeScale)) { if (gnomeScale > 0) { scale = gnomeScale; } } } // Also check text-scaling-factor for fractional scaling result = RunCommand("gsettings", "get org.gnome.desktop.interface text-scaling-factor"); if (!string.IsNullOrEmpty(result) && float.TryParse(result.Trim(), out float textScale)) { if (textScale > 0.5f) { scale = Math.Max(scale, textScale); } } // Check for GNOME 40+ experimental fractional scaling result = RunCommand("gsettings", "get org.gnome.mutter experimental-features"); if (result != null && result.Contains("scale-monitor-framebuffer")) { // Fractional scaling is enabled, try to get actual scale result = RunCommand("gdbus", "call --session --dest org.gnome.Mutter.DisplayConfig --object-path /org/gnome/Mutter/DisplayConfig --method org.gnome.Mutter.DisplayConfig.GetCurrentState"); if (result != null) { // Parse for scale value var scaleMatch = Regex.Match(result, @"'scale':\s*<(\d+\.?\d*)>"); if (scaleMatch.Success && float.TryParse(scaleMatch.Groups[1].Value, out float mutterScale)) { scale = mutterScale; } } } return scale > 1.0f || Math.Abs(scale - 1.0f) < 0.01f; } catch { return false; } } /// /// Gets scale from KDE settings. /// private static bool TryGetKdeScale(out float scale) { scale = 1.0f; try { // Try kreadconfig5 for KDE Plasma 5 var result = RunCommand("kreadconfig5", "--file kdeglobals --group KScreen --key ScaleFactor"); if (!string.IsNullOrEmpty(result) && float.TryParse(result.Trim(), out float kdeScale)) { if (kdeScale > 0) { scale = kdeScale; return true; } } // Try KDE Plasma 6 result = RunCommand("kreadconfig6", "--file kdeglobals --group KScreen --key ScaleFactor"); if (!string.IsNullOrEmpty(result) && float.TryParse(result.Trim(), out float kde6Scale)) { if (kde6Scale > 0) { scale = kde6Scale; return true; } } // Check kdeglobals config file directly var configPath = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".config", "kdeglobals"); if (File.Exists(configPath)) { var lines = File.ReadAllLines(configPath); bool inKScreenSection = false; foreach (var line in lines) { if (line.Trim() == "[KScreen]") { inKScreenSection = true; continue; } if (inKScreenSection && line.StartsWith("[")) { break; } if (inKScreenSection && line.StartsWith("ScaleFactor=")) { var value = line.Substring("ScaleFactor=".Length); if (float.TryParse(value, out float fileScale)) { scale = fileScale; return true; } } } } return false; } catch { return false; } } /// /// Gets scale from X11 Xresources. /// private bool TryGetX11Scale(out float scale, out float dpi) { scale = 1.0f; dpi = DefaultDpi; try { // Try xrdb query var result = RunCommand("xrdb", "-query"); if (!string.IsNullOrEmpty(result)) { // Look for Xft.dpi var match = Regex.Match(result, @"Xft\.dpi:\s*(\d+)"); if (match.Success && float.TryParse(match.Groups[1].Value, out float xftDpi)) { dpi = xftDpi; scale = xftDpi / DefaultDpi; return true; } } // Try reading .Xresources directly var xresourcesPath = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".Xresources"); if (File.Exists(xresourcesPath)) { var content = File.ReadAllText(xresourcesPath); var match = Regex.Match(content, @"Xft\.dpi:\s*(\d+)"); if (match.Success && float.TryParse(match.Groups[1].Value, out float fileDpi)) { dpi = fileDpi; scale = fileDpi / DefaultDpi; return true; } } // Try X11 directly return TryGetX11DpiDirect(out scale, out dpi); } catch { return false; } } /// /// Gets DPI directly from X11 server. /// private bool TryGetX11DpiDirect(out float scale, out float dpi) { scale = 1.0f; dpi = DefaultDpi; try { var display = XOpenDisplay(IntPtr.Zero); if (display == IntPtr.Zero) return false; try { int screen = XDefaultScreen(display); // Get physical dimensions int widthMm = XDisplayWidthMM(display, screen); int heightMm = XDisplayHeightMM(display, screen); int widthPx = XDisplayWidth(display, screen); int heightPx = XDisplayHeight(display, screen); if (widthMm > 0 && heightMm > 0) { float dpiX = widthPx * 25.4f / widthMm; float dpiY = heightPx * 25.4f / heightMm; dpi = (dpiX + dpiY) / 2; scale = dpi / DefaultDpi; return true; } return false; } finally { XCloseDisplay(display); } } catch { return false; } } /// /// Gets scale from xrandr output. /// private static bool TryGetXrandrScale(out float scale) { scale = 1.0f; try { var result = RunCommand("xrandr", "--query"); if (string.IsNullOrEmpty(result)) return false; // Look for connected displays with scaling // Format: "eDP-1 connected primary 2560x1440+0+0 (normal left inverted right x axis y axis) 309mm x 174mm" var lines = result.Split('\n'); foreach (var line in lines) { if (!line.Contains("connected") || line.Contains("disconnected")) continue; // Try to find resolution and physical size var resMatch = Regex.Match(line, @"(\d+)x(\d+)\+\d+\+\d+"); var mmMatch = Regex.Match(line, @"(\d+)mm x (\d+)mm"); if (resMatch.Success && mmMatch.Success) { if (int.TryParse(resMatch.Groups[1].Value, out int widthPx) && int.TryParse(mmMatch.Groups[1].Value, out int widthMm) && widthMm > 0) { float dpi = widthPx * 25.4f / widthMm; scale = dpi / DefaultDpi; return true; } } } return false; } catch { return false; } } private static string? RunCommand(string command, string arguments) { try { using var process = new System.Diagnostics.Process(); process.StartInfo = new System.Diagnostics.ProcessStartInfo { FileName = command, Arguments = arguments, RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, CreateNoWindow = true }; process.Start(); var output = process.StandardOutput.ReadToEnd(); process.WaitForExit(1000); return output; } catch { return null; } } /// /// Converts logical pixels to physical pixels. /// public float ToPhysicalPixels(float logicalPixels) { return logicalPixels * _scaleFactor; } /// /// Converts physical pixels to logical pixels. /// public float ToLogicalPixels(float physicalPixels) { return physicalPixels / _scaleFactor; } /// /// Gets the recommended font scale factor. /// public float GetFontScaleFactor() { // Some desktop environments use a separate text scaling factor try { var result = RunCommand("gsettings", "get org.gnome.desktop.interface text-scaling-factor"); if (!string.IsNullOrEmpty(result) && float.TryParse(result.Trim(), out float textScale)) { return textScale; } } catch { } return _scaleFactor; } #region X11 Interop [DllImport("libX11.so.6")] private static extern nint XOpenDisplay(nint display); [DllImport("libX11.so.6")] private static extern void XCloseDisplay(nint display); [DllImport("libX11.so.6")] private static extern int XDefaultScreen(nint display); [DllImport("libX11.so.6")] private static extern int XDisplayWidth(nint display, int screen); [DllImport("libX11.so.6")] private static extern int XDisplayHeight(nint display, int screen); [DllImport("libX11.so.6")] private static extern int XDisplayWidthMM(nint display, int screen); [DllImport("libX11.so.6")] private static extern int XDisplayHeightMM(nint display, int screen); #endregion } /// /// Event args for scale change events. /// public class ScaleChangedEventArgs : EventArgs { /// /// Gets the old scale factor. /// public float OldScale { get; } /// /// Gets the new scale factor. /// public float NewScale { get; } /// /// Gets the new DPI. /// public float NewDpi { get; } public ScaleChangedEventArgs(float oldScale, float newScale, float newDpi) { OldScale = oldScale; NewScale = newScale; NewDpi = newDpi; } }