How to build high DPI aware native Windows desktop applications

If you’re developing native applications for Windows using Win32 or MFC and you want to support high DPIs so that the application looks crisp on any display, you have to do a lot of things by hand. That is because the technologies for building native UIs, that is MFC, GDI, GDI+, do not provide DPI scaling support. In this article, I will walk through some of the problems of supporting DPI scaling and the solutions for them.

Overview

Every display has a different value for the DPI (dots per inch), which, by the way, for displays is rather called PPI (pixels per inch); DPI is a term originated from printers. However, Windows allows virtualizing this value by enabling users to set a scaling factor for text and UI elements. This is possible from the Display Settings.

The scaling value is given as a percentage value, such as 100%, 125%, 150%, 175%, 200%, etc. Custom percentages (between 100 – 500) are also possible from Advanced scaling settings, although not recommended. These scaling factors correspond to DPI values as follows:

ScalingDPI
100%96
125%120
150%144
175%168
200%192

Keep in mind that this effective DPI value may be different than the native (raw) DPI value of the display. You could have a 140 DPI display with the effective value reported as 96 when set to the 100% scaling.

You’re application may be running on a display with a scaling factor greater than the default 100%. This is especially reasonable to assume for newer laptop devices or displays (such as 4K ones, for instance). It is also more likely in multi-monitor environments, where different monitors are likely to be set up with different scaling.

The system can provide some automatic scaling for applications that can’t handle it by themselves (such as old applications that cannot or will not be updated). This is possible by changing the “Override High DPI scaling behavior” option in the Compatibility tab of the application’s (executable’s) properties. You can read more about this here:

If you want to ensure your application’s user interface is crisp no matter the DPI, that text fonts, controls, windows, and others are adjusted appropriately (increase or decrease in size with the scaling) you need to make it adjust based on the settings on the monitor it’s running on. To do so, you must make it per-monitor DPI aware.

Per Monitor DPI Scaling Support

An application can run with one of four different DPI awareness modes:

  • Unaware, views all displays as having 96 DPI
  • System, introduced in Windows Vista, views all displays as having the DPI value of the primary display
  • Per-Monitor, introduced in Windows 8.1, views the DPI of the display that the application window is primary located on. When the DPI changes, top-level windows are notified of the change, but there is no DPI scaling of any UI elements.
  • Per-Monitor V2, introduced in Windows 10 1703, similar to Per-Monitor, but support automatic scaling of non-client area, theme-drawn bitmaps in common controls, and dialogs.

However, as already mentioned, GDI/GDI+ and MFC do not provide any per-monitor DPI awareness support. That means, if you’re using these technologies, you’re on your own to provide this support and appropriate scaling. Part of the solution is to replace Win32 APIs that only support a single DPI (the primary display DPI) with ones that support per-monitor settings, where available, or write your own, where that’s not available.

Here is a list of such APIs. If you’re using these in your code, then your app needs changes.

System (primary monitor) DPIPer-monitor DPI
GetDeviceCapsGetDpiForMonitor / GetDpiForWindow
GetSystemMetricsGetSystemMetricsForDpi
SystemParametersInfoSystemParametersInfoForDpi
AdjustWindowRectExAdjustWindowRectExForDpi
CWnd::CalcWindowRectAdjustWindowRectExForDpi

GetDpiForMonitor vs GetDpiForWindow

The GetDpiForMonitor function returns the DPI value of a monitor. The GetDpiForWindow function returns the DPI value for a window. However, their behavior, and, therefore, return value, depends on some settings.

GetDpiForMonitor returns a different value based on the PROCESS_DPI_AWARENESS value. This is value set per application that indicates how much scaling is provided by the system and how much is done by the application. The behavior of GetDpiForMonitor is described in the following table:

PROCESS_DPI_AWARENESSDescriptionGetDpiForMonitor return value
PROCESS_DPI_UNAWAREDPI unaware96
PROCESS_SYSTEM_DPI_AWARESystem DPI aware (all monitors have the DPI value of the primary monitor)System DPI (the DPI value of the primary monitor)
PROCESS_PER_MONITOR_DPI_AWAREPer monitor DPI awareThe actual DPI value set by the user for the specified monitor

GetDpiForWindow also returns a different value based on the DPI_AWARENESS value. This is a value per thread, process, or window. This was introduced in Windows 10 1607 as an improvement over the per-application setting provided by PROCESS_DPI_AWARENESS. The behavior of GetDpiForWindow is described in the following table:

DPI_AWARENESSDescriptionGetDpiForWindow return value
DPI_AWARENESS_UNAWAREDPI unaware96
DPI_AWARENESS_SYSTEM_AWARESystem DPI awareSystem DPI (the DPI value of the primary monitor)
DPI_AWARENESS_PER_MONITOR_AWAREPer monitor DPI awareThe actual DPI value set by the user for the monitor where the window is located.

You can change the values for PROCESS_DPI_AWARENESS and DPI_AWARENESS either programmatically or with a manifest.

APIModify APIManifest
PROCESS_DPI_AWARENESSSetProcessDpiAwarenessApp manifest
DPI_AWARENESSSetThreadDpiAwarenessContext
SetProcessDpiAwarenessContext
App manifest

The application manifest should contain the following (for details, see the link from the above table):

<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0" xmlns:asmv3="urn:schemas-microsoft-com:asm.v3">
   <asmv3:application xmlns:asmv3="urn:schemas-microsoft-com:asm.v3">
      <asmv3:windowsSettings xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">
         <dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">True/PM</dpiAware>
         <dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2,PerMonitor</dpiAwareness>
      </asmv3:windowsSettings>
   </asmv3:application>
</assembly>

Although in this example both <dpiAware> and <dpiAwareness> are set, the former is ignored in Windows 10 1607 or newer if the latter is present.

Retrieving monitor information

You can retrieve monitor information using EnumDisplayMonitors to enumerate the available monitors and functions such as GetMonitorInfo, GetDpiForMonitor, and EnumDisplaySettings to retrieve various monitor information. An example is shown in the following listing:

int main()
{
   ::EnumDisplayMonitors(
      nullptr,
      nullptr,
      [](HMONITOR Arg1,
         HDC Arg2,
         LPRECT Arg3,
         LPARAM Arg4)
      {
         MONITORINFOEXA mif;
         mif.cbSize = sizeof(MONITORINFOEXA);

         if (::GetMonitorInfoA(Arg1, &mif) != 0)
         {
            std::cout << mif.szDevice << '\n';
            std::cout
               << "monitor rect:    "
               << '(' << mif.rcMonitor.left << ',' << mif.rcMonitor.top << ")-"
               << '(' << mif.rcMonitor.right << ',' << mif.rcMonitor.bottom << ")\n";
            std::cout
               << "work rect:       "
               << '(' << mif.rcWork.left << ',' << mif.rcWork.top << ")-"
               << '(' << mif.rcWork.right << ',' << mif.rcWork.bottom << ")\n";
         }

         UINT xdpi, ydpi;
         LRESULT success = ::GetDpiForMonitor(Arg1, MDT_EFFECTIVE_DPI, &xdpi, &ydpi);
         if (success == S_OK)
         {
            std::cout << "DPI (effective): " << xdpi << ',' << ydpi << '\n';
         }

         success = ::GetDpiForMonitor(Arg1, MDT_ANGULAR_DPI, &xdpi, &ydpi);
         if (success == S_OK)
         {
            std::cout << "DPI (angular):   " << xdpi << ',' << ydpi << '\n';
         }

         success = ::GetDpiForMonitor(Arg1, MDT_RAW_DPI, &xdpi, &ydpi);
         if (success == S_OK)
         {
            std::cout << "DPI (raw):       " << xdpi << ',' << ydpi << '\n';
         }

         DEVMODEA dm;
         dm.dmSize = sizeof(DEVMODEA);
         if (::EnumDisplaySettingsA(mif.szDevice, ENUM_CURRENT_SETTINGS, &dm) != 0)
         {
            std::cout << "BPP:             " << dm.dmBitsPerPel << '\n';
            std::cout << "resolution:      " << dm.dmPelsWidth << ',' << dm.dmPelsHeight << '\n';
            std::cout << "frequency:       " << dm.dmDisplayFrequency << '\n';
         }

         std::cout << '\n';
         return TRUE;
      },
      0);
}

With three monitors set at 100%, 125% and 150% scaling, this code displays the following for me:

\\.\DISPLAY1
monitor rect:    (-1920,0)-(0,1080)
work rect:       (-1920,0)-(0,1040)
DPI (effective): 96,96
DPI (angular):   123,123
DPI (raw):       141,141
BPP:             32
resolution:      1920,1080
frequency:       60

\\.\DISPLAY2
monitor rect:    (0,0)-(2560,1440)
work rect:       (0,0)-(2560,1390)
DPI (effective): 120,120
DPI (angular):   108,108
DPI (raw):       108,108
BPP:             32
resolution:      2560,1440
frequency:       59

\\.\DISPLAY3
monitor rect:    (2560,0)-(4480,1200)
work rect:       (2560,0)-(4480,1140)
DPI (effective): 144,144
DPI (angular):   93,93
DPI (raw):       94,94
BPP:             32
resolution:      1920,1200
frequency:       59

The value of the scaling is the ratio between the effective DPI (seen above) and 96. For instance, on the second display, 120 / 96 is 1.25, therefore the scaling for that display is set to 125%.

GetDeviceCaps

The use of GetDeviceCaps to retrieve the value of the DPI is a clear code smell that your code is not DPI aware. Typically, you could see code like the following to get the DPI:

int GetDpi(HWND hWnd)
{
   HDC hDC = ::GetDC(hWnd);
   INT ydpi = ::GetDeviceCaps(hDC, LOGPIXELSY);
   ::ReleaseDC(hWnd, hDC);
   return ydpi;
}
int dpi = GetDpi(GetDesktopWindow());
int scaling = static_cast<int>(100.0 * dpi / 96);

This is what the docs are saying about LOGPIXELSX and LOGPIXELSY:

LOGPIXELSX
Number of pixels per logical inch along the screen width. In a system with multiple display monitors, this value is the same for all monitors.
LOGPIXELSY
Number of pixels per logical inch along the screen height. In a system with multiple display monitors, this value is the same for all monitors.

Therefore, this function is not able to return a per-monitor DPI. For that, you should use GetDpiForWindow, available since Windows 10 1607.

int GetDpi(HWND hWnd)
{
   return static_cast<int>(::GetDpiForWindow(hWnd));
}

If you’re targeting an earlier version, you can also use GetDpiForMonitor, which is available since Windows 8.1.

int GetDpi(HWND hWnd)
{
   bool v81 = IsWindows8Point1OrGreater();
   bool v10 = IsWindows10OrGreater();

   if (v81 || v10)
   {
      HMONITOR hMonitor = ::MonitorFromWindow(hWnd, MONITOR_DEFAULTTONEAREST);
      UINT xdpi, ydpi;
      LRESULT success = ::GetDpiForMonitor(hMonitor, MDT_EFFECTIVE_DPI, &xdpi, &ydpi);
      if (success == S_OK)
      {
         return static_cast<int>(ydpi);
      }

      return 96;
   }
   else
   {
      HDC hDC = ::GetDC(hWnd);
      INT ydpi = ::GetDeviceCaps(hDC, LOGPIXELSY);
      ::ReleaseDC(NULL, hDC);

      return ydpi;
   }
}

This implementation calls GetDpiForMonitor if the code is running on Windows 8.1 or newer, and falls back to GetDeviceCaps for older systems. This is, most likely, not necessarily, since prior to Windows 10 1607 it’s unlikely you can do truly per-monitor DPI aware native apps.

IsWindows8Point1OrGreater and IsWindows10OrGreater depend on the application manifest to specify the application support a particular operating system. Otherwise, they return false even if the application is running on Windows 8.1 or Windows 10. Notice these two functions are available since Windows 2000. You can setup the application manifest as following:

<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0" xmlns:asmv3="urn:schemas-microsoft-com:asm.v3">
   <compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
      <application>
         <!-- Windows 10, Windows Server 2016 and Windows Server 2019 -->
         <supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}"/>
         <!-- Windows 8.1 and Windows Server 2012 R2 -->
         <supportedOS Id="{1f676c76-80e1-4239-95bb-83d0f6d0da78}"/>
         <!-- Windows 8 and Windows Server 2012 -->
         <supportedOS Id="{4a2f28e3-53b9-4441-ba9c-d69d4a4a6e38}"/>
         <!-- Windows 7 and Windows Server 2008 R2 -->
         <supportedOS Id="{35138b9a-5d96-4fbd-8e2d-a2440225f93a}"/>
         <!-- Windows Vista and Windows Server 2008 -->
         <supportedOS Id="{e2011457-1546-43c5-a5fe-008deee3d3f0}"/>
      </application>
   </compatibility>
</assembly>

Remember both GetDpiForWindow and GetDpiForMonitor depend on the DPI awareness set in the application manifest or programmatically, as described above.

AdjustWindowRect, AdjustWindowRectEx, CWnd::CalcWindowRect

The AdjustWindowRect and its sister API, AdjustWindowRectEx, calculate the required size of the window rectangle based on the desired size of the client rectangle. Similarly, the MFC counter-part, CWnd::CalcWindowRect, does the same, by calling AdjustWindowRectEx. However, these Windows APIs are not DPI aware and should be replaced with AdjustWindowsRectExForDPI. This function was introduced in Windows 10 1607.

The MFC implementation of CalcWindowRect is the following:

void CWnd::CalcWindowRect(LPRECT lpClientRect, UINT nAdjustType)
{
	DWORD dwExStyle = GetExStyle();
	if (nAdjustType == 0)
		dwExStyle &= ~WS_EX_CLIENTEDGE;
	::AdjustWindowRectEx(lpClientRect, GetStyle(), FALSE, dwExStyle);
}

This should be replaced with the following implementation:

using AdjustWindowRectExForDpi_fn = BOOL(WINAPI *)(LPRECT, DWORD, BOOL, DWORD, UINT);

BOOL CalcWindowRectForDpi(
   LPRECT lpRect,
   DWORD  dwStyle,
   BOOL   bMenu,
   DWORD  dwExStyle,
   UINT   dpi,
   UINT   nAdjustType = CWnd::adjustBorder)
{
   if (nAdjustType == 0)
      dwExStyle &= ~WS_EX_CLIENTEDGE;

   HMODULE hModule = ::LoadLibrary(_T("User32.dll")); // don't call FreeLibrary() with this handle; the module was already loaded up, it would break the app
   if (hModule != nullptr)
   {
      AdjustWindowRectExForDpi_fn addr = (AdjustWindowRectExForDpi_fn)::GetProcAddress(hModule, _T("AdjustWindowRectExForDpi"));
      if (addr != nullptr)
      {
         return addr(lpRect, dwStyle, bMenu, dwExStyle, dpi);
      }
   }
   return ::AdjustWindowRectEx(lpRect, dwStyle, bMenu, dwExStyle);
}

For calling this function, you need to the pass the DPI, after priory retrieving it as explained earlier.

Therefore, you should do the following replacements in your code:

FunctionReplacement
AdjustWindowRectAdjustWindowRectExForDpi
AdjustWindowRectExAdjustWindowRectExForDpi
CWnd::CalcWindowRectCalcWindowRectForDpi

CDC pixel conversion functions

The MFC’s CDC class contains several functions that perform conversions:

FunctionDescription
DPtoHIMETRICConverts device units into HIMETRIC units.
HIMETRICtoDPConverts HIMETRIC units into device units.
LPtoHIMETRICConverts logical units into HIMETRIC units.
HIMETRICtoLPConverts HIMETRIC units into logical units.

These functions need the DPI to perform the conversion, but depend on the GetDeviceCaps function. Here is their implementation:

#define HIMETRIC_INCH   2540    // HIMETRIC units per inch

void CDC::DPtoHIMETRIC(LPSIZE lpSize) const
{
	ASSERT(AfxIsValidAddress(lpSize, sizeof(SIZE)));

	int nMapMode;
	if (this != NULL && (nMapMode = GetMapMode()) < MM_ISOTROPIC &&
		nMapMode != MM_TEXT)
	{
		// when using a constrained map mode, map against physical inch
		((CDC*)this)->SetMapMode(MM_HIMETRIC);
		DPtoLP(lpSize);
		((CDC*)this)->SetMapMode(nMapMode);
	}
	else
	{
		// map against logical inch for non-constrained mapping modes
		int cxPerInch, cyPerInch;
		if (this != NULL)
		{
			ASSERT_VALID(this);
			ASSERT(m_hDC != NULL);  // no HDC attached or created?
			cxPerInch = GetDeviceCaps(LOGPIXELSX);
			cyPerInch = GetDeviceCaps(LOGPIXELSY);
		}
		else
		{
			cxPerInch = afxData.cxPixelsPerInch;
			cyPerInch = afxData.cyPixelsPerInch;
		}
		ASSERT(cxPerInch != 0 && cyPerInch != 0);
		lpSize->cx = MulDiv(lpSize->cx, HIMETRIC_INCH, cxPerInch);
		lpSize->cy = MulDiv(lpSize->cy, HIMETRIC_INCH, cyPerInch);
	}
}

void CDC::HIMETRICtoDP(LPSIZE lpSize) const
{
	ASSERT(AfxIsValidAddress(lpSize, sizeof(SIZE)));

	int nMapMode;
	if (this != NULL && (nMapMode = GetMapMode()) < MM_ISOTROPIC &&
		nMapMode != MM_TEXT)
	{
		// when using a constrained map mode, map against physical inch
		((CDC*)this)->SetMapMode(MM_HIMETRIC);
		LPtoDP(lpSize);
		((CDC*)this)->SetMapMode(nMapMode);
	}
	else
	{
		// map against logical inch for non-constrained mapping modes
		int cxPerInch, cyPerInch;
		if (this != NULL)
		{
			ASSERT_VALID(this);
			ASSERT(m_hDC != NULL);  // no HDC attached or created?
			cxPerInch = GetDeviceCaps(LOGPIXELSX);
			cyPerInch = GetDeviceCaps(LOGPIXELSY);
		}
		else
		{
			cxPerInch = afxData.cxPixelsPerInch;
			cyPerInch = afxData.cyPixelsPerInch;
		}
		ASSERT(cxPerInch != 0 && cyPerInch != 0);
		lpSize->cx = MulDiv(lpSize->cx, cxPerInch, HIMETRIC_INCH);
		lpSize->cy = MulDiv(lpSize->cy, cyPerInch, HIMETRIC_INCH);
	}
}

void CDC::LPtoHIMETRIC(LPSIZE lpSize) const
{
	ASSERT(AfxIsValidAddress(lpSize, sizeof(SIZE)));

	LPtoDP(lpSize);
	DPtoHIMETRIC(lpSize);
}

void CDC::HIMETRICtoLP(LPSIZE lpSize) const
{
	ASSERT(AfxIsValidAddress(lpSize, sizeof(SIZE)));

	HIMETRICtoDP(lpSize);
	DPtoLP(lpSize);
}

These functions can be rewritten as following, to be DPI aware. However, the DPI is actually provided as an argument, just as in the case of CalcWindowRectForDpi above.

constexpr int HIMETRIC_INCH = 2540;    // HIMETRIC units per inch

void Win32Utils::DPtoHIMETRIC(HDC hDC, LPSIZE lpSize, int const iDpi)
{
   ASSERT(AfxIsValidAddress(lpSize, sizeof(SIZE)));

   int nMapMode;
   if ((nMapMode = ::GetMapMode(hDC)) < MM_ISOTROPIC && nMapMode != MM_TEXT)
   {
      // when using a constrained map mode, map against physical inch
      ::SetMapMode(hDC, MM_HIMETRIC);
      ::DPtoLP(hDC, reinterpret_cast<LPPOINT>(lpSize), 1);
      ::SetMapMode(hDC, nMapMode);
   }
   else
   {
      lpSize->cx = MulDiv(lpSize->cx, HIMETRIC_INCH, iDpi);
      lpSize->cy = MulDiv(lpSize->cy, HIMETRIC_INCH, iDpi);
   }
}

void Win32Utils::HIMETRICtoDP(HDC hDC, LPSIZE lpSize, int const iDpi)
{
   ASSERT(AfxIsValidAddress(lpSize, sizeof(SIZE)));

   int nMapMode;
   if ((nMapMode = ::GetMapMode(hDC)) < MM_ISOTROPIC && nMapMode != MM_TEXT)
   {
      // when using a constrained map mode, map against physical inch
      ::SetMapMode(hDC, MM_HIMETRIC);
      ::LPtoDP(hDC, reinterpret_cast<LPPOINT>(lpSize), 1);
      ::SetMapMode(hDC, nMapMode);
   }
   else
   {
      lpSize->cx = MulDiv(lpSize->cx, iDpi, HIMETRIC_INCH);
      lpSize->cy = MulDiv(lpSize->cy, iDpi, HIMETRIC_INCH);
   }
}

void Win32Utils::LPtoHIMETRIC(HDC hDC, LPSIZE lpSize, int const iDpi)
{
   ASSERT(AfxIsValidAddress(lpSize, sizeof(SIZE)));

   ::LPtoDP(hDC, reinterpret_cast<LPPOINT>(lpSize), 1);
   DPtoHIMETRIC(hDC, lpSize, iDpi);
}

void Win32Utils::HIMETRICtoLP(HDC hDC, LPSIZE lpSize, int const iDpi)
{
   ASSERT(AfxIsValidAddress(lpSize, sizeof(SIZE)));

   HIMETRICtoDP(hDC, lpSize, iDpi);
   ::DPtoLP(hDC, reinterpret_cast<LPPOINT>(lpSize), 1);
}

More of MFC

If you search for GetDeviceCaps (or for LOGPIXELSY) in the MFC source code, you’ll see there are other places where it’s used. These include CFrameWnd::RecalcLayout and CWnd::RepositionBars. You will have to look out for all these functions and replace them. CFrameWnd::RecalcLayout, for instance, is a virtual method so you can override it. As for CWnd::RepositionBars, you just need to replace it. You could copy the implementation from MFC and replace the parts that deal with DPI.

Working with fonts

The GDI CreateFont API, the MFC’s CFont class (based on the previous) and the GDI+ Font class are not DPI aware. You can specify the height in various units, but the height is not adjusted based on the DPI. This is something you have to do explicitly. Let’s take a look at these functions and classes.

APILibraryDescription
CreateFontGDICreates a logical font with the specified characteristics. The height is given in logical units and indicates either the height of the character cell (if it’s a positive number) or the height of the character (if it’s a negative number).
LOGFONTA / LOGFONTWGDIA structure that defines the attributes of a font. The height has the the same meaning as above.
CFont::CreateFontMFCA wrapper of the GDI’s CreateFont function.
CFont::CreateFontIndirectMFCSimilar to CreateFont but takes a pointer to LOGFONT structure to describe the attributes of the font to be created.
Font::FontGDI+A set of overloaded constructors. Some take a pointer to a LOGFONT structure. Others take multiple arguments including the height in a specified unit. The default unit is the point (1/72 of an inch), but various other units are available.

This is what the documentation is saying about the height of the GDI fonts:

For the MM_TEXT mapping mode, you can use the following formula to specify a height for a font with a specified point size:

nHeight = -MulDiv(PointSize, GetDeviceCaps(hDC, LOGPIXELSY), 72);

Therefore, we often see code that looks like this:

int pointSize = 12;
int height = -MulDiv(pointSize, ::GetDeviceCaps(hDC, LOGPIXELSY), 72);

HFONT hFont = CreateFont(
   height,                   // nHeight
   0,                        // nWidth
   0,                        // nEscapement
   0,                        // nOrientation
   FW_DONTCARE,              // nWeight
   FALSE,                    // bItalic
   FALSE,                    // bUnderline
   FALSE,                    // cStrikeOut
   ANSI_CHARSET,             // nCharSet
   OUT_DEFAULT_PRECIS,       // nOutPrecision
   CLIP_DEFAULT_PRECIS,      // nClipPrecision
   DEFAULT_QUALITY,          // nQuality
   DEFAULT_PITCH | FF_SWISS, // nPitchAndFamily
   "Tahoma")

The part that needs to be changed here is the computation of the height. GetDeviceCaps needs to be replaced with one of the functions that can return the proper DPI of the window or monitor.

int pointSize = 12;
int height = -MulDiv(pointSize, ::GetDpiForWindow(hWnd), 72);

If you’re working with GDI+ and creating fonts by specifying a unit points you need to consider that the library is using the system DPI, which is the DPI of the primary monitor or just 96. Therefore, you need to adjust your font size with a factor that is the ratio between the DPI of the monitor on which the window that uses the font is displayed and the DPI of the primary monitor.

Therefore, if you have code that looks like this:

Gdiplus::Font font(L"Tahoma", 12, Gdiplus::FontStyleRegular);

You need to modify it as follows:

int primaryMonitorDpi = ::GetDpiForWindow(::GetDesktopWindow()); // or GetDeviceCaps(), or GetDpiForMonitor()
int currentMonitorDpi = ::GetDpiForWindow(hwnd);
Gdiplus::REAL emSize = 12.0 * currentMonitorDpi / primaryMonitorDpi;
Gdiplus::Font font(L"Tahoma", emSize, Gdiplus::FontStyleRegular);

Reacting to DPI changes

If you want your app to change on the fly to changes in DPI then you need to handle some windows messages and trigger the appropriate updates in your code. There are several messages related to DPI changes:

MessageDescription
WM_DPICHANGEDReceived by top-level windows when the effective DPI has changed. This message is only relevant for per-monitor DPI aware applications or threads. This was introduced in Windows 8.1.
WM_DPICHANGED_BEFOREPARENTFor top-level windows that are per-monitor v2 DPI aware, this message is sent (from bottom-up) to all the windows in the child HWND tree of the window that is undergoing the DPI change. This is sent before the top-level window receives the WM_DPICHANGED message.
WM_DPICHANGED_AFTERPARENTFor top-level windows that are per-monitor v2 DPI aware, this message is sent (from top-down) to all the windows in the child HWND tree of the window that is undergoing the DPI change. This is sent after the top-level window receives the WM_DPICHANGED message.

Here I show you an example of a dialog application reacting to DPI changes and resizing and repositioning all the controls on the dialog. This is how the dialog resource looks like:

First, you need to register the handler for the WM_DPICHANGED message.

BEGIN_MESSAGE_MAP(CDemoDlg, CDialogEx)
   // ...
   ON_MESSAGE(WM_DPICHANGED, OnDpiChanged)
END_MESSAGE_MAP()

The implementation of this handler should do the following:

  • resize and reposition the dialog (the top-level window that received the message); notice that the new window rectangle is received with the LPARAM argument.
  • enumerate all child windows and execute a callback that resizes and repositions each child window.

To do the latter step above, you have to:

  • determine the relative position of the child window to the parent window; this is needed to adjust the top-left corner of the child window based on the new DPI
  • know both the previous value of the DPI and the new DPI so that the position (left and top) and the size (width and height) can be adjusted accordingly (if you go from 100% to 125% the sizes must increase, but from 125% to 100% they have to decrease).

All these can be implemented as follows:

LRESULT CDemoDlg::OnDpiChanged(WPARAM wParam, LPARAM lParam)
{
   if (m_dpi != 0)
   {
      RECT* const prcNewWindow = reinterpret_cast<RECT*>(lParam);
      ::SetWindowPos(
         m_hWnd,
         nullptr,
         prcNewWindow->left,
         prcNewWindow->top,
         prcNewWindow->right - prcNewWindow->left,
         prcNewWindow->bottom - prcNewWindow->top,
         SWP_NOZORDER | SWP_NOACTIVATE);

      ::EnumChildWindows(
         m_hWnd, 
         [](HWND hWnd, LPARAM lParam)
         {
            int const dpi = ::GetDpiForWindow(hWnd);
            int const previousDpi = static_cast<int>(lParam);

            CRect rc;
            ::GetWindowRect(hWnd, rc);             // child window rect in screen coordinates

            HWND parentWnd = ::GetParent(hWnd);
            CRect rcParent;
            ::GetWindowRect(parentWnd, rcParent);  // parent window rect in screen coordinates

            POINT ptPos = { rc.left, rc.top };
            ::ScreenToClient(parentWnd, &ptPos);   // transforming the child window pos
                                                   // from screen space to parent window space
            int dpiScaledX = ::MulDiv(ptPos.x, dpi, previousDpi);
            int dpiScaledY = ::MulDiv(ptPos.y, dpi, previousDpi);
            int dpiScaledWidth = ::MulDiv(rc.Width(), dpi, previousDpi);
            int dpiScaledHeight = ::MulDiv(rc.Height(), dpi, previousDpi);

            ::SetWindowPos(
               hWnd,
               nullptr,
               dpiScaledX,
               dpiScaledY,
               dpiScaledWidth,
               dpiScaledHeight,
               SWP_NOZORDER | SWP_NOACTIVATE);

            return TRUE;
         },
         m_dpi);
   }

   m_dpi = HIWORD(wParam);

   return 0;
}

Notice that m_dpi is a class member, initialized with 0, that stores the value of the current DPI of the window.

The result is that if you move the window from one screen to another, with different DPIs, the application automatically adjust accordingly to the new DPI. Here are several screenshots from displays with 100%, 125%, and 150% scaling.

100% scaling
125% scaling
150% scaling

Conclusion

Making a native Windows application to be per-monitor DPI aware requires a certain amount of extra work. It is also going to work only for Windows 10 but at this point you shouldn’t be supporting any previous operating systems. To accomplish this task you need to loop up all those APIs (mentioned in this article) that are related to the DPI (return the DPI or fetch the DPI for various calculations) and replace them with other functions (either system ones or user-defined ones) that handle the DPI in a proper way.

2 Replies to “How to build high DPI aware native Windows desktop applications”

  1. Probably this is a piece of a good article but the title should be “How to build high DPI aware native Windows applications”. Most people now uses multiplatform solutions where they can.

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.