Per-Monitor DPI Aware in Windows Forms

On release of Window 8.1 RTM which includes change of WM_DPICHANGED message, I rewrote the most of this content.

Windows 8.1 offers new feature on monitor settings: Per-Monitor DPI. It enables different DPI settings for each monitor when using multiple monitors in contrast with older Windows that can use only one DPI settings throughout all monitors.

The implementation of this feature is described in a paper (Writing DPI-Aware Desktop Applications in Windows 8.1 Preview). It is basically simple and consists of following three elements:

  • WM_DPICHANGED: Window message that notifies DPI change when DPI of a monitor containing an application’s window is changed or the window is moved between monitors
  • GetDpiForMonitor: Win32 function to get DPI of a specified monitor
  • “True/PM” or “Per Monitor”: New types of DPI awareness to be set in the application manifest

No information on how to adapt a WinForms appliation to Per-Monitor DPI is provided so far but these elements are applicable to WinForms as well. Actually, making a WinForms application Per-Monitor DPI aware is not so difficult except scaling of Controls and Fonts.

Application manifest

First of all, to enable Per-Monitor DPI for an application, you have to add or modify description of DPI awareness in the application manifest.

<asmv3:application xmlns:asmv3="urn:schemas-microsoft-com:asm.v3">
  <asmv3:windowsSettings
       xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">
    <dpiAware>True/PM</dpiAware>
  </asmv3:windowsSettings>
</asmv3:application>

Also, you had better declare that the application is compatible with Windows 8.1 in the application manifest. Otherwise, you can not obtain correct version information from the OS (Operating system version changes in Windows 8.1 and Windows Server 2012 R2).

<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
  <application>
    <!-- Windows 7 -->
    <supportedOS Id="{35138b9a-5d96-4fbd-8e2d-a2440225f93a}"/>
    <!-- Windows 8 -->
    <supportedOS Id="{4a2f28e3-53b9-4441-ba9c-d69d4a4a6e38}"/>
    <!-- Windows 8.1 -->
    <supportedOS Id="{1f676c76-80e1-4239-95bb-83d0f6d0da78}"/>
  </application>
</compatibility>

Catch WM_DPICHANGED message

You can catch WM_DPICHANGED message and get new DPI from it by overriding Control.WndProc method.

Protected Overrides Sub WndProc(ByRef m As Message)
    MyBase.WndProc(m)
 
    Const WM_DPICHANGED As Integer = &H2E0 '0x02E0 from WinUser.h
 
    If (m.Msg = WM_DPICHANGED) Then
        'wParam
        Dim lo As Single = W32.GetLoWord(m.WParam.ToInt32)

        Trace.WriteLine("New DPI: " & lo)
    End If
End Sub

'In W32 class
Friend Function GetLoWord(dword As Int32) As Int16
    Return Convert.ToInt16(dword And &HFFFF)
End Function

Get DPI of a monitor

You can get DPI of a monitor by using GetDpiForMonitor through P/Invoke. The following is an example to get DPI of the monitor that currently contains the application’s window.

Private Sub GetDpiWindowMonitor()
    'Get handle to this window.
    Dim windowHandle As IntPtr = Process.GetCurrentProcess().MainWindowHandle

    'Get handle to monitor that contains this window.
    Dim monitorHandle As IntPtr = W32.MonitorFromWindow(windowHandle, W32.MONITOR_DEFAULTTOPRIMARY)

    'Get DPI (If the OS is not Windows 8.1 or newer, calling GetDpiForMonitor will cause exception).
    Dim dpiX As UInteger
    Dim dpiY As UInteger

    Dim result As Integer = W32.GetDpiForMonitor(monitorHandle, W32.Monitor_DPI_Type.MDT_Default, dpiX, dpiY)

    If (result = 0) Then 'If S_OK (= 0)
        Trace.WriteLine("DPI of monitor: " & dpiX)
    Else
        Trace.WriteLine("Failed to get DPI of monitor.")
    End If
End Sub

'In W32 class
<DllImport("User32.dll", SetLastError:=True)>
Friend Shared Function MonitorFromWindow(ByVal hwnd As IntPtr,
                                         ByVal dwFlags As Integer) As IntPtr
End Function

Friend Const MONITORINFOF_PRIMARY As Integer = &H1
Friend Const MONITOR_DEFAULTTONEAREST As Integer = &H2
Friend Const MONITOR_DEFAULTTONULL As Integer = &H0
Friend Const MONITOR_DEFAULTTOPRIMARY As Integer = &H1

<DllImport("Shcore.dll", SetLastError:=True)>
Friend Shared Function GetDpiForMonitor(ByVal hmonitor As IntPtr,
                                        ByVal dpiType As Monitor_DPI_Type,
                                        ByRef dpiX As UInteger,
                                        ByRef dpiY As UInteger) As Integer
End Function

Friend Enum Monitor_DPI_Type As Integer
    MDT_Effective_DPI = 0
    MDT_Angular_DPI = 1
    MDT_Raw_DPI = 2
    MDT_Default = MDT_Effective_DPI
End Enum

How to perform scaling

Assuming AutoScaleMode property of the Form is set to DPI (It makes the application at least System DPI aware), there are three types of DPI:

  1. DPI at design time: DPI when a Form is designed in designer of Visual Studio. It is the value of AutoScaleDimensions property. However, you cannot get it from the property at runtime because it will be made to the same as CurrentAutoScaleDimensions property when the Form is initiated.
  2. DPI at runtime (for System DPI aware): DPI at runtime when the OS is not Per-Monitor DPI aware. It is the value of CurrentAutoScaleDimensions property. This value seems to be the same as return value of GetDeviceCaps function with LOGPIXELSX or LOGPIXELSY flag.
  3. DPI at runtime (for Per-Monitor DPI aware): DPI at runtime when the OS is Per-Monitor DPI aware (Windows 8.1 only at this moment) and Per-Monitor DPI is enabled in monitor settings. As mentioned above, WM_DPICHANGED message includes this value and you can get it any time by using GetDpiForMonitor.

Regardless of whether the OS is running under System DPI or Per-Monitor DPI, the scaling from DPI at design time to DPI at runtime (for System DPI aware) is automatically performed when a Form is initiated, provided that AutoScaleMode is set to DPI. Then, if the OS is Per-Monitor DPI, you have to perform scaling from DPI at runtime (for System DPI aware) to DPI at runtime (for Per-Monitor DPI aware) in your own code.

As for location and size of Controls, you can use Control.Scale method to perform scaling (except the location of Form). This method recursively scales all child Controls. So, for instance, if DPI at design time is 96, DPI at runtime (for System DPI aware) is 120 and DPI at runtime (for Per-Monitor DPI aware) is 144, the scaling factor from DPI at runtime (for System DPI aware) to DPI at runtime (for Per-Monitor DPI aware) will be 1.2 and so you just do like this:

Me.Scale(New SizeF(1.2F, 1.2F))

As for Fonts of Controls, scaling will be a little different story. Size of Fonts will be silently and always adjusted by factor from DPI at design time to DPI at runtime (for System DPI aware), provided that AutoScaleMode is set to DPI. So, if DPI at design time is 96 and DPI at runtime (for System DPI aware) is 120, size of Fonts will be always expanded by 1.25. Therefore, if DPI at runtime (for Per-Monitor DPI aware) is 144, the scaling factor from DPI at runtime (for System DPI aware) to DPI at runtime (for Per-Monitor DPI aware) will be 1.2 and so you just need to perform scaling by 1.2 like this:

Me.Font = New Font(Me.Font.FontFamily,
                   Me.Font.Size * 1.2F,
                   Me.Font.Style)

In doing so, you have to think about which Controls you perform scaling. If Font property of a Control is individually specified in designer, its Font settings will not be affected by that of the parent Control. Naturally, scaling Font settings of only the Form is not enough to scale Font settings of such Control. In addition, if Font settings of a Control is not specified, that of the parent Control will be used. The problem is if you perform scaling Font settings of both parent Control and child Control, the Font will be scaled twice. Therefore, you have to carefully pick up Controls that require to be scaled directly. For instance, if you specify Font properties of all Button Controls in the Form, you can perfom their scaling like this (GetChildInControl is a method to get all descendant Controls):

For Each b As Button In GetChildInControl(Me).OfType(Of Button)()
    b.Font = New Font(b.Font.FontFamily,
                      b.Font.Size * 1.2F,
                      b.Font.Style)
Next

The way to pick up Controls depends on actual applications.

If DPI at runtime (for Per-Monitor DPI aware) is changed during runtime, perform scaling again using factor from old DPI to new DPI.

When to perform scaling

Another issue is when you should perform scaling especially during the application’s window is being moved by user from a monitor to another monitor of different DPI settings.

As long as I observed, WM_DPICHANGED message seems to be sent from the OS every time the monitor that has the largest intersection with the window (the same as MonitorFromWindow function) is changed. In other word, the message will come when the intersection with destined monitor becomes bigger than that with originated monitor. On the other hand, if you perform scaling immediately after the application receives the message, as a result of the change of intersection with both originated monitor and destined monitor, the intersection with originated monitor may become bigger because scaling of size of a window will be done as its Left-Top corner is pinned. In such case, the scaling will invoke another WM_DPICHANGED message and this loop will be repeated until the window passes the border of monitors.

To avoid such loop, you have to manage timing of scaling and location of the window concertedly. I think there can be following two types of scaling:

  • Immediate scaling: Immediately after the message comes, perform scaling after moving the window to the location where DPI of monitor that will contain the scaled window is the same DPI as new DPI.
  • Delayed scaling: After the message comes, wait until the window is moved to the location where DPI of monitor that will contain the scaled window is the same as new DPI and then perform scaling.

Since the immediate scaling will be accompanied by a jump of the window’s location, the delayed scaling will look smoother for user. So, in most cases, the latter will be a priority choice while it can be used only when the window is moved by user.

The following is an example of delayed scaling (The value of dpiOld is provided in other part. AdjustWindow is the method to perform scaling).

'Old (previous) DPI
Private dpiOld As Single = 0
 
'New (current) DPI
Private dpiNew As Single = 0
 
'Flag to set whether this window is being moved by user
Private isBeingMoved As Boolean = False
 
'Flag to set whether this window will be adjusted later
Private willBeAdjusted As Boolean = False
 
'Detect user began moving this window.
Private Sub MainForm_ResizeBegin(sender As Object, e As EventArgs) Handles MyBase.ResizeBegin
    isBeingMoved = True
End Sub
 
'Detect user ended moving this window.
Private Sub MainForm_ResizeEnd(sender As Object, e As EventArgs) Handles MyBase.ResizeEnd
    isBeingMoved = False
End Sub
 
'Catch window message of DPI change.
Protected Overrides Sub WndProc(ByRef m As Message)
    MyBase.WndProc(m)
 
    Const WM_DPICHANGED As Integer = &H2E0 '0x02E0 from WinUser.h
 
    If (m.Msg = WM_DPICHANGED) Then
        'wParam
        Dim lo As Single = W32.GetLoWord(m.WParam.ToInt32())
 
        'Hold new DPI as target for adjustment.
        dpiNew = lo
 
        If (dpiOld <> lo) Then
            If (isBeingMoved = True) Then
                willBeAdjusted = True
            Else
                AdjustWindow()
            End If
        Else
            willBeAdjusted = False
        End If
    End If
End Sub
 
'Detect this window is moved.
Private Sub MainForm_Move(sender As Object, e As EventArgs) Handles MyBase.Move
    If (willBeAdjusted = True) AndAlso IsLocationGood() Then
        willBeAdjusted = False
 
        AdjustWindow()
    End If
End Sub
 
'Check if current location of this window is good for delayed adjustment.
Private Function IsLocationGood() As Boolean
    If (dpiOld = 0) Then Return False 'Abort.
 
    Dim factor As Single = dpiNew / dpiOld
 
    'Prepare new rectangle shrinked or expanded sticking Left-Top corner.
    Dim widthDiff As Integer = Convert.ToInt32(Me.ClientSize.Width * factor) - Me.ClientSize.Width
    Dim heightDiff As Integer = Convert.ToInt32(Me.ClientSize.Height * factor) - Me.ClientSize.Height
 
    Dim rect As New W32.RECT() With {.left = Me.Bounds.Left,
                                     .top = Me.Bounds.Top,
                                     .right = Me.Bounds.Right + widthDiff,
                                     .bottom = Me.Bounds.Bottom + heightDiff}
 
    'Get handle to monitor that has the largest intersection with the rectangle.
    Dim handleMonitor As IntPtr = W32.MonitorFromRect(rect, W32.MONITOR_DEFAULTTONULL)
 
    If (handleMonitor <> IntPtr.Zero) Then
        'Check if DPI of the monitor matches.
        Dim dpiX As UInteger
        Dim dpiY As UInteger
 
        Dim result As Integer = W32.GetDpiForMonitor(handleMonitor, W32.Monitor_DPI_Type.MDT_Default, dpiX, dpiY)
 
        If (result = 0) Then 'If S_OK (= 0)
            If (Convert.ToSingle(dpiX) = dpiNew) Then
                Return True
            End If
        End If
    End If
 
    Return False
End Function

'In W32 class (newly added members)
<DllImport("User32.dll", SetLastError:=True)>
Friend Shared Function MonitorFromRect(ByRef lprc As RECT,
                                       ByVal dwFlags As Integer) As IntPtr
End Function<

<StructLayout(LayoutKind.Sequential)>
Friend Structure RECT
    Friend left As Integer
    Friend top As Integer
    Friend right As Integer
    Friend bottom As Integer
End Structure

Please note that to get a rectangle of the same size as the scaled window, apply scaling factor only to the client area because chrome of window including title bar will not be affected by DPI change at this moment.

And the following is an example of immediate scaling.

'Old (previous) DPI
Private dpiOld As Single = 0
 
'New (current) DPI
Private dpiNew As Single = 0
 
'Catch window message of DPI change.
Protected Overrides Sub WndProc(ByRef m As Message)
    MyBase.WndProc(m)
 
    Const WM_DPICHANGED As Integer = &H2E0 '0x02E0 from WinUser.h
 
    If (m.Msg = WM_DPICHANGED) Then
        'wParam
        Dim lo As Single = W32.GetLoWord(m.WParam.ToInt32())
 
        'Hold new DPI as target for adjustment.
        dpiNew = lo
 
        If (dpiOld <> lo) Then
            MoveWindow()
            AdjustWindow()
        End If
    End If
End Sub
 
'Move this window for immediate adjustment. 
Private Sub MoveWindow()
    If (dpiOld = 0) Then Exit Sub 'Abort.
 
    Dim factor As Single = dpiNew / dpiOld
 
    'Prepare new rectangles shrinked or expanded sticking four corners.
    Dim widthDiff As Integer = Convert.ToInt32(Me.ClientSize.Width * factor) - Me.ClientSize.Width
    Dim heightDiff As Integer = Convert.ToInt32(Me.ClientSize.Height * factor) - Me.ClientSize.Height
 
    Dim rectList As New List(Of W32.RECT)()
 
    'Left-Top corner
    rectList.Add(New W32.RECT() With {.left = Me.Bounds.Left,
                                      .top = Me.Bounds.Top,
                                      .right = Me.Bounds.Right + widthDiff,
                                      .bottom = Me.Bounds.Bottom + heightDiff})
 
    'Right-Top corner
    rectList.Add(New W32.RECT() With {.left = Me.Bounds.Left - widthDiff,
                                      .top = Me.Bounds.Top,
                                      .right = Me.Bounds.Right,
                                      .bottom = Me.Bounds.Bottom + heightDiff})
 
    'Left-Bottom corner
    rectList.Add(New W32.RECT() With {.left = Me.Bounds.Left,
                                      .top = Me.Bounds.Top - heightDiff,
                                      .right = Me.Bounds.Right + widthDiff,
                                      .bottom = Me.Bounds.Bottom})
 
    'Right-Bottom corner
    rectList.Add(New W32.RECT() With {.left = Me.Bounds.Left - widthDiff,
                                      .top = Me.Bounds.Top - heightDiff,
                                      .right = Me.Bounds.Right,
                                      .bottom = Me.Bounds.Bottom})
 
    'Get handle to monitor that has the largest intersection with each rectangle.
    For i = 0 To rectList.Count - 1
        Dim handleMonitor As IntPtr = W32.MonitorFromRect(rectList(i), W32.MONITOR_DEFAULTTONULL)
 
        If (handleMonitor <> IntPtr.Zero) Then
            'Check if at least Left-Top corner or Right-Top corner is inside monitors.
            Dim handleLeftTop As IntPtr = W32.MonitorFromPoint(New W32.POINT(rectList(i).left, rectList(i).top),
                                                               W32.MONITOR_DEFAULTTONULL)
            Dim handleRightTop As IntPtr = W32.MonitorFromPoint(New W32.POINT(rectList(i).right, rectList(i).top),
                                                                W32.MONITOR_DEFAULTTONULL)
 
            If (handleLeftTop <> IntPtr.Zero) OrElse (handleRightTop <> IntPtr.Zero) Then
                'Check if DPI of the monitor matches.
                Dim dpiX As UInteger
                Dim dpiY As UInteger
 
                Dim result As Integer = W32.GetDpiForMonitor(handleMonitor, W32.Monitor_DPI_Type.MDT_Default, dpiX, dpiY)
 
                If (result = 0) Then 'If S_OK (= 0)
                    If (Convert.ToSingle(dpiX) = dpiNew) Then
                        'Move this window.
                        Me.Location = New Point(rectList(i).left, rectList(i).top)
                        Exit For
                    End If
                End If
            End If
        End If
    Next
End Sub

'In W32 class (newly added members)
<DllImport("User32.dll", SetLastError:=True)>
Friend Shared Function MonitorFromPoint(ByVal pt As POINT,
                                        ByVal dwFlags As Integer) As IntPtr
End Function

<StructLayout(LayoutKind.Sequential)>
Friend Structure POINT
    Friend x As Integer
    Friend y As Integer

    Friend Sub New(x As Integer, y As Integer)
        Me.x = x
        Me.y = y
    End Sub
End Structure

Based on the above points, I created a demo application of Per-Monitor DPI in WinForms. Developed in both VB and C# on .NET Framework 4.0 with Visual Studio Express 2012 for Windows Desktop.
dpichangedemo12

  • “CurrentAutoScaleDimensions” and “GetDeviceCaps (LOGPIXELSX)” should always be the same.
  • When the OS is Windows 8.1, “GetDpiMonitor” will show DPI of the monitor that contains this window.
  • When the OS is Windows 8.1, “WM_DPICHANGED (Latest)” will show DPI included in the latest message.
  • The center box will show the time when WM_DPICHANGED message came and DPI included in its wParam and location and size of RECT in its lParam. If you DEBUG build in VB version, additional information will be shown.
  • You can switch “Immediate” scaling and “Delayed” scaling by radio buttons. The box next to it will show, in the case of delayed scaling, status of waiting: “Waiting” means waiting, “Resized” means the window is scaled and “Aborted” means the window entered waiting but aborted.
  • “Create Button” in the bottom is an example to dynamically create a Control.

Executable binaries
Complete source code

About these ads

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s