From 8822070aaf89dcd89745c34e3349aa98245f713c Mon Sep 17 00:00:00 2001 From: Corvin Date: Sat, 13 Sep 2025 12:59:41 +0200 Subject: [PATCH 1/6] restore focus to the last focused element inside of the dialog --- src/MaterialDesignThemes.Wpf/DialogHost.cs | 50 +++++++++++++++++++++- 1 file changed, 49 insertions(+), 1 deletion(-) diff --git a/src/MaterialDesignThemes.Wpf/DialogHost.cs b/src/MaterialDesignThemes.Wpf/DialogHost.cs index 1fb5688e3a..a237299f0a 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 WindowState _previousWindowState; 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,50 @@ private static void IsOpenPropertyChangedCallback(DependencyObject dependencyObj }))); } + + private void ListenForWindowStateChanged(Window? window) + { + window ??= Window.GetWindow(this); + + if (window is not null) + { + window.StateChanged += Window_StateChanged; + } + } + + private void Window_StateChanged(object? sender, EventArgs e) + { + if (sender is not Window window) + { + return; + } + + var windowState = window.WindowState; + if (windowState == WindowState.Minimized) + { + _lastFocusedDialogElement = FocusManager.GetFocusedElement(window); + _previousWindowState = windowState; + return; + } + + // We only need to focus anything manually if the window changes state from Minimized --> (Normal or Maximized) + // Going from Normal --> Maximized (and vice versa) is fine since the focus is already kept correctly + if (IsWindowRestoredFromMinimized() && IsLastFocusedDialogElementFocusable()) + { + // 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(() => + { + _lastFocusedDialogElement!.Focus(); + }))); + } + _previousWindowState = windowState; + + bool IsWindowRestoredFromMinimized() => (windowState == WindowState.Normal || windowState == WindowState.Maximized) && + _previousWindowState == WindowState.Minimized; + + bool IsLastFocusedDialogElementFocusable() => _lastFocusedDialogElement is UIElement { Focusable: true, IsVisible: true }; + } + /// /// Returns a DialogSession for the currently open dialog for managing it programmatically. If no dialog is open, CurrentSession will return null /// From 2dc7672a868d07da6d4e72db9b26893997424565 Mon Sep 17 00:00:00 2001 From: Corvin Date: Sat, 13 Sep 2025 13:00:07 +0200 Subject: [PATCH 2/6] add tests --- .../DialogHost/WithMultipleTextBoxes.xaml | 28 +++++++++++++ .../DialogHost/WithMultipleTextBoxes.xaml.cs | 16 ++++++++ .../WPF/DialogHosts/DialogHostTests.cs | 40 ++++++++++++++++++- 3 files changed, 82 insertions(+), 2 deletions(-) create mode 100644 tests/MaterialDesignThemes.UITests/Samples/DialogHost/WithMultipleTextBoxes.xaml create mode 100644 tests/MaterialDesignThemes.UITests/Samples/DialogHost/WithMultipleTextBoxes.xaml.cs 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..719e221654 100644 --- a/tests/MaterialDesignThemes.UITests/WPF/DialogHosts/DialogHostTests.cs +++ b/tests/MaterialDesignThemes.UITests/WPF/DialogHosts/DialogHostTests.cs @@ -314,7 +314,7 @@ 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 +500,7 @@ 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 +514,40 @@ await Wait.For(async () => recorder.Success(); } + + [Test] + [Description("Issue 3434")] + [Arguments(WindowState.Minimized, WindowState.Maximized)] + [Arguments(WindowState.Minimized, WindowState.Normal)] + [Arguments(WindowState.Maximized, WindowState.Normal)] + public async Task DialogHost_WhenWindowStateChanges_FocusedElementStaysFocused(WindowState firstWindowState, WindowState secondWindowState) + { + await using var recorder = new TestRecorder(App); + + var dialogHost = (await LoadUserControl()).As(); + 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!; + } + } } From 31fec8b4d2fa1c8feba107bd3304c555538633d6 Mon Sep 17 00:00:00 2001 From: Corvin Date: Sat, 13 Sep 2025 13:12:42 +0200 Subject: [PATCH 3/6] undo whitespace changes --- .../WPF/DialogHosts/DialogHostTests.cs | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/MaterialDesignThemes.UITests/WPF/DialogHosts/DialogHostTests.cs b/tests/MaterialDesignThemes.UITests/WPF/DialogHosts/DialogHostTests.cs index 719e221654..d2f739071e 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(); From a0c440cbddc4ac95c0745d30cb7dcd306e6fcf13 Mon Sep 17 00:00:00 2001 From: Corvin <43533385+corvinsz@users.noreply.github.com> Date: Tue, 16 Sep 2025 17:07:27 +0200 Subject: [PATCH 4/6] Apply suggestions from code review LGTM, thank you for the review! Co-authored-by: Kevin B --- src/MaterialDesignThemes.Wpf/DialogHost.cs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/MaterialDesignThemes.Wpf/DialogHost.cs b/src/MaterialDesignThemes.Wpf/DialogHost.cs index a237299f0a..1486607cda 100644 --- a/src/MaterialDesignThemes.Wpf/DialogHost.cs +++ b/src/MaterialDesignThemes.Wpf/DialogHost.cs @@ -412,10 +412,10 @@ private static void IsOpenPropertyChangedCallback(DependencyObject dependencyObj private void ListenForWindowStateChanged(Window? window) { - window ??= Window.GetWindow(this); if (window is not null) { + window.StateChanged -= Window_StateChanged; window.StateChanged += Window_StateChanged; } } @@ -437,12 +437,15 @@ private void Window_StateChanged(object? sender, EventArgs e) // We only need to focus anything manually if the window changes state from Minimized --> (Normal or Maximized) // Going from Normal --> Maximized (and vice versa) is fine since the focus is already kept correctly - if (IsWindowRestoredFromMinimized() && IsLastFocusedDialogElementFocusable()) + if (IsWindowRestoredFromMinimized()) { // 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(() => { - _lastFocusedDialogElement!.Focus(); + if (IsLastFocusedDialogElementFocusable()) + { + _lastFocusedDialogElement!.Focus(); + } }))); } _previousWindowState = windowState; From 3c6ea518b015642be3d69c9b2aa37c140d7b5317 Mon Sep 17 00:00:00 2001 From: Corvin Date: Fri, 26 Sep 2025 18:23:07 +0200 Subject: [PATCH 5/6] Adjust test to test against both DialogHost styles --- .../WPF/DialogHosts/DialogHostTests.cs | 27 ++++++++++++++----- 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/tests/MaterialDesignThemes.UITests/WPF/DialogHosts/DialogHostTests.cs b/tests/MaterialDesignThemes.UITests/WPF/DialogHosts/DialogHostTests.cs index d2f739071e..a19f7bb536 100644 --- a/tests/MaterialDesignThemes.UITests/WPF/DialogHosts/DialogHostTests.cs +++ b/tests/MaterialDesignThemes.UITests/WPF/DialogHosts/DialogHostTests.cs @@ -515,15 +515,24 @@ await Wait.For(async () => [Test] [Description("Issue 3434")] - [Arguments(WindowState.Minimized, WindowState.Maximized)] - [Arguments(WindowState.Minimized, WindowState.Normal)] - [Arguments(WindowState.Maximized, WindowState.Normal)] - public async Task DialogHost_WhenWindowStateChanges_FocusedElementStaysFocused(WindowState firstWindowState, WindowState secondWindowState) + [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(); - await Task.Delay(400, TestContext.Current!.CancellationToken); + 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"); @@ -547,5 +556,11 @@ static object SetStateOfParentWindow(DialogHost dialogHost, WindowState state) window.WindowState = state; return null!; } - } + static object SetDialogHostStyle(DialogHost dialogHost, string styleName) + { + Style style = (Style)dialogHost.FindResource(styleName); + dialogHost.Style = style; + return null!; + } + } } From 34082b4928b5955fcd150b7f51f2985072b8bd83 Mon Sep 17 00:00:00 2001 From: Corvin Date: Sun, 5 Oct 2025 17:34:00 +0200 Subject: [PATCH 6/6] Refactor DialogHost to use HwndSourceHook for focus Replaced the Window.StateChanged event-based approach with an HwndSourceHook-based implementation to handle window state changes (minimize/restore) more effectively. Removed `_previousWindowState` and introduced `_hook` for managing system commands (`WM_SYSCOMMAND`). Added a `Hook` method to handle `SC_MINIMIZE` and `SC_RESTORE` commands, ensuring proper focus management. Refactored focus restoration logic to use asynchronous behavior (`Task.Delay` and `Dispatcher.BeginInvoke`) for improved reliability. Removed redundant state-checking methods and improved code clarity with constants for system commands. --- src/MaterialDesignThemes.Wpf/DialogHost.cs | 69 +++++++++++----------- 1 file changed, 36 insertions(+), 33 deletions(-) diff --git a/src/MaterialDesignThemes.Wpf/DialogHost.cs b/src/MaterialDesignThemes.Wpf/DialogHost.cs index 1486607cda..4cbb9db755 100644 --- a/src/MaterialDesignThemes.Wpf/DialogHost.cs +++ b/src/MaterialDesignThemes.Wpf/DialogHost.cs @@ -65,7 +65,7 @@ public class DialogHost : ContentControl private DialogClosedEventHandler? _attachedDialogClosedEventHandler; private IInputElement? _restoreFocusDialogClose; private IInputElement? _lastFocusedDialogElement; - private WindowState _previousWindowState; + private HwndSourceHook? _hook; private Action? _currentSnackbarMessageQueueUnPauseAction; static DialogHost() @@ -412,48 +412,51 @@ private static void IsOpenPropertyChangedCallback(DependencyObject dependencyObj private void ListenForWindowStateChanged(Window? window) { - - if (window is not null) - { - window.StateChanged -= Window_StateChanged; - window.StateChanged += Window_StateChanged; - } - } - - private void Window_StateChanged(object? sender, EventArgs e) - { - if (sender is not Window window) + if (window is null) { return; } - var windowState = window.WindowState; - if (windowState == WindowState.Minimized) + if (PresentationSource.FromVisual(window) is HwndSource source) { - _lastFocusedDialogElement = FocusManager.GetFocusedElement(window); - _previousWindowState = windowState; - return; + _hook = Hook; + source.RemoveHook(_hook); + source.AddHook(_hook); } - // We only need to focus anything manually if the window changes state from Minimized --> (Normal or Maximized) - // Going from Normal --> Maximized (and vice versa) is fine since the focus is already kept correctly - if (IsWindowRestoredFromMinimized()) + IntPtr Hook(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled) { - // 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(() => + //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) { - if (IsLastFocusedDialogElementFocusable()) - { - _lastFocusedDialogElement!.Focus(); - } - }))); + 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; } - _previousWindowState = windowState; - - bool IsWindowRestoredFromMinimized() => (windowState == WindowState.Normal || windowState == WindowState.Maximized) && - _previousWindowState == WindowState.Minimized; - - bool IsLastFocusedDialogElementFocusable() => _lastFocusedDialogElement is UIElement { Focusable: true, IsVisible: true }; } ///