Implementing per-monitor DPI awareness

Windows 8.1 will soon offer the ability to use different DPI scaling settings for different monitors. This is a cool feature, but one that will need some fixup in existing programs, particularly since the virtualization method that scales up existing programs looks rather ugly. Below is a dump of everything I've encountered while implementing per-monitor DPI awareness in an existing program.

Fair warning: this is based on the publicly available Windows 8.1 Preview (build 9431), so behaviors may change in the final (RTM) release.

Testing per-monitor DPI

Obviously, it's inconvenient to set up a new physical system with multiple monitors in order to test. A virtual machine is more convenient for this, and fortunately, Oracle VirtualBox works well. You need a recent version in order to boot Windows 8.1 Preview and an even newer version for the Additions to load so that multi-monitor support works. As of this writing, the latest version is 4.2.16 and it successfully boots 8.1 Preview on the VirtualBox WDDM driver.

Detecting 8.1 Preview

I learned the hard way a while back that it's not a good idea to try to load system DLLs without checking the OS version first. I know, Microsoft's recommended strategy is to load the system DLL in question and then doing GetProcAddress() to check for functionality instead of checking OS version, but that has two ugly problems. The first is that unless care is taken, the LoadLibrary() call can pull a different but same-named DLL from the current directory on downlevel OSes that don't have the system DLL. The second is that there have been problems with people somehow getting DLLs from newer versions of Windows into the PATH and even system32 directories of older systems, particularly DWMAPI.DLL on XP, resulting in bizarre-looking DLL load error dialogs. For this reason, I now do an OS version check first before attempting a LoadLibrary() from the system directory.

GetVersionEx() works as usual to check the OS version, with the minor problem that it lies by default in Windows 8.1 Preview and reports the same version as Windows 8: 6.2. To fix this, a Windows 8.1 compatible entry must be added to the application manifest. This isn't always possible, particularly if you're in a plug-in DLL or otherwise don't have access to the manifest. It's unclear if there is an official way to bypass the version lie, but the new version helpers use VerifyVersionInfo() under the hood rather than GetVersionEx().

Adapting to per-monitor DPI

In order to bypass virtualization and allow your program to directly handle the different DPI settings on each monitor, you need to tell Windows that you're DPI-aware. This is documented in Writing DPI-Aware Desktop Applications in Windows 8.1 Preview. The short version is that you need to put a declaration in the application manifest, similarly to the way DPI awareness declaration works in Windows Vista. In case it's not clear from the doc, the string True/PM is literally what you put inside the <dpiAware> element.

It's also possible to call an API to declare DPI-awareness, but I wouldn't recommend it as it needs to be called before virtualization starts, and a surprising amount of UI code can execute before WinMain() starts due to DLL dependencies.

Once the declaration is in place, it's shockingly easy to get basic support working: simply handle the WM_DPICHANGED message to resize and redraw the window. No need to track a resolution independent size, watch for monitor crossing, implement hysteresis, or compute an appropriate new size  the window manager handles all that for you. There's not even a need to do a version check or to call GetProcAddress().

Of course, it can get a little bit more complicated than that....

The global DPI setting

Enabling per-monitor DPI in Control Panel allows different DPI settings for each monitor, but despite having that enabled, there is still a global DPI setting. It's the one that's returned by GetDeviceCaps(LOGPIXELSY) and it determines the size of theme elements like window caption bars and menu text. Windows on monitors that have different DPIs than the global DPI are appropriately scaled up or down to compensate, by either image scaling when virtualization is active or by the program itself when it isn't.

There are a few nasty issues with the global DPI setting: (a) it doesn't have to match the DPI settings of any of the monitors, (b) it can be lower or greater than a monitor's DPI, and (c) it can be different each time you log in. These arise because the global DPI is selected based on the monitor resolutions when you log in or enable the per-monitor DPI mode and doesn't change when the resolution of the monitors are changed. This can lead to the situation where lowering the resolution of one monitor results in window downscaling on that monitor, but after logging out and back in, that monitor draws at 1:1 and the other monitor now has window upscaling!

The upside of this goofy behavior is that it comes in handy during testing, because you can log in with a specific setup of monitor resolutions to set the global API, then change them after the fact to test any combination of upscaling and downscaling.

3D virtualization

When DPI virtualization was introduced in Windows Vista, it affected GDI and DirectDraw rendering, but not the 3D APIs (D3D9, D3D11, OpenGL). This meant that programs that mainly just created a bare-bones window and inited D3D  particularly games  largely weren't affected.

With per-monitor mode in Windows 8.1, this is still the case for programs that do not declare themselves DPI-aware at all. However, if a program declares itself as DPI-aware in Vista style  and not per-monitor aware  then things get a bit funky. 3D output is virtualized and scaled, but unfortunately, IDirect3D9::GetAdapterDisplayMode() is not and still returns the physical display resolution. Annoyingly, this breaks my D3D9 display code when windows are downscaled. This appears to be a bug in 8.1 Preview, but unfortunately I don't have high hopes for it getting fixed for RTM.

As a side note, it looks like the DirectDraw implementation has also been changed in 8.1 Preview so that blits use bilinear filtering, like the majority of hardware drivers did on XP. If so, this is a nice bonus for older programs that are DirectDraw-based.

Non-client metrics

You've been a good programmer and used NONCLIENTMETRICS to adjust the size of text in your program, right? That way, if the user adjusts the size of caption text, palette window text, etc. in Windows, your program also follows suit. Well, no good deed goes unpunished. The issue is that the NONCLIENTMETRICS structure provides font selection information as a raw LOGFONT structure, so if you have a per-monitor DPI aware program and try using those font specifications directly, the text in your program won't scale properly with DPI changes. Doh.

It's easy to scale the font heights in the LOGFONT structures as long as the proper scale factor is available. There is a handy GetScaleFactorForMonitor() function, but it appears to always return 100% for per-monitor DPI aware programs. What does work is GetDpiForMonitor(), which can then be compared against the global DPI value returned by GetDeviceCaps(LOGPIXELSY) on an HDC from GetDC(NULL).

One oddity that I haven't been able to resolve is the scaling factor on the top-level window frame. When virtualization is enabled, the whole window is scaled, so the caption bar and menu bar also scale. Once a program is made per-monitor DPI aware, though, this stops and there doesn't appear to be compensation logic for the window manager to rescale the caption and menu bars. It looks odd to have a huge caption bar when a window is dragged to a scaled down monitor, but I haven't found a fix for this yet.

The font chooser dialog

Okay, this is getting a bit long, but as Columbo would say, there's just one more thing.

Presumably, as a good programmer, you've also been using the OS's ChooseFont() dialog for user font selection. This still works with per-monitor DPI, but with a couple of quirks. First, the choose font dialog doesn't use the current monitor DPI when displaying the font, so the font preview shows a different size than what the user will get. This is caused by it using the global DPI setting from GetDeviceCaps() instead of the per-monitor DPI. No workaround, as far as I can tell.

The second issue is related and is that the height that ChooseFont() puts into the LOGFONT structure is also scaled by global DPI. Using the point size value reported in the CHOOSEFONT structure works around this coming out of the dialog, but doesn't work going in when using the LOGFONT structure to init the dialog. The solution is to use the point size for persistence and to create fonts, but to calculate a different LOGFONT structure using the global DPI just for ChooseFont().

You might think that creating a custom font dialog would be a good workaround. It's an attractive idea, particularly since this would also allow fixes for other issues such as the inability to accept fractional point sizes. Doing so is not a good idea in Windows 7 and later, though, due to ChooseFont() having access to hidden font filtering functionality.