내용 보기

작성자

관리자 (IP : 106.247.248.10)

날짜

2022-09-13 08:53

제목

[WPF] 높은 DPI를 사용하여 WPF에서 디스플레이 열거 | Bruno Sonnino


WPF에서 DPI에 상관없이 정확한 위치에 창을 배치하기 위한 솔루션
* 전체 소스는 파일첨부


Sometimes, we need to get our display disposition to position windows on them in specific places. The usual way to do it in .NET is to use the Screen class, with a code like this one:

internal record Rect(int X, int Y, int Width, int Height);
internal record Display(string DeviceName, Rect Bounds, Rect WorkingArea, double ScalingFactor);

private void InitializeDisplayCanvas()
{
    
    var minX = 0;
    var minY = 0;
    var maxX = 0;
    var maxY = 0;
    foreach(var screen in Screen.AllScreens)
    {
        if (minX > screen.WorkingArea.X)
            minX = screen.WorkingArea.X;
        if (minY > screen.WorkingArea.Y)
            minY = screen.WorkingArea.Y;
        if (maxX < screen.WorkingArea.X+screen.WorkingArea.Width)
            maxX = screen.WorkingArea.X+screen.WorkingArea.Width;
        if (maxY < screen.WorkingArea.Y+screen.WorkingArea.Height)
            maxY = screen.WorkingArea.Y+screen.WorkingArea.Height;

        _displays.Add(new Display(screen.DeviceName, screen.Bounds, screen.WorkingArea, 1.0));
    }
    DisplayCanvas.Width = maxX - minX;
    DisplayCanvas.Height = maxY - minY;
    DisplayCanvas.RenderTransform = new TranslateTransform(-minX, -minY);
    var background = new System.Windows.Shapes.Rectangle
    {
        Width = DisplayCanvas.Width,
        Height = DisplayCanvas.Height,
        Fill = new SolidColorBrush(System.Windows.Media.Color.FromArgb(1,242,242,242)),
    };
    Canvas.SetLeft(background, minX);
    Canvas.SetTop(background, minY);
    DisplayCanvas.Children.Add(background);
    var numDisplay = 0;
    foreach (var display in _displays)
    {
        numDisplay++;
        var border = new Border
        {
            Width = display.WorkingArea.Width,
            Height = display.WorkingArea.Height,
            Background = System.Windows.Media.Brushes.DarkGray,
            CornerRadius = new CornerRadius(30)
        };
        var text = new TextBlock
        {
            Text = numDisplay.ToString(),
            FontSize = 200,
            FontWeight = FontWeights.Bold,  
            HorizontalAlignment = System.Windows.HorizontalAlignment.Center,
            VerticalAlignment = VerticalAlignment.Center,
        };
        border.Child = text;
        Canvas.SetLeft(border, display.WorkingArea.Left);
        Canvas.SetTop(border, display.WorkingArea.Top);
        DisplayCanvas.Children.Add(border);
    }

}

If you run the code, you will see something like this:

The monitors aren’t contiguous, as you would expect. But, the worst is that it doesn’t work well. If you try to position a window in the center of the middle monitor, with a code like this:

private void Button_Click(object sender, RoutedEventArgs e)
{
    var display = _displays[0];
    var window = new NewWindow
    {
        Top = display.WorkingArea.Top + (display.WorkingArea.Height - 200) / 2,
        Left = display.WorkingArea.Left + (display.WorkingArea.Width - 200) / 2,
    };
    window.Show();
}

You will get something like this:

As you can see, the window is far from centered. And why is that? The reason for these problems are the usage of high DPI. When you set the displays in you system, you set the resolution and the scaling factor:

In my setup, I have three monitors:

  • 1920×1080 125%
  • 3840×2160 150%
  • 1920×1080 100%

This scale factor is not taken in account when you are enumerating the displays and, when I am enumerating them, I have no way of getting this value. That way, everything is positioned in the wrong place. It would work fine if all monitors had a scaling factor of 100%, but most of the time that’s not true.

Researching for High DPI WPF, I came to this page, which shows the use of the appmanifest, so I gave it a try. I added a new item, Application Manifest and uncommented these lines:

<application xmlns="urn:schemas-microsoft-com:asm.v3">
    <windowsSettings>
      <dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true</dpiAware>
      <longPathAware xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">true</longPathAware>
    </windowsSettings>
  </application>

And nothing happened. Then I added this line:

<windowsSettings>
  <dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitor</dpiAwareness>
  <dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true</dpiAware>
  <longPathAware xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">true</longPathAware>
</windowsSettings>

Running the project, I got the correct display settings:

But the secondary screen is positioned in the wrong place. If I change the dpiAwareness clause to Unaware, I get the wrong display disposition, but the window is positioned at the center!

We need to get the scale factor for each monitor, to get the correct values in both cases.

Before going further we must notice that this code has also another problem: it’s a WPF app that is using a Winforms class: Screen is declared in System.Windows.Forms and there is no equivalent in WPF. To use, it you must add UseWindowsForms in the csproj:

<Project Sdk="Microsoft.NET.Sdk">
	<PropertyGroup>
		<OutputType>WinExe</OutputType>
		<TargetFramework>net6.0-windows</TargetFramework>
		<Nullable>enable</Nullable>
		<UseWPF>true</UseWPF>
		<UseWindowsForms>true</UseWindowsForms>
	</PropertyGroup>
</Project>

That is something I really don’t like to do: use Winforms in a WPF project. If you want to check the project, the branch I’ve used is here.

So, I tried to find a way to enumerate the displays in WPF and have the right scaling factor, and I found two ways: query the registry or use Win32 API. Yes, the API that is available since the beginning of Windows, and it’s still there.

We could go to http://pinvoke.net/ to get the signatures we need for our project. This is a great site and a nice resource when you want to use Win32 APIs in C#, but we’ll use another resource: CsWin32, a nice source generator that generates the P/Invokes for us. I have already written an article about it, if you didn’t see it, you should check it out.

For that, we should install the NuGet package Microsoft.Windows.CsWin32 (be sure to check the pre-release box). Once installed, you must create a text file and name it NativeMethods.txt. There, we will add the names of all the methods and structures we need.

The first function we need is EnumDisplayMonitors, which we add there. With that, we can use it in our method:

private unsafe void InitializeDisplayCanvas()
{
    Windows.Win32.PInvoke.EnumDisplayMonitors(null, null, enumProc, IntPtr.Zero);
}

We are passing all parameters as null, except for the third one, which is the callback function. As I don’t know the parameters of this function, I will let Visual Studio create it for me. Just press Ctrl+. and Generate method enumProc and Visual Studio will generate the method for us:

private unsafe BOOL enumProc(HMONITOR param0, HDC param1, RECT* param2, LPARAM param3)
{
    throw new NotImplementedException();
}

We can change the names of the parameters and add the return value true to continue the enumeration. This function will be called for each monitor in the system and will pass in the first parameter the handle of the monitor. With that handle we can determine its properties with GetMonitorInfo (which we add in NativeMethods.txt) and use it to get the monitor information. This function uses a MONITORINFO struct as a parameter, and we must declare it before calling the function:

private unsafe BOOL enumProc(HMONITOR monitor, HDC hdc, RECT* clipRect, LPARAM data)
{
    var mi = new MONITORINFO
    {
        cbSize = (uint)Marshal.SizeOf(typeof(MONITORINFO))
    };
    if (Windows.Win32.PInvoke.GetMonitorInfo(monitor, ref mi))
    {

    }
    return true;
}

You have noticed that we’ve set the size of the structure before passing it to the function. This is common to many WinApi functions and forgetting to set this member or setting it with a wrong value is a common source of bugs.

MONITORINFO is declared in Windows.Win32.Graphics.Gdi, which is included in the usings for the file. Now, mi has the data for the monitor:

internal partial struct MONITORINFO
{
	internal uint cbSize;
	internal winmdroot.Foundation.RECT rcMonitor;
	internal winmdroot.Foundation.RECT rcWork;
	internal uint dwFlags;
}

But it doesn’t have the name of the monitor. This was solved by using the structure MONITORINFOEX, which has the name of the monitor. There is a catch, here: although GetMonitorInfo has an overload that uses the MONITORINFOEX structure, it’s not declared in CsWin32, so we must do a trick, here:

private unsafe BOOL enumProc(HMONITOR monitor, HDC hdc, RECT* clipRect, LPARAM data)
{
    var mi = new MONITORINFOEXW();
    mi.monitorInfo.cbSize = (uint)Marshal.SizeOf(typeof(MONITORINFOEXW));
    
    if (Windows.Win32.PInvoke.GetMonitorInfo(monitor, (MONITORINFO*) &mi))
    {

    }
    return true;
}

MONITORINFOEX is not declared automatically, you must use the explicit wide version, MONITORINFOEXW and add the impport to NativeMethods.txt. To use it, you must create the structure, initialize it and then cast the pointer to a pointer of MONITORINFO. It’s not the most beautiful code, but it works. Now we have the code to enumerate the displays:

public class DisplayList
{
    private List<Display> _displays = new List<Display>();

    public DisplayList()
    {
        QueryDisplayDevices();
    }

    public List<Display> Displays => _displays;

    private unsafe void QueryDisplayDevices()
    {
        PInvoke.EnumDisplayMonitors(null, null, enumProc, IntPtr.Zero);
    }

    private unsafe BOOL enumProc(HMONITOR monitor, HDC hdc, RECT* clipRect, LPARAM data)
    {
        var mi = new MONITORINFOEXW();
        mi.monitorInfo.cbSize = (uint)Marshal.SizeOf(typeof(MONITORINFOEXW));

        if (PInvoke.GetMonitorInfo(monitor, (MONITORINFO*)&mi))
        {
            var display = new Display(mi.szDevice.ToString(),
                new Rect(mi.monitorInfo.rcMonitor.left, mi.monitorInfo.rcMonitor.top, 
                    mi.monitorInfo.rcMonitor.Width, mi.monitorInfo.rcMonitor.Height),
                new Rect(mi.monitorInfo.rcWork.left, mi.monitorInfo.rcWork.top, 
                    mi.monitorInfo.rcWork.Width, mi.monitorInfo.rcWork.Height),
                1);
            _displays.Add(display);
        }
        return true;
    }
}

private void InitializeDisplayCanvas()
{
    var displayList = new DisplayList();
    _displays = displayList.Displays;
    InitializeCanvasWithDisplays();
}

private void InitializeCanvasWithDisplays()
{
    var minX = 0;
    var minY = 0;
    var maxX = 0;
    var maxY = 0;
    foreach (var display in _displays)
    {
        if (minX > display.WorkingArea.X)
            minX = display.WorkingArea.X;
        if (minY > display.WorkingArea.Y)
            minY = display.WorkingArea.Y;
        if (maxX < display.WorkingArea.X + display.WorkingArea.Width)
            maxX = display.WorkingArea.X + display.WorkingArea.Width;
        if (maxY < display.WorkingArea.Y + display.WorkingArea.Height)
            maxY = display.WorkingArea.Y + display.WorkingArea.Height;
    }
    DisplayCanvas.Width = maxX - minX;
    DisplayCanvas.Height = maxY - minY;
    DisplayCanvas.RenderTransform = new TranslateTransform(-minX, -minY);
    var background = new System.Windows.Shapes.Rectangle
    {
        Width = DisplayCanvas.Width,
        Height = DisplayCanvas.Height,
        Fill = new SolidColorBrush(System.Windows.Media.Color.FromArgb(1, 242, 242, 242)),
    };
    Canvas.SetLeft(background, minX);
    Canvas.SetTop(background, minY);
    DisplayCanvas.Children.Add(background);
    var numDisplay = 0;
    foreach (var display in _displays)
    {
        numDisplay++;
        var border = new Border
        {
            Width = display.WorkingArea.Width,
            Height = display.WorkingArea.Height,
            Background = System.Windows.Media.Brushes.DarkGray,
            CornerRadius = new CornerRadius(30)
        };
        var text = new TextBlock
        {
            Text = numDisplay.ToString(),
            FontSize = 200,
            FontWeight = FontWeights.Bold,
            HorizontalAlignment = System.Windows.HorizontalAlignment.Center,
            VerticalAlignment = VerticalAlignment.Center,
        };
        border.Child = text;
        Canvas.SetLeft(border, display.WorkingArea.X);
        Canvas.SetTop(border, display.WorkingArea.Y);
        DisplayCanvas.Children.Add(border);
    }
}
private void Button_Click(object sender, RoutedEventArgs e)
{
    var display = _displays[0];
    var window = new NewWindow
    {
        Top = display.Bounds.Y + (display.Bounds.Height - 200) / display.ScalingFactor / 2,
        Left = display.Bounds.X + (display.Bounds.Width - 200) / display.ScalingFactor / 2,
    };
    window.Show();
}

If you run this code, you’ll get the same result we’ve got with the previous version, but at least we don’t have to include WinForms here. But we can go a step further and get the scale for the monitors. For that, we must use the EnumDisplaySettings function, like this:

if (PInvoke.GetMonitorInfo(monitor, (MONITORINFO*)&mi))
{
    var dm = new DEVMODEW
    {
        dmSize = (ushort)Marshal.SizeOf(typeof(DEVMODEW))
    };
    PInvoke.EnumDisplaySettings(mi.szDevice.ToString(), ENUM_DISPLAY_SETTINGS_MODE.ENUM_CURRENT_SETTINGS, ref dm);

    var scalingFactor = Math.Round((double)dm.dmPelsWidth / mi.monitorInfo.rcMonitor.Width, 2);
    var display = new Display(mi.szDevice.ToString(),
        new Rect(mi.monitorInfo.rcMonitor.left, mi.monitorInfo.rcMonitor.top, 
            (int)(mi.monitorInfo.rcMonitor.Width * scalingFactor), (int)(mi.monitorInfo.rcMonitor.Height * scalingFactor)),
        new Rect(mi.monitorInfo.rcWork.left, mi.monitorInfo.rcWork.top, 
            (int)(mi.monitorInfo.rcWork.Width * scalingFactor), (int)(mi.monitorInfo.rcWork.Height * scalingFactor)),
        scalingFactor);
    _displays.Add(display);
}

Now, if you run the code, you’ll see that it runs fine and positions the window in the center of the display.

We now have a WPF application that can detect all installed displays and position a window correctly in any display. The key to that is, besides using the Win32 API to get the display information, to use the App Manifest dpiAwareness setting to Unaware. If you change to any other setting, you will get the wrong positions, because the scaling factors will only be correct when the Unaware setting is used.

The full source code for this article is at https://github.com/bsonnino/EnumeratingDisplays

출처1

https://blogs.msmvps.com/bsonnino/2022/09/10/enumerating-displays-in-wpf-with-high-dpi/

출처2