dotnet / wpf

WPF is a .NET Core UI framework for building Windows desktop applications.
MIT License
7.03k stars 1.16k forks source link

Window.Left and Window.Top are broken when usign PerMonitorV2 #4127

Open todor-dk opened 3 years ago

todor-dk commented 3 years ago

Example

In this example, the user is using Windows 10, the app is PerMonitorV2 enabled and the user has two monitors, as shown below.

Pic4

Scaling Between WPF and Screen Sizes

A window can be freely moved between the available monitors and WPF automatically changes scaling and other parameters. Depending on the window’s current monitor, WPF calculates the scaling factor between Screen and WPF sizes, and vice versa. To my knowledge, this works fine.

Pic1

In the above example, the left window is on monitor 1, which has DPI of 144. This gives a scaling factor of: 144 / 96 = 1,5. The right window is on monitor 2, which has DPI of 192. This gives a scaling factor of: 192 / 96 = 2,0. Knowing the scaling factors, it is easy to calculate between the screen size and WPF sizes. For example, the right window has a width of 500 WPF units. This is 500 x 2.0 = 1000 pixels.

Scaling Between WPF and Screen Positions – BROKEN

Similar to scaling of WPF sizes, we can try to scale between WPF and Screen positions, for example the window’s top-left corner. The current buggy WPF implementation uses the scaling factor from the current monitor (see above) to calculate the top-left corner of the window.

Example for the left window. Left: 375 pixels / 1,5 = 250 WPF units. This is correct. To position the window on the screen, we need to calculate from WPF to Screen units. This is trickier, as we don’t know on which screen 250 WPF units is. This could be on monitor 1 or monitor 2. For now, we assume monitor 1. Screen-left: *250 WPF units 1,5 = 375**.

Example for the right window: Left: 2200 pixel / 2,0 = 1100 WPF units. To calculate the other way, WPF needs to figure out on which monitor 1100 WPF units is. It starts enumerating monitors to try to find one that will work. • Monitor 1: 1100 1,5 = 1650. Monitor has bounds 0..1500. 1650 is outside the bounds. • Monitor 2: 1100 2,0 = 2200. Monitor has bounds 1500..3500. 2200 is inside the bounds. We’ve found our monitor. Therefore, to position the window at 1100 WPF, we calculate: *1100 WPF 2,0 = 2200** pixel.

Now, let’s look at a broken calculation. We again have two windows, but this time positioned slightly different on monitor 1 and 2. Pic2

The first (left) window’s left edge: 1275 pixel / 1,5 = 850 WPF units. The second (right) window’s left edge: 1700 pixel / 2.0 = 850 WPF units.

It is clear that both windows are at different positions on the virtual desktop, but WPF calculates the same position for both windows.

When trying to position the windows programmatically on the screen, things of course go terribly wrong. If the window is already opened, then WPF know the affiliated monitor and will use the scaling factor for that monitor. This will work, unit the window is moved to the other monitor, then the coordinates may jump due to change of scaling factor.

If the window is not opened, then WPF will have to guess on which monitor WPF position 850 is. It iterates the monitors, as explained above. The issue is that the calculations give a position that is valid for both monitors. • Monitor 1: 850 1,5 = 1275. Monitor has bounds 0..1500. 1275 is inside the bounds. • Monitor 2: 850 2,0 = 1700. Monitor has bounds 1500..3500. 1700 is inside the bounds.

The logic WPF uses is to iterate the monitors in the order in which Windows returns them. Once it finds a monitor that contains the requested coordinates, the search terminates. If it is alphabetically, the search for position 850 will end on monitor 1. This means that it is impossible to programmatically open a window at the position of the right window on monitor 2.

Proposed Solution

I see no easy way to fix Window.Left and Window.Top. An unfeasible fix is to divide and map the virtual desktop to individual adjacent areas, each having different scaling factor and WPF bounds. How to handle areas not visible on any monitor is unclear.

I propose to take the simple solution and expose an API to position the window directly using screen coordinates.

Some Additional Challenges

Once a window is opened, positions relative to the window’s top-left corner can be calculated mostly without issue. This means that the relative position of a button compared to the window’s top-left corner can be calculated both ways without issues. Some issues may exist, as Windows scales the part of the window that is not on the monitor associated with the window according to some internal logic. In the example, the part of the window on monitor 1 may be scaled. This must be tested, especially how mouse or similar coordinates are converted to WPF positions relative to the window’s top-left, as we have little experience with this.

Pic3

Related Bugs

https://github.com/dotnet/wpf/issues/3343 https://github.com/dotnet/wpf/issues/3105

daveorourke commented 3 years ago

For what it's worth, we've hit this same issue with Window.Left and Window.Top being ambiguous in a PerMonitorV2 application. I agree with the assessment that an API which allows the use of screen coordinates would be helpful.

Given a destination window rectangle in screen coordinates, and given that the window movement might cause the window to jump to a different screen with a different DPI scaling %, we've had to resort to this hack in the code behind (*.xaml.cs) of the window class. The caller can move the window using window.MoveThisWindow( rect ), where rect is in screen coordinates.

public partial class MyWindow : Window
{
   private IntPtr _hwnd;

   public MyWindow()
   {
      InitializeComponent();
      SourceInitialized += MyWindow_SourceInitialized;
   }

   private void MyWindow_SourceInitialized( object sender, System.EventArgs e )
   {
      _hwnd = new WindowInteropHelper( this ).Handle;
   }

   [DllImport( "user32.dll", SetLastError = true )]
   private static extern bool MoveWindow( IntPtr hWnd, int X, int Y, int nWidth, int nHeight, bool bRepaint );

   public void MoveThisWindow( Rectangle rect )
   {
      // The first move puts it on the correct monitor, which triggers WM_DPICHANGED
      // The +1/-1 coerces WPF to update Window.Top/Left/Width/Height in the second move
      MoveWindow( _hwnd, rect.Left + 1, rect.Top, rect.Width - 1, rect.Height, false );
      MoveWindow( _hwnd, rect.Left, rect.Top, rect.Width, rect.Height, true );
   }
}

Clearly, this is a hack. But it's the best we've come up with so far to workaround this limitation of WPF Window.Left and Window.Top when crossing between screens. It's not perfect.

Pros:

Cons:

If there's a better way, I'd love to hear it.

Perpete commented 2 years ago

Hello, Here is how I move a window by code to work around the positioning issue with PerMonitorV2. During testing, I noticed that I could consider that each monitor saw all the dimensions of the other monitors with its own scale. Let's take an example of positioning a window with the following monitors: Monitor 1 (1920x1080 - 100%) Monitor 2 (1920x1080 - 150%)

For window positioning references, I always use 100% monitor scale values. Let's put the upper left corner of the window in the center of the width of the monitor 2. If my window is initially positioned on monitor 1: The left value to give to the window will be 2879 -> ((1920 +1920/2) / 1) -1

If my window is initially positioned on monitor 2: The left value to give to the window will be 1919 -> ((1920 +1920/2) / 1.5) -1

Here is my test code in VB.

` Class MainWindow Private Sub btMove_Click(sender As Object, e As RoutedEventArgs) Handles btMove.Click

    'Déplace la fenêtre

    'Récupère le facteur d'échelle du moniteur
    Dim ScaleMonitor As DpiScale = VisualTreeHelper.GetDpi(Me)

    Dim CalculLeft As Double = CDbl(txtLeft.Text) / ScaleMonitor.DpiScaleX
    Dim CalculTop As Double = CDbl(txtTop.Text) / ScaleMonitor.DpiScaleY

    'Attibue les coordonnées à la fenêtre
    Left = CalculLeft
    Top = CalculTop

    lbLeftCalcul.Content = CalculLeft
    lbTopCalcul.Content = CalculTop
    lbScale.Content = ScaleMonitor.DpiScaleX

    lbLeftAfterMove.Content = Left
    lbTopAfterMove.Content = Top

End Sub

End Class`

Here is a screen of the window moving from monitor 1 to monitor 2 with always the same initial coordinates at 100% scale. 2021-11-21 10_28_23-

Here is a screen of the window of the movement of monitor 2 on monitor 2 with always the same initial coordinates at 100% scale. 2021-11-21 10_28_42-

Here is my code for test (wpf in vb with net 6). MoveWindows.zip

markoweb2 commented 1 year ago

I am also experiencing this issue and would like it to be fixed. Everything worked fine in .net 4.7 and before. You could specify actual physical screen pixel values for Top and Left, if I remember correctly. (for example if you had two 1920x1080 screens horizontally, then to open your window on the second screen, you could set Left = 1921; the fact that the first screen DPI was 100% and the second screen was 125%, or vice versa, did not matter)

In .net 4.8 the logic was changed. So that before the window is shown, the manually entered value of Left=1921 would be automatically scaled based on the window that the monitor would end up on. Let's say 1921 * 1,25 = 2401. The problem is, that once the window rendering pipeline kicked in, it calculates the value of Left=2401 as not the very first pixel of it's monitor left side, but 2401 happens to be some offset from the corner. Maybe something like 2401 - 1921 = 480px, divide that by 1,25 = 384px. So the window ends up like physicaly 384px offset to the right, from the left most corner of monitor 2.

Thus it is impossible to manually open a window at the top left corner of monitor 2. Only hack I found, was to let the window open with an offset, then in Window_Loaded() method re-run the code to specify the Left/Top/Width/Height again. Now the values you enter, will not be re-scaled and the window can actually position itself to the top most corner as intended.

The fix would be to make sure that the Window initalization logic does not auto scale the manually inserted values for Top/Left/Width/Height. I have already taken scaling into account and can provide the correct values.

Even better would be to stop this scaling nonsense in the first place and allow me to position things perfectly by physical pixels. Take again the same example of two 1920x1080 monitors. Even if one of them has a scaling of 125% and the second one has 400%. I just want to specify TopPx=0 and LeftPx=1921 and let the window open on the second monitors top left corner. (similar logic for WidthPx/HeightPx) This would be so much easier, rather than to start calculating some strange scaled numbers that would be appropriate for Top/Left to end up on the corner exactly.

MichaeIDietrich commented 1 year ago

Just in case someone is looking for a way to work around automatic scaling when positioning a window. Here is how Microsoft devs fixed high DPI issues in NotePad. Generally, an interesting post.

https://blogs.windows.com/windowsdeveloper/2016/10/24/high-dpi-scaling-improvements-for-desktop-applications-and-mixed-mode-dpi-scaling-in-the-windows-10-anniversary-update/

SmikeSix commented 1 year ago

this is quite annoying. im trying to have a window as a tooltip. as soon as its shown on the other window it jumpes between both windows and i lose focus of my the ide. any way to set the absolute position without this happening?

dt200r commented 1 year ago

Please fix this! It makes basic window manipulation impossible.

Edit: The 'hack' above works for me, thank you. Unfortunately it took me a while before I landed on this page.

lindexi commented 1 year ago

@dt200r The problem is more of a design problem. Because it's not a continuous value.

dt200r commented 1 year ago

@dt200r The problem is more of a design problem. Because it's not a continuous value.

Understood, by basic window manipulation I mean programmatically placing a window in the right location in a multi-monitor diverse DPI setup.

daveorourke commented 1 year ago

@dt200r The problem is more of a design problem. Because it's not a continuous value.

@lindexi What about adding an API that uses screen coordinates? Is that something the team would consider?

dt200r commented 1 year ago

Easy APIs to do the following would make (my) life much easier:

1) Enumerate the system monitors and provide their location and width/height in native coordinates (ie independent of the process DPI awareness). It would nice if this could also provide the DPI for each monitor. There is no WPF equivalent of Forms.Screen.AllScreens, and AllScreens gives different results depending on the DPI awareness of the process, so it isn't that useful.

2) Set or get a window position, with the following parameters/returns: Monitor (from above enumeration), Top/Left (relative to Monitor specified) in native coordinates, Width/Height in native coordinates, and whether the window is maximized or minimized. The Top/Left/Width/Height should represent the window in the 'restored' position, regardless of whether the window is maximized or minimized. You should be able to set the Minimized/Maximized state of the window without affecting any other windows in the ownership chain.

3) Don't fire LocationChanged or SizeChanged events if the window is being minimized or maximized, only fire StateChanged. Clearly this implies that Location and Size refer to the 'restored' size of the window, not the actual size. So maybe need new properties/events to provide clear distinction.

As it stands, I have just completed my window management coding for a new project where every top level window is in a dedicated thread and it is an unholy mess of DPI awareness, Windows Forms, WPF, Windows API, WndProc hooks and overrides, event masking, and so on. The worst part is it took a week when it should have taken a day.

rayzorben commented 1 year ago

I am wondering if this is the same issue that I am having, if anybody could take a look I would appreciate it so I don't open up a new issue.

In my NET 7.0 WPF app I am setting window.Left and window.Top on 2 different windows, one is positioned in the working area of Screen 1, and the 2nd in the working area of Screen 2.

In the first call, the windows are placed correctly. In the 2nd call , the window on the secondary screen ignores the Left and puts it on the primary screen. The 3rd AND ALL OTHER CALLS puts it on the secondary screen just fine!

I confirmed that this worked fine in 4.7.2 with no issues.

Here is my stackoverflow link for context https://stackoverflow.com/questions/76024476/different-behavior-for-window-placement-only-on-2nd-showing-of-windows-in-wpf