dotnet / wpf

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

`CenterOwner` with Win32 owner #9018

Open chucker opened 1 month ago

chucker commented 1 month ago

Description

I see that a code path exists for using WindowStartupLocation.CenterOwner with a Win32 owner, namely the else branch after https://source.dot.net/#PresentationFramework/System/Windows/Window.cs,3659.

And indeed, this works, as long as that owner isn't maximized. If it is maximized, the window position isn't what I would expect.

Reproduction Steps

Take a WPF app template.

Enable WinForms interop in the csproj:

<UseWindowsForms>true</UseWindowsForms>

Modify App.xaml to not have a StartupUri:

<Application x:Class="WpfApp1.App"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:local="clr-namespace:WpfApp1">
    <Application.Resources>

    </Application.Resources>
</Application>

Modify MainWindow.xaml to be a little smaller:

<Window x:Class="WpfApp1.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:local="clr-namespace:WpfApp1"
        mc:Ignorable="d"
        Title="MainWindow" Height="600" Width="600">
    <Grid>

    </Grid>
</Window>

Finally, add a constructor to App that creates a WinForms form and uses MainWindow:

using System.Windows;
using System.Windows.Interop;

namespace WpfApp1
{
    /// <summary>
    /// Interaction logic for App.xaml
    /// </summary>
    public partial class App : Application
    {
        public App()
        {
            var form = new System.Windows.Forms.Form
            {
                Width = 1_000,
                Height = 1_000
            };
            form.WindowState = System.Windows.Forms.FormWindowState.Maximized;
            form.Show();

            var window = new MainWindow
            {
                WindowStartupLocation = WindowStartupLocation.CenterOwner
            };

            _ = new WindowInteropHelper(window)
            {
                Owner = form.Handle
            };

            window.ShowDialog();
        }
    }
}

Expected behavior

The WPF window should show up as concentric to the form.

Actual behavior

If we comment out form.WindowState = System.Windows.Forms.FormWindowState.Maximized; and thus the form shows as a regular window, this does work.

But if the form is maximized, the WPF window instead is concentric to where the form would be if it weren't maximized. I don't believe this behavior makes sense.

Regression?

None.

Reproducible in net47, net7.0-windows, net8.0-windows.

Known Workarounds

I would like one, especially one that works with .NET Framework 4.7.2.

I'm guessing I need to call SetupInitialState, perhaps by calling CreateSourceWindow(duringShow: false), then fixing the location, then call Show()?

Impact

We have a legacy primarily WinForms app that we're adding WPF stuff to bit by bit, including by adding WPF-based dialogs — which, preferably, would center correctly.

Configuration

I don't think this is specific to the above configuration.

Other information

It appears CalculateWindowLocation does consider the case of "what if the owning WPF window is maximized", but not "what if the owning Win32 form is maximized".

chucker commented 1 month ago

I've written/amended extension methods to help myself in the meantime. Sharing this for others who run into the same problem. This will work in net472, net7.0-windows and newer.

using System.Runtime.InteropServices;
using System.Windows;
using System.Windows.Interop;

using WinForms = System.Windows.Forms;

namespace EL.Client.WpfUtils.WinFormsInterop
{
    public static class WindowOwnerExtensions
    {
        public static void SetOwner(this Window window, WinForms.Control winFormsControl)
        {
            _ = new WindowInteropHelper(window)
            {
                Owner = winFormsControl.Handle
            };
        }

        /// <summary>
        /// <para>
        /// Set a WPF window's owner to a Win32/WinForms owner, centers to, then
        /// opens it. Do not set <c>WindowStartupLocation</c> separately.
        /// </para>
        ///
        /// <para>
        /// This is necessary because WPF's code
        /// https://referencesource.microsoft.com/#PresentationFramework/src/Framework/System/Windows/Window.cs,1872024dfc3ed928
        /// gets the wrong metrics when a Win32 owner is maximized: it treats
        /// the owner as though it <em>weren't</em> maximized. Thus, with
        /// <see cref="WindowStartupLocation.CenterOwner"/> and a Win32 owner
        /// that's maximized, the center would be wrong.
        /// </para>
        /// </summary>
        /// <param name="window">The WPF window</param>
        /// <param name="winFormsControl">The owning Win32 control</param>
        public static void ShowWithConcentricOwner(this Window window, WinForms.Control winFormsControl)
        {
            SetOwner(window, winFormsControl);

            bool isMaximized = IsWinFormsOwnerMaximized(winFormsControl);

            if (!isMaximized)
                window.WindowStartupLocation = WindowStartupLocation.CenterOwner;
            else
                window.WindowStartupLocation = WindowStartupLocation.CenterScreen;

            window.Show();
        }

        /// <summary>
        /// <para>
        /// Set a WPF window's owner to a Win32/WinForms owner, centers to, then
        /// opens it. Do not set <c>WindowStartupLocation</c> separately.
        /// </para>
        /// 
        /// <para>
        /// Returns only when the window is closed.
        /// </para>
        ///
        /// <para>
        /// This is necessary because WPF's code
        /// https://referencesource.microsoft.com/#PresentationFramework/src/Framework/System/Windows/Window.cs,1872024dfc3ed928
        /// gets the wrong metrics when a Win32 owner is maximized: it treats
        /// the owner as though it <em>weren't</em> maximized. Thus, with
        /// <see cref="WindowStartupLocation.CenterOwner"/> and a Win32 owner
        /// that's maximized, the center would be wrong.
        /// </para>
        /// </summary>
        /// <param name="window">The WPF window</param>
        /// <param name="winFormsControl">The owning Win32 control</param>
        public static bool? ShowDialogWithConcentricOwner(this Window window, WinForms.Control winFormsControl)
        {
            SetOwner(window, winFormsControl);

            bool isMaximized = IsWinFormsOwnerMaximized(winFormsControl);

            if (!isMaximized)
                window.WindowStartupLocation = WindowStartupLocation.CenterOwner;
            else
                window.WindowStartupLocation = WindowStartupLocation.CenterScreen;

            return window.ShowDialog();
        }

        private static bool IsWinFormsOwnerMaximized(WinForms.Control winFormsControl)
        {
            bool isMaximized;

            Windows.Win32.UI.WindowsAndMessaging.WINDOWPLACEMENT windowPlacement = new();
            windowPlacement.length = (uint)Marshal.SizeOf(windowPlacement);
            Windows.Win32.Foundation.HWND windowHandle = (Windows.Win32.Foundation.HWND)winFormsControl.Handle;

            if (!Windows.Win32.PInvoke.GetWindowPlacement(windowHandle, ref windowPlacement))
                isMaximized = false; // fall back to WPF code
            else
                isMaximized = windowPlacement.showCmd == Windows.Win32.UI.WindowsAndMessaging.SHOW_WINDOW_CMD.SW_MAXIMIZE;

            return isMaximized;
        }
    }
}

(GetWindowPlacement and related symbols are source-generated with CsWin32.)