HorizontalOffset goes in the wrong direction for High DPI primary screen

1.4k views Asked by At

I am currently working on some high DPI issues in our WPF app (.NET 4.6.1 - System DPI-awareness is active).

Generally the app does what we expect it to do - scale depending on the current displays DPI setting, also when moving it from screen A @ 100% to screen B @ 150% it changes it's overall scale correctly "at the half-point".

Most of the open issues where there because we had some pixel-/DIP-based calculations which did not took the DPI-setting into consideration. This I fixed by calculating in the correct DPI values:

var source = PresentationSource.FromVisual(this);
var dpiX = source?.CompositionTarget?.TransformToDevice.M11 ?? 1;
var dpiY = source?.CompositionTarget?.TransformToDevice.M22 ?? 1;

There I found out the first strange thing (at least for me):

  1. If the primary display is set to e.g. 125% I get 1.25 for dpiX for all screens, even the secondary screen @ 100%, but there all pixel-values are already multiplied by 1.25 (meaning a 1600x1200 pixel screen has a working size of 2000x1500).
  2. And it is exactly the other way around if the primary screen is at 100% and the secondary screen is at e.g. 150%: I always get 1 for dpiX, but all values are already correct and no correction is necessary (=> or multiply/dived by 1 does not break it).

But now to my actual problem:
I have some pop-ups I am placing at the center of their placement-targets with the following binding:

<Popup.HorizontalOffset>
    <MultiBinding Converter="{lth:CenterConverter}">
        <Binding RelativeSource="{RelativeSource Self}" Path="PlacementTarget.ActualWidth" />
        <Binding RelativeSource="{RelativeSource Self}" Path="Child.ActualWidth" />
        <Binding RelativeSource="{RelativeSource Self}" Path="." />
    </MultiBinding>
</Popup.HorizontalOffset>

and converter:

public class CenterConverter : MarkupExtension, IMultiValueConverter
{
    public override object ProvideValue(IServiceProvider serviceProvider) => this;

    public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture)
    {
        if (values.Any(v => v == DependencyProperty.UnsetValue))
            return Double.NaN;

        double placementTargetWidth = (double)values[0];
        double elementWidth = (double)values[1];

        var offset = (placementTargetWidth - elementWidth) / 2;

        ////if (values.Length >= 3 && values[2] is Visual)
        ////{
        ////    var source = PresentationSource.FromVisual((Visual)values[2]);
        ////    var dpiX = source?.CompositionTarget?.TransformToDevice.M11 ?? 1;
        ////    offset *= -1; //dpiX;
        ////}

        return offset;
    }

    public object[] ConvertBack(object value, Type[] targetTypes, object parameter, CultureInfo culture) { throw new NotSupportedException(); }
}

For case 2 everything already works correctly without the commented out code, but for case 1 I tried dividing and multiplying the DPI value, but in the end the correct thing was to multiply it by -1 to get it to work correctly.

Why ist that the case?
And how can I savely detect when this is needed? dpiX > 1?

I am also open for other solutions to the scaling issue or to the center-placement as a whole.

P.S.: I am running Windows 10 1703 with .NET 4.7 installed (App still targets 4.6.1 for some other reasons).

UPDATE:
I created a demo-solution: https://github.com/chrfin/HorizontalOffsetError
If the main screen is at 100% it is correct:
Correct
but if the main screen is e.g. 125% it is off:
Wrong
BUT if I than add *-1 to the offset it is correct again:
Corrected

...but why?

1

There are 1 answers

7
RJ Thompson On

I've done something similar. I had to go to winforms to accomplish it:

/// <summary>
/// Calculates and sets the correct start location for a dialog to appear.
/// </summary>
/// <param name="form">The dialog to be displayed</param>
/// <param name="screen">Desired screen</param>
public static void SetStartLocation(Form form, Screen screen)
{
           form.StartPosition = FormStartPosition.Manual;
    // Calculate the new top left corner of the form, so it will be centered.
    int newX = (screen.WorkingArea.Width - form.Width) / 2 + screen.WorkingArea.X;
    int newY = (screen.WorkingArea.Height - form.Height) / 2 + screen.WorkingArea.Y;

    form.Location = new Point(newX, newY);
}

Run this code in the debugger then look at your screen inspect all the screen variable and make sure it they make sense, they won't. Your making an assumption.

look at GetScreen()

/// <summary>
    /// Handles drags that go "outside" the screen and returns the mouse delta from the last mouse position.
    /// When the user wants to edit a value greater, but the mouse is at the edge of the screen we want to wrap the mouse position.
    /// 
    /// Wrapping the mouse is non trival do to multiple monitor setups but Forms has a screen class that encapsolates the 
    /// low level calls required to determine the screen the user is working on and it's bounds.
    /// Wrapping is confusing because there are edge cases which mess with the coordinate system. For example, if your primary monitor
    /// is your second monitor which is on the right and the app is on your left screen the mouse 
    /// coordinates will be in the negative and second monitors mouse coords would max at 1920 ( depending on resolution ).
    /// Alternatively if screen 1 is your primary and screen 2 is your secondary then X=3xxx will be your far right max mouse position.
    /// 
    /// When we wrap, we need to take that into account and not have the delta go out of whack.
    /// Note: This mouse wrapping works exactly the same as unity does when the user does a value drag.
    /// Note: When the mouse does a wrap, we musn't set get the position until the next move event, or the set will fail.
    /// </summary>
    /// <param name="delta"> the amount the mouse movement has changed since this was last called</param>
    /// <returns>true if delta was gotten succesfully</returns>
    private bool GetScreenWrappedDragVector(out Vector delta)
    {
        delta = new Vector(); // Always set the out parameter

        // We need to determine what our window is, otherwise the coordinate system will be off if your in a child window on other monitor!
        var element = Mouse.DirectlyOver as UIElement;
        if (element != null)
        {
            Window parentWindow = Window.GetWindow(element);

            if (parentWindow != null)
            {
                System.Windows.Forms.Screen screen = GetScreen(parentWindow);

                var mousePos = Win32.GetCursorPos();

                if ((int)mousePos.X >= screen.WorkingArea.Right - 1)
                {
                    Win32.SetCursorPos(screen.WorkingArea.Left, (int)mousePos.Y);
                    m_lastOnMouseMoveValue.X = screen.WorkingArea.Left;
                    return false;
                }
                if ((int)mousePos.X <= screen.WorkingArea.Left)
                {
                    Win32.SetCursorPos(screen.WorkingArea.Right, (int)mousePos.Y);
                    m_lastOnMouseMoveValue.X = screen.WorkingArea.Right;
                    return false;
                }

                delta = mousePos - m_lastOnMouseMoveValue;
                m_lastOnMouseMoveValue = mousePos;
            }
        }
        if (delta.Length <= 0)
            return false;

        return true;
    }