Andrei Smolin

Inner details of DPI scaling in Office COM add-ins

Today, I'm following Microsoft recommendations and using code fragments from docs.microsoft.com to investigate the behavior of a System.Windows.Forms.Form (simply a form).

In an Office add-in, a form is top-level whether you show it modally (via ShowDialog) or not (via Show). Being a top-level window, the form *may* receive DPI notifications when the DPI changes. To receive DPI notifications, you need to:

  • Use SetThreadDpiAwarenessContext (description) to set the right DPI Awareness context before the form window is created. You need to use Per Monitor DPI aware or Per Monitor v2 (see the previous blog).

  • Override the WndProc method of the form to handle the WM_DPICHANGED message (see docs.microsoft.com).

To set the DPI context, the sample add-in uses the DPIContextBlock class that Microsoft provides in their recommendations (see above). Here's how they describe the need to use that context block:

Your solution will interact with its host Office app, so you will have incoming calls to your solution from Office such as event callbacks. When Office calls your solution, it has a context block that forces the thread context to be in System DPI Aware context. You must change the thread context to match the DPI awareness of your window. You can implement a similar context block to switch the thread context on incoming calls. Use the SetThreadDpiAwarenessContext function to change the context to match your window context.

Also, they've posted a note there:

Note. Your context block should restore the original DPI thread context before calling other components outside of your solution code.

Here's the Click event of the button that opens the form in the DPI Awareness mode Per Monitor v2 following the above suggestions.

private void btnWinFormPerMonitorAwareV2WithScaling_OnClick(object sender, IRibbonControl control, bool pressed)
{
    using (DPIContextBlock context =
        new DPIContextBlock(DPIHelper.DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2))
    {
        ShowForm(scaleForm: true);
    }
}

In the WndProc method of the form class, the add-in handles the WM_DPICHANGED message to retrieve the form size and the DPI to be used. Just in case you need this info: the parameters of the WM_DPICHANGED message contain both horizontal and vertical DPI; these DPI values are the same on Windows Desktop.

protected override void WndProc(ref Message m)
{
    switch (m.Msg)
    {
        case Win32Api.WM_DPICHANGED:   
            // https://docs.microsoft.com/en-us/windows/win32/hidpi/wm-dpichanged
            Win32Api.RECT newRect = 
                (Win32Api.RECT)Marshal.PtrToStructure(m.LParam, typeof(Win32Api.RECT));
            // Set the form size and location in one step. Using other bounds (e.g. changing 
            // Size and Location separately) creates a loop of WM_DPIChanged messages
            this.SetBounds(
                newRect.Left, 
                newRect.Top, 
                newRect.Right - newRect.Left, 
                newRect.Bottom - newRect.Top);
            int newDpi = (int)(m.WParam.ToInt64() & 0x7FFF);
            this.DPI = newDpi;
            break;
        default:
            base.WndProc(ref m);
            break;
    }
}

When a new value is assigned to this.DPI property in the code above, the sample add-in invokes this method (on a condition; see code):

internal static void Scale(Control control, float dpiFactor)
{
    // Scale controls
    DpiScaleHelper.ScaleAllChildControls(control, dpiFactor);
    // Scale the root control's font
    DpiScaleHelper.ScaleFont(control, dpiFactor);
    // Recursively scale the font of controls with different fonts
    DpiScaleHelper.ScaleFontRecursively(control, dpiFactor);
}

The ScaleAllChildControls method – I've copied it from the Microsoft's page – starts with a promising comment line:

// Additional changes may be needed for controls that set Anchor or Dock properties

Very true! Soon you'll find that more properties are involved requiring more additional changes.

Let's test the form. Here it is after I’ve moved it from my display A (96 DPI) to display B (192 DPI); the image is clickable:

Form handling WM_DPICHANGED on a 200% (192 DPI) monitor

All fonts are good. What's wrong?

  • The picture box shows the image not filling the control's bounds. That means the ScaleAllChildControls method should also update all picture boxes to redraw their images correctly.

  • The combo box, check box and tree view display unproportionally small icons. These are supplied by the Common Controls library; Microsoft describes it here. Because you can't control their appearance, your options are 1) switch Display Options to Optimize for compatibility, or 2) replace them with some other controls.

It becomes clear: there's no generic solution and you'll need to program for the controls that *you* use in the way you use.

I'll continue my tests. Below is the result (clickable) of my moving the form back to display A, and then to display B again:

Form handling WM_DPICHANGED is shown on a 200% (192 DPI) monitor after being moved to 100% display and then back to 200% display

Phew! The controls get moved to the right so that the horizontal scrollbar gets shown! Why? There's no code moving the controls to the right!

Another test. I open the form on display A, click a tree node (this focuses the node *and* scrolls the form content), then move the form to display B, back to display A, and to display B again:

Form handling WM_DPICHANGED is shown on a 200% (192 DPI) monitor after I click the tree node, move the form to 200% display, then back to 100% display and once again to 200% display

This case reminds of many, many complex things that the form is supplied with. This specific issue is caused by the AutoScroll property set to True. To bypass it, you should set the property to False, store the positions of , hide the scroll bars, perform the scaling, and restore the scroll bars' visibility and positions; finally, you restore the AutoScroll property. Find that code commented out in UserControl1.FixDpi().

This becomes quite complex: I suppose quite many developers may not be able to find this fix.

My understanding is: the complexity of features behind a simple form requires really advanced understanding of System.Windows.Forms.Form inner details. Remember that Add-in Express panes descend from System.Windows.Forms.Form, too.

Anyway, you cannot spend so much time making a checkbox look correctly: this isn't what you are paid for. I would check how third-party controls behave, but I don't know if there are controls supporting DPI scaling.

So, I research using WPF.

Using WPF

WPF has a VERY different ideology. On multiple occasions I ran into introductory-level issues.

Say, I've found there's no concept of anchoring in WPF. Docking in WPF is different from docking in Windows.Forms; don't know English enough to express “not the same” in the greatest possible degree. Turned out creating an editable textbox isn't a trivial task if you only used Windows.Forms. And so on. A different ideology.

At certain stage I was baffled: the WPF controls on my UserControl suddenly appeared oversized. An hour to find a simple way out (I failed), another hour to construct a correct google query and to get the simplest possible result: the WPF designer allows zooming the view. My advice: before you start using WPF, make sure you read about the architecture and tools; following [video] tutorials step by step would be of help as well.

The sample project contains my attempt to use WPF to reproduce the UI demonstrated in the first blog. I created a WPF UserControl and put it on an ElementHost control located on an Add-in Express pane.

Excel window on a 200% (192 DPI) monitor showing Windows Forms UI on CTP and WPF UI on Add-in Express pane

For the scaling of the WPF UserControl to occur, I had to apply ScaleTransform using this method:

void ApplyScaleTransform(int dpi)
{
    var source = (HwndSource)PresentationSource.FromVisual(this);
    if (source == null) return;
    var wpfDpi = 96 * source.CompositionTarget.TransformToDevice.M11;
    var scaleFactor = dpi / wpfDpi;
    var scaleTransform = 
        Math.Abs(scaleFactor - 1.0) < 0.001 ? null : new ScaleTransform(scaleFactor, scaleFactor);
    this.SetValue(FrameworkElement.LayoutTransformProperty, scaleTransform);
}

Check UserControl2.xaml.cs to find out how that method is invoked.

Summary

Windows provides a limited support for DPI scaling of Windows Forms in Office add-ins. The easiest solution with forms is - just do nothing about them. This produces a bit imperfect display if the DPI changes.

If you want a perfect form, you will need to research how inner details of Windows Forms influence *your* form; there may be no solution on this route as demonstrated in the blog. Some third-party controls MAY support DPI scaling; we haven't looked for such controls.

In case of Custom Task Panes and Add-in Express panes an equivalent of "doing nothing" is to switch the host application's Display Settings to Optimize for compatibility; this causes the whole application to be bitmap-stretched and your pane doesn't create the spotlight effect.

To get a perfect display, WPF is an alternative that works (see the sample project).

Available downloads:

The sample COM Add-in was developed using Add-in Express for Office and .net:

Sample COM Add-in project (C# and VB.NET)

Post a comment

Have any questions? Ask us right now!