File: System\Windows\Forms\Internals\ScaleHelper.cs
Web Access
Project: src\src\System.Windows.Forms.Primitives\src\System.Windows.Forms.Primitives.csproj (System.Windows.Forms.Primitives)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Drawing;
using System.Drawing.Drawing2D;
using System.Windows.Forms.Primitives.Resources;
using Microsoft.Win32;
 
namespace System.Windows.Forms;
 
/// <summary>
///  Helper class for scaling.
/// </summary>
internal static partial class ScaleHelper
{
    /// <summary>
    ///  Pixels per inch at 100% scaling.
    /// </summary>
    /// <remarks>
    ///  <para>
    ///   Some historical discussion of this can be found
    ///   <see href="https://en.wikipedia.org/wiki/Dots_per_inch#Computer_monitor_DPI_standards">
    ///    here.
    ///   </see>
    ///  </para>
    /// </remarks>
    internal const int OneHundredPercentLogicalDpi = 96;
 
    // Backing field, indicating that we will need to send a PerMonitorV2 query in due course.
    private static bool s_processPerMonitorAware;
    private static Size? s_logicalSmallSystemIconSize;
 
    /// <summary>
    ///  The initial primary monitor DPI (logical pixels per inch) for the process.
    /// </summary>
    /// <remarks>
    ///  <para>
    ///   This value may change when <see cref="SetProcessHighDpiMode(HighDpiMode)"/> is called.
    ///   Application.SetHighDpiMode makes this call. This is intended to be an initial setup step and will not
    ///   change after the application has created the first window. As such you can treat this as a "constant".
    ///  </para>
    ///  <para>
    ///   The System DPI can, of course, change if the user changes the primary monitor's DPI.
    ///  </para>
    ///  <para>
    ///   If the startup thread is unaware this will always be 96 (100%).
    ///  </para>
    /// </remarks>
    internal static int InitialSystemDpi { get; private set; }
 
    static ScaleHelper() => InitializeStatics();
 
    private static void InitializeStatics()
    {
        s_processPerMonitorAware = GetPerMonitorAware();
        InitialSystemDpi = GetSystemDpi();
 
        static int GetSystemDpi()
        {
            // This will only change when the first call to set the process DPI awareness is made. Multiple calls to
            // set the DPI have no effect after making the first call. Depending on what the DPI awareness settings are
            // we'll get either the actual DPI of the primary display at process startup or the default LogicalDpi;
 
            if (!OsVersion.IsWindows10_1607OrGreater())
            {
                using var dc = GetDcScope.ScreenDC;
                return PInvokeCore.GetDeviceCaps(dc, GET_DEVICE_CAPS_INDEX.LOGPIXELSX);
            }
 
            // This avoids needing to create a DC
            return (int)PInvoke.GetDpiForSystem();
        }
 
        static bool GetPerMonitorAware()
        {
            if (!OsVersion.IsWindows10_1607OrGreater())
            {
                return false;
            }
 
            HRESULT result = PInvoke.GetProcessDpiAwareness(
                HANDLE.Null,
                out PROCESS_DPI_AWARENESS processDpiAwareness);
 
            Debug.Assert(result.Succeeded, $"Failed to get ProcessDpi HRESULT: {result}");
            Debug.Assert(Enum.IsDefined(processDpiAwareness));
 
            return result.Succeeded && processDpiAwareness switch
            {
                PROCESS_DPI_AWARENESS.PROCESS_DPI_UNAWARE => false,
                PROCESS_DPI_AWARENESS.PROCESS_SYSTEM_DPI_AWARE => false,
                PROCESS_DPI_AWARENESS.PROCESS_PER_MONITOR_DPI_AWARE => true,
                _ => true
            };
        }
    }
 
    /// <summary>
    ///  Returns a boolean to specify if we should enable processing of WM_DPICHANGED and related messages
    /// </summary>
    internal static bool IsThreadPerMonitorV2Aware
    {
        get
        {
            if (s_processPerMonitorAware)
            {
                // We can't cache this value because different top level windows can have different DPI awareness context
                // for mixed mode applications.
                DPI_AWARENESS_CONTEXT dpiAwareness = PInvoke.GetThreadDpiAwarenessContextInternal();
                return dpiAwareness.IsEquivalent(DPI_AWARENESS_CONTEXT.DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2);
            }
            else
            {
                return false;
            }
        }
    }
 
    /// <summary>
    ///  Indicates, if rescaling becomes necessary, either because we are not 96 DPI or we're PerMonitorV2Aware.
    /// </summary>
    internal static bool IsScalingRequirementMet => IsScalingRequired || s_processPerMonitorAware;
 
    /// <summary>
    ///  Copies the given <see cref="Bitmap"/>, scaling if needed.
    /// </summary>
    /// <inheritdoc cref="ScaleToSize(Bitmap, Size, bool, bool)"/>
    internal static Bitmap CopyAndScaleToSize(Bitmap bitmap, Size desiredSize)
        => ScaleToSize(bitmap, desiredSize, disposeBitmap: false, alwaysCopy: true);
 
    /// <summary>
    ///  Scales the given <see cref="Bitmap"/> to the desired size if needed.
    /// </summary>
    /// <param name="disposeBitmap">
    ///  If <see langword="true"/>, the original bitmap will be disposed if a new bitmap is created.
    /// </param>
    /// <param name="alwaysCopy">
    ///  If <see langword="true"/>, the original will be copied even if it doesn't need scaled.
    /// </param>
    private static Bitmap ScaleToSize(Bitmap bitmap, Size desiredSize, bool disposeBitmap = false, bool alwaysCopy = false)
    {
        Size originalSize = bitmap.Size;
        if (originalSize == desiredSize)
        {
            if (alwaysCopy)
            {
                Bitmap copy = new(bitmap);
                if (disposeBitmap)
                {
                    bitmap.Dispose();
                }
 
                bitmap = copy;
            }
 
            return bitmap;
        }
 
        // In general this is the best quality interpolation mode we have available. While it introduces fuzziness in
        // the resulting image, it will not distort it as NearestNeighbor would (which is extremely important for
        // small zoom factors like 125%, 150%).
        InterpolationMode interpolationMode = InterpolationMode.HighQualityBicubic;
 
        if (desiredSize.Width % originalSize.Width == 0 && desiredSize.Height % originalSize.Height == 0)
        {
            // We will prefer NearestNeighbor algorithm for 200, 300, 400, etc zoom factors, in which each pixel
            // become a 2x2, 3x3, 4x4, etc rectangle. This produces sharp edges in the scaled image and doesn't
            // cause distortions of the original image.
            interpolationMode = InterpolationMode.NearestNeighbor;
        }
        else if (desiredSize.Width < originalSize.Width && desiredSize.Height < originalSize.Height)
        {
            // Shrinking the graphic, use Bilinear. Produces better results as it uses less neighboring pixels.
            interpolationMode = InterpolationMode.HighQualityBilinear;
        }
 
        Bitmap scaledBitmap = new(desiredSize.Width, desiredSize.Height, bitmap.PixelFormat);
 
        using (Bitmap? dispose = disposeBitmap ? bitmap : null)
        using (Graphics graphics = Graphics.FromImage(scaledBitmap))
        {
            graphics.InterpolationMode = interpolationMode;
 
            RectangleF sourceBounds = new(0, 0, bitmap.Size.Width, bitmap.Size.Height);
            RectangleF destinationBounds = new(0, 0, desiredSize.Width, desiredSize.Height);
 
            // Specify a source rectangle shifted by half of pixel to account for GDI+ considering the source origin the
            // center of top-left pixel.
            //
            // Failing to do so will result in the right and bottom of the bitmap lines being interpolated with the
            // graphics' background color, and will appear black even if we cleared the background with transparent color.
            // The apparition of these artifacts depends on the interpolation mode, on the dpi scaling factor, etc.
            // (e.g. at 150% DPI, Bicubic produces them and NearestNeighbor is fine, but at 200% DPI NearestNeighbor
            // also shows them).
            sourceBounds.Offset(-0.5f, -0.5f);
 
            graphics.DrawImage(bitmap, destinationBounds, sourceBounds, GraphicsUnit.Pixel);
        }
 
        return scaledBitmap;
    }
 
    /// <summary>
    ///  Scales a logical (100%) <see cref="Bitmap"/> value to the specified DPI.
    /// </summary>
    /// <param name="logicalBitmap"><see cref="Bitmap"/> in logical units (pixels at 100%).</param>
    /// <returns>Scaled <see cref="Bitmap"/>.</returns>
    internal static Bitmap ScaleToDpi(Bitmap logicalBitmap, int dpi, bool disposeBitmap = false) =>
        dpi == OneHundredPercentLogicalDpi
            ? logicalBitmap
            : ScaleToSize(logicalBitmap, ScaleToDpi(logicalBitmap.Size, dpi), disposeBitmap);
 
    /// <summary>
    ///  Returns whether scaling is required when converting between logical-device units,
    ///  if the application opted in the automatic scaling in the .config file.
    /// </summary>
    internal static bool IsScalingRequired => InitialSystemDpi != OneHundredPercentLogicalDpi;
 
    /// <summary>
    ///  Creates a scaled version of the given non system <see cref="Font"/> to the Windows Accessibility Text Size setting (also
    ///  known as Text Scaling) if needed, otherwise returns <see langword="null"/>.
    /// </summary>
    internal static Font? ScaleToSystemTextSize(Font? font)
    {
        if (font is null || font.IsSystemFont || !OsVersion.IsWindows10_1507OrGreater())
        {
            return null;
        }
 
        // The default(100) and max(225) text scale factor is value what Settings display text scale
        // applies and also clamps the text scale factor value between 100 and 225 value.
        // See https://docs.microsoft.com/windows/uwp/design/input/text-scaling.
        const int MinTextScaleValue = 100;
        const int MaxTextScaleValue = 225;
 
        try
        {
            // Retrieve the text scale factor, which is set via Settings > Display > Make Text Bigger.
            using RegistryKey? key = Registry.CurrentUser.OpenSubKey("Software\\Microsoft\\Accessibility");
            if (key is not null && key.GetValue("TextScaleFactor") is int textScale)
            {
                textScale = Math.Clamp(textScale, MinTextScaleValue, MaxTextScaleValue);
                return textScale == 100 ? null : font.WithSize(font.Size * (textScale / 100.0f));
            }
        }
        catch
        {
            // Failed to read the registry for whatever reason.
#if DEBUG
            throw;
#endif
        }
 
        return null;
    }
 
    /// <summary>
    ///  Scale the given value the specified percent. Never returns less than 1.
    /// </summary>
    /// <param name="percent">Percentage value, with 1.0 equaling 100%.</param>
    internal static int ScaleToPercent(int value, double percent) => Math.Max(1, (int)Math.Round(value * percent));
 
    /// <summary>
    ///  Scales a logical (100%) pixel value to the specified DPI.
    /// </summary>
    /// <param name="logicalValue">Value in logical units (pixels at 100%).</param>
    internal static int ScaleToDpi(int logicalValue, int dpi)
    {
        Debug.Assert(dpi >= 96);
        if (dpi == OneHundredPercentLogicalDpi)
        {
            return logicalValue;
        }
 
        double scalingFactor = dpi / (double)OneHundredPercentLogicalDpi;
        return (int)Math.Round(scalingFactor * logicalValue);
    }
 
    /// <summary>
    ///  Scales a logical (100%) <see cref="Padding"/> value to the specified DPI.
    /// </summary>
    /// <param name="logicalPadding"><see cref="Padding"/> in logical units (pixels at 100%).</param>
    internal static Padding ScaleToDpi(Padding logicalPadding, int dpi) => dpi == OneHundredPercentLogicalDpi
        ? logicalPadding
        : new(
            ScaleToDpi(logicalPadding.Left, dpi),
            ScaleToDpi(logicalPadding.Top, dpi),
            ScaleToDpi(logicalPadding.Right, dpi),
            ScaleToDpi(logicalPadding.Bottom, dpi));
 
    /// <summary>
    ///  Scales a logical (100%) pixel value to the initial system DPI.
    /// </summary>
    /// <param name="logicalValue">Value in logical units (pixels at 100%).</param>
    internal static int ScaleToInitialSystemDpi(int logicalValue) => ScaleToDpi(logicalValue, InitialSystemDpi);
 
    /// <summary>
    ///  Scales a logical (100%) <see cref="Size"/> value to the specified DPI.
    /// </summary>
    /// <param name="logicalSize"><see cref="Size"/> in logical units (pixels at 100%).</param>
    internal static Size ScaleToDpi(Size logicalSize, int dpi) => dpi == OneHundredPercentLogicalDpi
        ? logicalSize
        : new(ScaleToDpi(logicalSize.Width, dpi), ScaleToDpi(logicalSize.Height, dpi));
 
    internal static Size SystemIconSize => new(
        PInvokeCore.GetSystemMetrics(SYSTEM_METRICS_INDEX.SM_CXICON),
        PInvokeCore.GetSystemMetrics(SYSTEM_METRICS_INDEX.SM_CYICON));
 
    internal static Size LogicalSmallSystemIconSize => s_logicalSmallSystemIconSize ??= OsVersion.IsWindows10_1607OrGreater()
        ? new(
            PInvoke.GetSystemMetricsForDpi(SYSTEM_METRICS_INDEX.SM_CXSMICON, OneHundredPercentLogicalDpi),
            PInvoke.GetSystemMetricsForDpi(SYSTEM_METRICS_INDEX.SM_CXSMICON, OneHundredPercentLogicalDpi))
        : new(16, 16);
 
    /// <summary>
    ///  Gets the given icon resource as a <see cref="Bitmap"/> at the default icon size.
    /// </summary>
    internal static Bitmap GetIconResourceAsDefaultSizeBitmap(Type type, string resource) =>
        GetIconResourceAsBestMatchBitmap(type, resource, Size.Empty);
 
    /// <summary>
    ///  Gets the given small icon (usually 16x16) resource as a <see cref="Bitmap"/> scaled to the specified dpi.
    /// </summary>
    internal static Bitmap GetSmallIconResourceAsBitmap(Type type, string resource, int dpi) =>
        GetIconResourceAsBitmap(type, resource, ScaleToDpi(LogicalSmallSystemIconSize, dpi));
 
    /// <summary>
    ///  Gets the given icon resource as a <see cref="Bitmap"/> of the given size.
    /// </summary>
    internal static Bitmap GetIconResourceAsBitmap(Type type, string resource, Size size)
    {
        if (size.IsEmpty)
        {
            size = SystemIconSize;
        }
 
        return ScaleToSize(
            GetIconResourceAsBestMatchBitmap(type, resource, size),
            size,
            disposeBitmap: true);
    }
 
    /// <summary>
    ///  Gets the given icon resource that is closest to the given size as a <see cref="Bitmap"/>.
    /// </summary>
    internal static Bitmap GetIconResourceAsBestMatchBitmap(Stream resourceStream, Size size)
    {
        // While more efficient than what we were doing, this could be even more so if we grabbed the bitmap data
        // directly out of the data stream.
        using Icon icon = new(resourceStream, size.IsEmpty ? SystemIconSize : size);
        return icon.ToBitmap();
    }
 
    /// <summary>
    ///  Gets the given icon resource that is closest to the given size as a <see cref="Bitmap"/>.
    /// </summary>
    internal static Bitmap GetIconResourceAsBestMatchBitmap(Type type, string resource, Size size)
    {
        using Stream stream = type.Module.Assembly.GetManifestResourceStream(type, resource)
            ?? throw new ArgumentException(string.Format(SR.ResourceNotFound, type, resource));
 
        return GetIconResourceAsBestMatchBitmap(stream, size);
    }
 
    /// <summary>
    ///  Gets the DPI mode for the current thread.
    /// </summary>
    internal static HighDpiMode GetThreadHighDpiMode()
    {
        // For Windows 10 RS2 and above
        if (OsVersion.IsWindows10_1607OrGreater())
        {
            DPI_AWARENESS_CONTEXT dpiAwareness = PInvoke.GetThreadDpiAwarenessContextInternal();
 
            if (dpiAwareness.IsEquivalent(DPI_AWARENESS_CONTEXT.DPI_AWARENESS_CONTEXT_SYSTEM_AWARE))
            {
                return HighDpiMode.SystemAware;
            }
 
            if (dpiAwareness.IsEquivalent(DPI_AWARENESS_CONTEXT.DPI_AWARENESS_CONTEXT_UNAWARE))
            {
                return HighDpiMode.DpiUnaware;
            }
 
            if (dpiAwareness.IsEquivalent(DPI_AWARENESS_CONTEXT.DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2))
            {
                return HighDpiMode.PerMonitorV2;
            }
 
            if (dpiAwareness.IsEquivalent(DPI_AWARENESS_CONTEXT.DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE))
            {
                return HighDpiMode.PerMonitor;
            }
 
            if (dpiAwareness.IsEquivalent(DPI_AWARENESS_CONTEXT.DPI_AWARENESS_CONTEXT_UNAWARE_GDISCALED))
            {
                return HighDpiMode.DpiUnawareGdiScaled;
            }
        }
        else if (OsVersion.IsWindows8_1OrGreater())
        {
            PInvoke.GetProcessDpiAwareness(HANDLE.Null, out PROCESS_DPI_AWARENESS processDpiAwareness);
            switch (processDpiAwareness)
            {
                case PROCESS_DPI_AWARENESS.PROCESS_DPI_UNAWARE:
                    return HighDpiMode.DpiUnaware;
                case PROCESS_DPI_AWARENESS.PROCESS_SYSTEM_DPI_AWARE:
                    return HighDpiMode.SystemAware;
                case PROCESS_DPI_AWARENESS.PROCESS_PER_MONITOR_DPI_AWARE:
                    return HighDpiMode.PerMonitor;
            }
        }
        else
        {
            // Available on Vista and higher.
            return PInvoke.IsProcessDPIAware() ? HighDpiMode.SystemAware : HighDpiMode.DpiUnaware;
        }
 
        // We should never get here.
        Debug.Fail("Unexpected DPI state.");
        return HighDpiMode.DpiUnaware;
    }
 
    /// <summary>
    ///  Sets the requested DPI mode. If the current OS does not support the requested mode,
    /// </summary>
    /// <returns><see langword="true"/> if the mode was successfully set.</returns>
    internal static bool SetProcessHighDpiMode(HighDpiMode highDpiMode)
    {
        bool success = false;
 
        if (OsVersion.IsWindows10_1703OrGreater())
        {
            DPI_AWARENESS_CONTEXT dpiAwareness = highDpiMode switch
            {
                HighDpiMode.SystemAware => DPI_AWARENESS_CONTEXT.DPI_AWARENESS_CONTEXT_SYSTEM_AWARE,
                HighDpiMode.PerMonitor => DPI_AWARENESS_CONTEXT.DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE,
                HighDpiMode.PerMonitorV2 =>
                    // Necessary for RS1, since this SetProcessIntPtr IS available here.
                    PInvoke.IsValidDpiAwarenessContext(DPI_AWARENESS_CONTEXT.DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2)
                        ? DPI_AWARENESS_CONTEXT.DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2
                        : DPI_AWARENESS_CONTEXT.DPI_AWARENESS_CONTEXT_SYSTEM_AWARE,
                HighDpiMode.DpiUnawareGdiScaled =>
                    // Make sure we do not try to set a value which has been introduced in later Windows releases.
                    PInvoke.IsValidDpiAwarenessContext(DPI_AWARENESS_CONTEXT.DPI_AWARENESS_CONTEXT_UNAWARE_GDISCALED)
                        ? DPI_AWARENESS_CONTEXT.DPI_AWARENESS_CONTEXT_UNAWARE_GDISCALED
                        : DPI_AWARENESS_CONTEXT.DPI_AWARENESS_CONTEXT_UNAWARE,
                _ => DPI_AWARENESS_CONTEXT.DPI_AWARENESS_CONTEXT_UNAWARE,
            };
 
            success = PInvoke.SetProcessDpiAwarenessContext(dpiAwareness);
        }
        else if (OsVersion.IsWindows8_1OrGreater())
        {
            PROCESS_DPI_AWARENESS dpiAwareness = highDpiMode switch
            {
                HighDpiMode.DpiUnaware or HighDpiMode.DpiUnawareGdiScaled => PROCESS_DPI_AWARENESS.PROCESS_DPI_UNAWARE,
                HighDpiMode.SystemAware => PROCESS_DPI_AWARENESS.PROCESS_SYSTEM_DPI_AWARE,
                HighDpiMode.PerMonitor or HighDpiMode.PerMonitorV2 => PROCESS_DPI_AWARENESS.PROCESS_PER_MONITOR_DPI_AWARE,
                _ => PROCESS_DPI_AWARENESS.PROCESS_SYSTEM_DPI_AWARE,
            };
 
            success = PInvoke.SetProcessDpiAwareness(dpiAwareness).Succeeded;
        }
        else
        {
            // Vista or higher has SetProcessDPIAware
            PROCESS_DPI_AWARENESS dpiAwareness = (PROCESS_DPI_AWARENESS)(-1);
            switch (highDpiMode)
            {
                case HighDpiMode.DpiUnaware:
                case HighDpiMode.DpiUnawareGdiScaled:
                    // We can return, there is nothing to set if we assume we're already in DpiUnaware.
                    return true;
                case HighDpiMode.SystemAware:
                case HighDpiMode.PerMonitor:
                case HighDpiMode.PerMonitorV2:
                    dpiAwareness = PROCESS_DPI_AWARENESS.PROCESS_SYSTEM_DPI_AWARE;
                    break;
            }
 
            if (dpiAwareness == PROCESS_DPI_AWARENESS.PROCESS_SYSTEM_DPI_AWARE)
            {
                success = PInvoke.SetProcessDPIAware();
            }
        }
 
        // Need to reset as our DPI might change if this was the first call to set the DPI context for the process.
        InitializeStatics();
 
        return success;
    }
 
    /// <summary>
    ///  Enters a scope during which the current thread's DPI awareness context is set to
    ///  <paramref name="awareness"/>
    /// </summary>
    /// <param name="awareness">The new DPI awareness for the current thread</param>
    /// <returns>
    ///  An object that, when disposed, will reset the current thread's DPI awareness to the value it had when the
    ///  object was created.
    /// </returns>
    public static IDisposable EnterDpiAwarenessScope(
        DPI_AWARENESS_CONTEXT awareness,
        DPI_HOSTING_BEHAVIOR dpiHosting = DPI_HOSTING_BEHAVIOR.DPI_HOSTING_BEHAVIOR_MIXED)
        => new DpiAwarenessScope(awareness, dpiHosting);
 
    /// <summary>
    ///  Invokes the given action in the System Aware DPI context.
    /// </summary>
    public static T InvokeInSystemAwareContext<T>(Func<T> func)
    {
        using (EnterDpiAwarenessScope(DPI_AWARENESS_CONTEXT.DPI_AWARENESS_CONTEXT_SYSTEM_AWARE))
        {
            return func();
        }
    }
}