diff --git a/src/MaterialDesignThemes.Wpf/DialogHost.cs b/src/MaterialDesignThemes.Wpf/DialogHost.cs index 1fb5688e3a..4cbb9db755 100644 --- a/src/MaterialDesignThemes.Wpf/DialogHost.cs +++ b/src/MaterialDesignThemes.Wpf/DialogHost.cs @@ -64,6 +64,8 @@ public class DialogHost : ContentControl private DialogClosingEventHandler? _attachedDialogClosingEventHandler; private DialogClosedEventHandler? _attachedDialogClosedEventHandler; private IInputElement? _restoreFocusDialogClose; + private IInputElement? _lastFocusedDialogElement; + private HwndSourceHook? _hook; private Action? _currentSnackbarMessageQueueUnPauseAction; static DialogHost() @@ -370,6 +372,7 @@ private static void IsOpenPropertyChangedCallback(DependencyObject dependencyObj dialogHost.CurrentSession = new DialogSession(dialogHost); var window = Window.GetWindow(dialogHost); + dialogHost.ListenForWindowStateChanged(window); if (!dialogHost.IsRestoreFocusDisabled) { dialogHost._restoreFocusDialogClose = window != null ? FocusManager.GetFocusedElement(window) : null; @@ -395,7 +398,8 @@ private static void IsOpenPropertyChangedCallback(DependencyObject dependencyObj //https://github.com/MaterialDesignInXAML/MaterialDesignInXamlToolkit/issues/187 //totally not happy about this, but on immediate validation we can get some weird looking stuff...give WPF a kick to refresh... - Task.Delay(300).ContinueWith(t => dialogHost.Dispatcher.BeginInvoke(DispatcherPriority.Background, new Action(() => { + Task.Delay(300).ContinueWith(t => dialogHost.Dispatcher.BeginInvoke(DispatcherPriority.Background, new Action(() => + { CommandManager.InvalidateRequerySuggested(); //Delay focusing the popup until after the animation has some time, Issue #2912 UIElement? child = dialogHost.FocusPopup(); @@ -405,6 +409,56 @@ private static void IsOpenPropertyChangedCallback(DependencyObject dependencyObj }))); } + + private void ListenForWindowStateChanged(Window? window) + { + if (window is null) + { + return; + } + + if (PresentationSource.FromVisual(window) is HwndSource source) + { + _hook = Hook; + source.RemoveHook(_hook); + source.AddHook(_hook); + } + + IntPtr Hook(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled) + { + //https://learn.microsoft.com/en-us/windows/win32/menurc/wm-syscommand + const int WM_SYSCOMMAND = 0x0112; + const int SC_MINIMIZE = 0xf020; + const int SC_RESTORE = 0xF120; + + long wParamLong = wParam.ToInt64(); + switch (msg) + { + case WM_SYSCOMMAND: + if (wParamLong == SC_MINIMIZE && //Minimize + _popupContentControl?.IsKeyboardFocusWithin == true) //Only persistent the one with keyboard focus + { + var element = Keyboard.FocusedElement; + _lastFocusedDialogElement = element; + } + else if (wParamLong == SC_RESTORE) //Restore + { + // Kinda hacky, but without a delay the focus doesn't always get set correctly because the Focus() method fires too early + Task.Delay(50).ContinueWith(_ => this.Dispatcher.BeginInvoke(DispatcherPriority.Background, new Action(() => + { + if (_lastFocusedDialogElement is UIElement { Focusable: true, IsVisible: true }) + { + _lastFocusedDialogElement.Focus(); + _lastFocusedDialogElement = null; + } + }))); + } + break; + } + return IntPtr.Zero; + } + } + /// /// Returns a DialogSession for the currently open dialog for managing it programmatically. If no dialog is open, CurrentSession will return null /// diff --git a/tests/MaterialDesignThemes.UITests/Samples/DialogHost/WithMultipleTextBoxes.xaml b/tests/MaterialDesignThemes.UITests/Samples/DialogHost/WithMultipleTextBoxes.xaml new file mode 100644 index 0000000000..53ec2a6f53 --- /dev/null +++ b/tests/MaterialDesignThemes.UITests/Samples/DialogHost/WithMultipleTextBoxes.xaml @@ -0,0 +1,28 @@ + + + + + + + + + + + diff --git a/tests/MaterialDesignThemes.UITests/Samples/DialogHost/WithMultipleTextBoxes.xaml.cs b/tests/MaterialDesignThemes.UITests/Samples/DialogHost/WithMultipleTextBoxes.xaml.cs new file mode 100644 index 0000000000..0911fa7f73 --- /dev/null +++ b/tests/MaterialDesignThemes.UITests/Samples/DialogHost/WithMultipleTextBoxes.xaml.cs @@ -0,0 +1,16 @@ +namespace MaterialDesignThemes.UITests.Samples.DialogHost; + +/// +/// Interaction logic for WithMultipleTextBoxes.xaml +/// +public partial class WithMultipleTextBoxes : UserControl +{ + public WithMultipleTextBoxes() + { + InitializeComponent(); + } + private void DialogHost_Loaded(object sender, RoutedEventArgs e) + { + SampleDialogHost.IsOpen = true; + } +} diff --git a/tests/MaterialDesignThemes.UITests/WPF/DialogHosts/DialogHostTests.cs b/tests/MaterialDesignThemes.UITests/WPF/DialogHosts/DialogHostTests.cs index 088c82ad1a..a19f7bb536 100644 --- a/tests/MaterialDesignThemes.UITests/WPF/DialogHosts/DialogHostTests.cs +++ b/tests/MaterialDesignThemes.UITests/WPF/DialogHosts/DialogHostTests.cs @@ -314,7 +314,6 @@ public async Task CornerRadius_AppliedToContentCoverBorder_WhenSetOnEmbeddedDial await Wait.For(async () => { var contentCoverBorder = await dialogHost.GetElement("ContentCoverBorder"); - await Assert.That((await contentCoverBorder.GetCornerRadius()).TopLeft).IsEqualTo(1); await Assert.That((await contentCoverBorder.GetCornerRadius()).TopRight).IsEqualTo(2); await Assert.That((await contentCoverBorder.GetCornerRadius()).BottomRight).IsEqualTo(3); @@ -500,7 +499,6 @@ public async Task DialogHost_WithComboBox_CanSelectItem() var comboBox = await dialogHost.GetElement("TargetedPlatformComboBox"); await Task.Delay(500, TestContext.Current!.CancellationToken); await comboBox.LeftClick(); - var item = await Wait.For(() => comboBox.GetElement("TargetItem")); await Task.Delay(TimeSpan.FromSeconds(1)); await item.LeftClick(); @@ -514,4 +512,55 @@ await Wait.For(async () => recorder.Success(); } + + [Test] + [Description("Issue 3434")] + [Arguments(WindowState.Minimized, WindowState.Maximized, null)] + [Arguments(WindowState.Minimized, WindowState.Normal, null)] + [Arguments(WindowState.Maximized, WindowState.Normal, null)] + [Arguments(WindowState.Minimized, WindowState.Maximized, "MaterialDesignEmbeddedDialogHost")] + [Arguments(WindowState.Minimized, WindowState.Normal, "MaterialDesignEmbeddedDialogHost")] + [Arguments(WindowState.Maximized, WindowState.Normal, "MaterialDesignEmbeddedDialogHost")] + public async Task DialogHost_WhenWindowStateChanges_FocusedElementStaysFocused(WindowState firstWindowState, + WindowState secondWindowState, + string? styleName) + { + await using var recorder = new TestRecorder(App); + + var dialogHost = (await LoadUserControl()).As(); + if (styleName is not null) + { + await dialogHost.RemoteExecute(SetDialogHostStyle, styleName); + } + await Task.Delay(400, TestContext.Current!.CancellationToken); + + // Select the second TextBox + var tbTwo = await dialogHost.GetElement("TextBoxTwo"); + await tbTwo.MoveKeyboardFocus(); + await Assert.That(await tbTwo.GetIsFocused()).IsTrue(); + + // First state + await dialogHost.RemoteExecute(SetStateOfParentWindow, firstWindowState); + await Task.Delay(400, TestContext.Current!.CancellationToken); + // Second state + await dialogHost.RemoteExecute(SetStateOfParentWindow, secondWindowState); + await Task.Delay(400, TestContext.Current!.CancellationToken); + + // After changing state of the window the previously focused element should be focused again + await Assert.That(await tbTwo.GetIsFocused()).IsTrue(); + recorder.Success(); + + static object SetStateOfParentWindow(DialogHost dialogHost, WindowState state) + { + var window = Window.GetWindow(dialogHost); + window.WindowState = state; + return null!; + } + static object SetDialogHostStyle(DialogHost dialogHost, string styleName) + { + Style style = (Style)dialogHost.FindResource(styleName); + dialogHost.Style = style; + return null!; + } + } }