diff --git a/src/Wpf.Ui.FontMapper/Program.cs b/src/Wpf.Ui.FontMapper/Program.cs index 31ad36578..83e4de7d7 100644 --- a/src/Wpf.Ui.FontMapper/Program.cs +++ b/src/Wpf.Ui.FontMapper/Program.cs @@ -39,7 +39,8 @@ await httpClient.GetFromJsonAsync>( ) ?.Last() ?.Ref.Replace("refs/tags/", string.Empty) - .Trim() ?? throw new Exception("Unable to parse the version string"); + .Trim() + ?? throw new Exception("Unable to parse the version string"); } string FormatIconName(string rawIconName) diff --git a/src/Wpf.Ui.Gallery/Views/Windows/MainWindow.xaml b/src/Wpf.Ui.Gallery/Views/Windows/MainWindow.xaml index 16341cea5..97e649dd4 100644 --- a/src/Wpf.Ui.Gallery/Views/Windows/MainWindow.xaml +++ b/src/Wpf.Ui.Gallery/Views/Windows/MainWindow.xaml @@ -76,8 +76,6 @@ - - + + diff --git a/src/Wpf.Ui/Appearance/SystemThemeWatcher.cs b/src/Wpf.Ui/Appearance/SystemThemeWatcher.cs index 3bc005284..376505569 100644 --- a/src/Wpf.Ui/Appearance/SystemThemeWatcher.cs +++ b/src/Wpf.Ui/Appearance/SystemThemeWatcher.cs @@ -151,9 +151,11 @@ public static void UnWatch(Window? window) /// private static IntPtr WndProc(IntPtr hWnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled) { - if (msg == (int)PInvoke.WM_DWMCOLORIZATIONCOLORCHANGED || - msg == (int)PInvoke.WM_THEMECHANGED || - msg == (int)PInvoke.WM_SYSCOLORCHANGE) + if ( + msg == (int)PInvoke.WM_DWMCOLORIZATIONCOLORCHANGED + || msg == (int)PInvoke.WM_THEMECHANGED + || msg == (int)PInvoke.WM_SYSCOLORCHANGE + ) { UpdateObservedWindow(hWnd); } diff --git a/src/Wpf.Ui/AutomationPeers/ContentDialogAutomationPeer.cs b/src/Wpf.Ui/AutomationPeers/ContentDialogAutomationPeer.cs new file mode 100644 index 000000000..ad6d907f9 --- /dev/null +++ b/src/Wpf.Ui/AutomationPeers/ContentDialogAutomationPeer.cs @@ -0,0 +1,206 @@ +// This Source Code Form is subject to the terms of the MIT License. +// If a copy of the MIT was not distributed with this file, You can obtain one at https://opensource.org/licenses/MIT. +// Copyright (C) Leszek Pomianowski and WPF UI Contributors. +// All Rights Reserved. + +using System.Windows.Automation; +using System.Windows.Automation.Peers; +using System.Windows.Automation.Provider; +using System.Windows.Threading; +using Wpf.Ui.Controls; + +namespace Wpf.Ui.AutomationPeers; + +/// +/// Automation peer that exposes a as a standard modal window +/// for UI Automation clients. +/// +/// +/// This peer maps dialog-specific behavior to the pattern so +/// assistive technologies (screen readers, automation tools) perceive the +/// as a modal, non-resizable dialog window. +/// +internal sealed class ContentDialogAutomationPeer : UIElementAutomationPeer, IWindowProvider +{ + /// + /// Initializes a new instance of the class. + /// + /// The associated . + public ContentDialogAutomationPeer(ContentDialog owner) + : base(owner) { } + + /// + /// Gets a value indicating whether the window is modal. + /// Always for . + /// + bool IWindowProvider.IsModal => true; + + /// + /// Gets a value indicating whether the window is topmost. + /// are treated as topmost for automation. + /// + bool IWindowProvider.IsTopmost => true; + + /// + /// Gets the current interaction state of the dialog window for UI Automation. + /// + public WindowInteractionState InteractionState + { + get + { + if (Owner is ContentDialog dialog) + { + if ( + !dialog.IsLoaded + || dialog.Dispatcher is { HasShutdownFinished: true } or { HasShutdownStarted: true } + ) + { + return WindowInteractionState.Closing; + } + } + + return WindowInteractionState.Running; + } + } + + /// + /// Gets a value indicating whether the window can be maximized. + /// Always for . + /// + public bool Maximizable => false; + + /// + /// Gets a value indicating whether the window can be minimized. + /// Always for . + /// + public bool Minimizable => false; + + /// + /// Gets the visual state of the window. + /// report . + /// + public WindowVisualState VisualState => WindowVisualState.Normal; + + /// + protected override string GetClassNameCore() + { + // "Emulating WinUI3's ContentDialog ClassName" + return "Popup"; + } + + /// + protected override string? GetNameCore() + { + if (Owner is ContentDialog dialog) + { + return dialog.Title as string ?? dialog.Title?.ToString(); + } + + return base.GetNameCore(); + } + + /// + protected override AutomationControlType GetAutomationControlTypeCore() + { + return AutomationControlType.Window; + } + +#if NET48_OR_GREATER || NET5_0_OR_GREATER + /// + protected override bool IsDialogCore() + { + return true; + } +#endif + + /// + protected override bool IsControlElementCore() + { + return true; + } + + /// + protected override bool IsContentElementCore() + { + return true; + } + + /// + protected override bool IsKeyboardFocusableCore() + { + return false; + } + + /// + /// Returns whether the dialog is currently offscreen. A dialog is considered offscreen when not loaded or not visible. + /// + protected override bool IsOffscreenCore() + { + return Owner is ContentDialog { IsLoaded: false } or { IsVisible: false }; + } + + /// + /// Returns automation pattern implementations supported by this peer. Provides . + /// + /// The requested automation pattern. + /// An object implementing the requested pattern or when not supported. + public override object? GetPattern(PatternInterface pattern) + { + // Include PatternInterface.ScrollItem to align with WinUI3 behavior: WinUI3 exposes this pattern + // for dialog-like popups, and exposing it here helps automation clients that rely on that behavior. + if (pattern is PatternInterface.Window or PatternInterface.ScrollItem) + { + return this; + } + + return null; + } + + /// + /// Closes the associated . + /// This is invoked by UI Automation clients through the pattern. + /// + void IWindowProvider.Close() + { + if (Owner is ContentDialog dialog) + { + Dispatcher? dispatcher = dialog.Dispatcher; + if (dispatcher is { HasShutdownStarted: false, HasShutdownFinished: false }) + { + dispatcher.BeginInvoke( + () => + { + dialog.Hide(); + }, + DispatcherPriority.Normal + ); + } + else + { + dialog.Hide(); + } + } + } + + /// + /// Sets the visual state of the window. Not supported for . + /// + void IWindowProvider.SetVisualState(WindowVisualState state) + { + // Not supported for this. + } + + /// + /// Waits for the dialog to become idle. + /// Always returns for . + /// + /// Maximum time to wait in milliseconds (ignored). + /// + /// if the dialog is idle or the operation completed; + /// otherwise . + /// + public bool WaitForInputIdle(int milliseconds) + { + return true; + } +} diff --git a/src/Wpf.Ui/ContentDialogService.cs b/src/Wpf.Ui/ContentDialogService.cs index c0b9e3200..2db383343 100644 --- a/src/Wpf.Ui/ContentDialogService.cs +++ b/src/Wpf.Ui/ContentDialogService.cs @@ -13,7 +13,7 @@ namespace Wpf.Ui; /// /// /// -/// <ContentPresenter x:Name="RootContentDialogPresenter" Grid.Row="0" /> +/// <ContentDialogHost x:Name="RootContentDialogPresenter" Grid.Row="0" /> /// /// /// IContentDialogService contentDialogService = new ContentDialogService(); @@ -33,6 +33,7 @@ namespace Wpf.Ui; public class ContentDialogService : IContentDialogService { private ContentPresenter? _dialogHost; + private ContentDialogHost? _dialogHostEx; [Obsolete("Use SetDialogHost instead.")] public void SetContentPresenter(ContentPresenter contentPresenter) @@ -47,34 +48,110 @@ public void SetContentPresenter(ContentPresenter contentPresenter) } /// + [Obsolete("Use SetDialogHost(ContentDialogHost) instead.")] public void SetDialogHost(ContentPresenter contentPresenter) { + if (contentPresenter == null) + { + throw new ArgumentNullException(nameof(contentPresenter)); + } + + if (_dialogHostEx != null) + { + throw new InvalidOperationException( + "Cannot set ContentPresenter: a ContentDialogHost host has already been set. " + + "Only one host type is allowed per instance for compatibility." + ); + } + _dialogHost = contentPresenter; } /// + [Obsolete("Use GetDialogHostEx() instead.")] public ContentPresenter? GetDialogHost() { return _dialogHost; } + /// + /// + /// Thrown when is . + /// + /// + /// Thrown when a legacy dialog host (ContentPresenter) has already been set via + /// . Only one host type can be set per instance. + /// + /// + /// + /// This method sets the enhanced to contain and manage dialogs. + /// For compatibility reasons, an instance can have either a legacy host (set via + /// ) or an enhanced host (set via this method), + /// but not both. + /// + /// + public void SetDialogHost(ContentDialogHost dialogHost) + { + if (dialogHost == null) + { + throw new ArgumentNullException(nameof(dialogHost)); + } + + // Defense mechanism: prevent mixed host types for compatibility + if (_dialogHost != null) + { + throw new InvalidOperationException( + "Cannot set ContentDialogHost: a legacy ContentPresenter host has already been set. " + + "Only one host type is allowed per instance for compatibility." + ); + } + + _dialogHostEx = dialogHost; + } + + /// + public ContentDialogHost? GetDialogHostEx() + { + return _dialogHostEx; + } + /// public Task ShowAsync(ContentDialog dialog, CancellationToken cancellationToken) { - if (_dialogHost == null) +#pragma warning disable CS0618 // (Warning: Obsolete) To maintain compatibility + + if (dialog == null) + { + throw new ArgumentNullException(nameof(dialog)); + } + + if (_dialogHostEx == null && _dialogHost == null) { throw new InvalidOperationException("The DialogHost was never set."); } - if (dialog.DialogHost != null && _dialogHost != dialog.DialogHost) + object? svcHost = _dialogHostEx is not null ? _dialogHostEx : _dialogHost; + + object? dlgHost = dialog.DialogHostEx is not null ? dialog.DialogHostEx : dialog.DialogHost; + + if (dlgHost != null && !ReferenceEquals(dlgHost, svcHost)) { throw new InvalidOperationException( "The DialogHost is not the same as the one that was previously set." ); } - dialog.DialogHost = _dialogHost; + if (_dialogHostEx != null) + { + dialog.DialogHostEx = _dialogHostEx; + } + else + { + dialog.DialogHost = _dialogHost; + } return dialog.ShowAsync(cancellationToken); + +#pragma warning restore CS0618 // (Warning: Obsolete) } } diff --git a/src/Wpf.Ui/Controls/ContentDialog/ContentDialog.FocusBehavior.cs b/src/Wpf.Ui/Controls/ContentDialog/ContentDialog.FocusBehavior.cs new file mode 100644 index 000000000..c8b7c7368 --- /dev/null +++ b/src/Wpf.Ui/Controls/ContentDialog/ContentDialog.FocusBehavior.cs @@ -0,0 +1,330 @@ +// This Source Code Form is subject to the terms of the MIT License. +// If a copy of the MIT was not distributed with this file, You can obtain one at https://opensource.org/licenses/MIT. +// Copyright (C) Leszek Pomianowski and WPF UI Contributors. +// All Rights Reserved. + +using System.Windows.Controls; +using System.Windows.Input; +using System.Windows.Media.Media3D; +using System.Windows.Threading; +using UiButton = Wpf.Ui.Controls.Button; +using WinButton = System.Windows.Controls.Button; + +// ReSharper disable once CheckNamespace +namespace Wpf.Ui.Controls; + +#pragma warning disable IDE0008 // Use explicit type instead of 'var' + +/// +/// Partial class ContentDialog.FocusBehavior +/// +/// This partial class implements the initial focus behavior for ContentDialog, +/// ensuring compliance with the Windows App SDK's official guidelines. +/// +/// Reference: +/// https://learn.microsoft.com/en-us/windows/apps/develop/ui/controls/dialogs-and-flyouts/dialogs +/// +/// +/// Implementation notes: +/// - Focuses only on initial display behavior, not ongoing focus management +/// - Works with both XAML-defined and dynamically added content +/// - Does not handle UI Automation or accessibility providers +/// +public partial class ContentDialog +{ + // Temporarily suppress focus event listener + private bool _suppressFocusRestore; + + /// + /// Returns when the keyboard focus is currently within the dialog's visual/logical tree. + /// + /// + /// Thrown when the method is called from a non-UI thread. + /// + public bool IsFocusInsideDialog() + { + if (Dispatcher is not { HasShutdownStarted: false, HasShutdownFinished: false }) + { + return false; + } + + if (Dispatcher.CheckAccess()) + { + return IsFocusInsideDialogCore(Keyboard.FocusedElement); + } + + throw new InvalidOperationException("IsFocusInsideDialog can only be called from the UI thread."); + } + + /// + /// Completely prevents focus from escaping the ContentDialog. When a focus escape is detected, + /// the focus is forcibly pulled back into the dialog. + /// + protected override void OnPreviewLostKeyboardFocus(KeyboardFocusChangedEventArgs e) + { + if (_suppressFocusRestore) + { + return; + } + + var window = Window.GetWindow(this); + if (e.NewFocus == null || window is not { IsActive: true }) + { + return; + } + + if (!IsFocusInsideDialogCore(e.NewFocus) && IsLoaded) + { + e.Handled = true; + + _suppressFocusRestore = true; + + Dispatcher.BeginInvoke( + () => + { + if (e.OldFocus is { } old && IsFocusInsideDialogCore(old)) + { + e.OldFocus.Focus(); + } + else + { + Focus(); + } + + _suppressFocusRestore = false; + }, + DispatcherPriority.Input + ); + } + } + + private bool IsFocusInsideDialogCore(IInputElement? element) + { + // ReSharper disable once SuspiciousTypeConversion.Global + if (element is not DependencyObject focused) + { + return false; + } + + var current = focused; + + while (current != null) + { + if (ReferenceEquals(current, this)) + { + return true; + } + + current = GetParent(current); + } + + return false; + } + + /// + /// Sets the initial keyboard focus when the dialog is first displayed. + /// + /// + /// Priority strategy: + /// 1. Content-first: focus the first focusable element within the user-provided + /// `Content` (the first focusable ``). + /// 2. Built-in default button: if a built-in template button (Primary, Close, or + /// Secondary) is marked as default and is safely focusable, focus it (see + /// ). + /// 3. Template fallback: find any `System.Windows.Controls.Button` in the template + /// with `IsDefault == true` and focus it. + /// 4. Fallback: if none of the above are available, make the + /// `ContentDialog` itself focusable and set focus to it. + /// + protected virtual void SetInitialFocus() + { + // 1) Primary (content-first): focus first focusable element within user-provided content. + var content = Content as DependencyObject; + + // Prefer `Control` (elements deriving from System.Windows.Controls.Control) because + // they reliably provide UI Automation peers and are recognized by screen readers. + var firstFocusable = FindDescendant(content, IsSafelyFocusable); + if (firstFocusable is not null) + { + firstFocusable.Focus(); + return; + } + + // 2) Secondary: focus built-in default button placed in template footer + if (FocusBuiltInButton()) + { + return; + } + + // 3) Template fallback: try to find any custom button marked as default (IsDefault == true) + var templateDefault = FindDescendant( + this, + b => b is { IsDefault: true } && IsSafelyFocusable(b) + ); + + if (templateDefault is not null) + { + templateDefault.Focus(); + return; + } + + /* + At this point, there are no safely focusable controls available. The final attempt is to set focus to the + ContentDialog itself. Since ContentDialog contains a full-window overlay mask layer, UI automation tools + will recognize the ContentDialog's size as the mask layer's dimensions. Therefore, if the focus indicator + appears inconsistent with the "dialog" size, do not be surprised. + */ + + // 4) Fallback: make ContentDialog focusable and focus it + SetCurrentValue(FocusableProperty, true); + Focus(); + } + + private bool FocusBuiltInButton() + { + var safelyButtons = new List(); + + if (GetTemplateChild("PrimaryButton") is WinButton primaryBtn && IsSafelyFocusable(primaryBtn)) + { + safelyButtons.Add(primaryBtn); + } + + if (GetTemplateChild("CloseButton") is WinButton closeBtn && IsSafelyFocusable(closeBtn)) + { + safelyButtons.Add(closeBtn); + } + + if (GetTemplateChild("SecondaryButton") is WinButton secondaryBtn && IsSafelyFocusable(secondaryBtn)) + { + safelyButtons.Add(secondaryBtn); + } + + // Priority: find the first IsDefault button and select it, then return. + foreach (var btn in safelyButtons) + { + if (btn.IsDefault) + { + btn.Focus(); + return true; + } + } + + // Fallback: Find the first button and focus it, then return. + if (safelyButtons.Count > 0) + { + safelyButtons[0].Focus(); + return true; + } + + return false; + } + + private static T? FindDescendant(DependencyObject? root, Predicate predicate) + where T : DependencyObject + { + if (root == null) + { + return null; + } + + try + { + // If the root is a Visual or Visual3D, traverse the visual tree + if (root is Visual or Visual3D) + { + var childrenCount = VisualTreeHelper.GetChildrenCount(root); + + for (var i = 0; i < childrenCount; i++) + { + var child = VisualTreeHelper.GetChild(root, i); + + if (child is T t && predicate(t)) + { + return t; + } + + T? found = FindDescendant(child, predicate); + if (found is not null) + { + return found; + } + } + + return null; + } + + // For non-visual elements, fall back to logical tree traversal + foreach (var logicalChild in LogicalTreeHelper.GetChildren(root)) + { + if (logicalChild is not DependencyObject child) + { + continue; + } + + if (child is T t && predicate(t)) + { + return t; + } + + T? found = FindDescendant(child, predicate); + if (found is not null) + { + return found; + } + } + } + catch + { + // defensive: ignore traversal errors and return null + } + + return null; + } + + private static bool IsSafelyFocusable(DependencyObject? dp) + { + switch (dp) + { + case not (UIElement or ContentElement or UIElement3D): + case UIElement ue when !ue.Focusable || !ue.IsVisible || !ue.IsEnabled: + case Control { IsTabStop: false }: + case ContentElement { Focusable: false }: + case UIElement3D { Focusable: false }: + case UiButton { Appearance: ControlAppearance.Danger or ControlAppearance.Caution }: + return false; + } + + return true; + } + + private static DependencyObject? GetParent(DependencyObject d) + { + try + { + var p = VisualTreeHelper.GetParent(d); + if (p != null) + { + return p; + } + + var lp = LogicalTreeHelper.GetParent(d); + if (lp != null) + { + return lp; + } + } + catch + { + // ignored + } + + if (d is FrameworkElement fe) + { + return fe.Parent ?? fe.TemplatedParent; + } + + return null; + } +} + +#pragma warning restore IDE0008 // Use explicit type instead of 'var' diff --git a/src/Wpf.Ui/Controls/ContentDialog/ContentDialog.cs b/src/Wpf.Ui/Controls/ContentDialog/ContentDialog.cs index eab8314a7..461ec10c4 100644 --- a/src/Wpf.Ui/Controls/ContentDialog/ContentDialog.cs +++ b/src/Wpf.Ui/Controls/ContentDialog/ContentDialog.cs @@ -3,7 +3,9 @@ // Copyright (C) Leszek Pomianowski and WPF UI Contributors. // All Rights Reserved. +using System.Windows.Automation.Peers; using System.Windows.Controls; +using Wpf.Ui.AutomationPeers; using Wpf.Ui.Input; // ReSharper disable once CheckNamespace @@ -14,10 +16,10 @@ namespace Wpf.Ui.Controls; /// /// /// -/// <ContentPresenter x:Name="RootContentDialogPresenter" Grid.Row="0" /> +/// <ContentDialogHost x:Name="RootContentDialogHost" Grid.Row="0" /> /// /// -/// var contentDialog = new ContentDialog(RootContentDialogPresenter); +/// var contentDialog = new ContentDialog(RootContentDialogHost); /// /// contentDialog.SetCurrentValue(ContentDialog.TitleProperty, "Hello World"); /// contentDialog.SetCurrentValue(ContentControl.ContentProperty, "This is a message"); @@ -27,7 +29,7 @@ namespace Wpf.Ui.Controls; /// /// /// var contentDialogService = new ContentDialogService(); -/// contentDialogService.SetContentPresenter(RootContentDialogPresenter); +/// contentDialogService.SetDialogHost(RootContentDialogHost); /// /// await _contentDialogService.ShowSimpleDialogAsync( /// new SimpleContentDialogCreateOptions() @@ -41,7 +43,7 @@ namespace Wpf.Ui.Controls; /// ); /// /// -public class ContentDialog : ContentControl +public partial class ContentDialog : ContentControl { /// Identifies the dependency property. public static readonly DependencyProperty TitleProperty = DependencyProperty.Register( @@ -210,6 +212,18 @@ public class ContentDialog : ContentControl new PropertyMetadata(null) ); + private static readonly DependencyPropertyKey IsLegacyHostPropertyKey = + DependencyProperty.RegisterReadOnly( + nameof(IsLegacyHost), + typeof(bool), + typeof(ContentDialog), + new PropertyMetadata(true) + ); + + /// Identifies the dependency property. + public static readonly DependencyProperty IsLegacyHostProperty = + IsLegacyHostPropertyKey.DependencyProperty; + /// Identifies the routed event. public static readonly RoutedEvent OpenedEvent = EventManager.RegisterRoutedEvent( nameof(Opened), @@ -439,6 +453,21 @@ public event TypedEventHandler Opened /// /// Occurs after the dialog starts to close, but before it is closed and before the event occurs. /// + /// + /// + /// This event allows cancellation of the close operation by setting + /// to . + /// + /// + /// Important: The Closing event is only raised for explicit close operations initiated via the + /// method. It is not raised when the dialog is passively removed from the visual tree, + /// such as when: + /// + /// + /// Another dialog replaces this one + /// The host control or window is disposed + /// + /// public event TypedEventHandler Closing { add => AddHandler(ClosingEvent, value); @@ -463,6 +492,9 @@ public event TypedEventHandler remove => RemoveHandler(ButtonClickedEvent, value); } + /// Gets a value indicating whether the dialog is shown in the legacy host. + public bool IsLegacyHost => (bool)GetValue(IsLegacyHostProperty); + /// /// Initializes a new instance of the class. /// @@ -470,55 +502,207 @@ public ContentDialog() { SetValue(TemplateButtonCommandProperty, new RelayCommand(OnButtonClick)); - // Avoid registering runtime code that triggers designer behavior or throws exceptions - // at design time (to reduce the possibility of designer crashes/rendering failures). - if (!Wpf.Ui.Designer.DesignerHelper.IsInDesignMode) - { - Loaded += static (sender, _) => - { - var self = (ContentDialog)sender; - self.OnLoaded(); - }; - } + RegisterRuntimeEventHandlers(); } /// /// Initializes a new instance of the class. /// /// inside of which the dialogue will be placed. The new will replace the current . + /// + /// DEPRECATED: This constructor overload is deprecated. Use the constructor that accepts a + /// instead for enhanced modal dialog capabilities. + /// + [Obsolete( + "ContentDialog(ContentPresenter? is deprecated. Please use ContentDialog(ContentDialogHost? instead.", + false + )] public ContentDialog(ContentPresenter? dialogHost) { - if (dialogHost is null) + // Prefer the legacy DialogHost (ContentPresenter) when both ContentDialogHost + // and the legacy host exist in the same window, and ContentDialogService is + // configured to use the legacy host. + // This ensures consistency between the host instance used locally and + // the actual instance utilized internally by ContentDialogService. + if (dialogHost is not null) + { + DialogHost = dialogHost; + } + else { - throw new ArgumentNullException(nameof(dialogHost)); + // Fallback to using ContentDialogHost, which must be obtained from the currently active window. + Window? activeWindow = null; + + // try Application.Current windows + try + { + Application? app = Application.Current; + if (app != null) + { + activeWindow = + app.Windows.OfType().FirstOrDefault(w => w.IsActive) ?? app.MainWindow; + } + } + catch + { + // ignore and fallback + } + + // fallback: Win32 foreground window -> HwndSource -> Window + activeWindow ??= Win32.Utilities.TryGetWindowFromForegroundHwnd(); + + var hostEx = ContentDialogHost.GetForWindow(activeWindow); + if (hostEx is not null) + { + DialogHostEx = hostEx; + } + else + { + // The legacy constructor immediately throws when the dialogHost parameter is null. + // For backward compatibility, we now fall back to using the new ContentDialogHost + // when no dialogHost is specified. Only when both are unavailable do we actually + // throw the null argument exception. + throw new ArgumentNullException(nameof(dialogHost)); + } } - DialogHost = dialogHost; + SetValue(TemplateButtonCommandProperty, new RelayCommand(OnButtonClick)); + + RegisterRuntimeEventHandlers(); + } + + /// + /// Initializes a new instance of the class with the specified dialog host. + /// + /// The ContentDialogHost that manages the dialog's display and interaction. + /// Thrown if dialogHost is null. + public ContentDialog(ContentDialogHost? dialogHost) + { + DialogHostEx = dialogHost ?? throw new ArgumentNullException(nameof(dialogHost)); SetValue(TemplateButtonCommandProperty, new RelayCommand(OnButtonClick)); + RegisterRuntimeEventHandlers(); + } + + private void RegisterRuntimeEventHandlers() + { // Avoid registering runtime code that triggers designer behavior or throws exceptions // at design time (to reduce the possibility of designer crashes/rendering failures). - if (!Wpf.Ui.Designer.DesignerHelper.IsInDesignMode) + if (!Designer.DesignerHelper.IsInDesignMode) { Loaded += static (sender, _) => { var self = (ContentDialog)sender; - self.OnLoaded(); + self.OnLoadedInternal(); + }; + + Unloaded += static (sender, _) => + { + var self = (ContentDialog)sender; + self.OnUnloadedInternal(); }; } } + // Legacy and new host coexist for compatibility during migration. + private ContentPresenter? _dialogHost; + private ContentDialogHost? _dialogHostEx; + + /// + /// Gets or sets inside of which the dialogue will be placed. + /// + /// + /// Thrown if trying to set DialogHost when DialogHostEx is already set, or if trying to change DialogHost while the dialog is being shown. + /// + [Obsolete("DialogHost is deprecated. Please use DialogHostEx instead.")] + public ContentPresenter? DialogHost + { + get => _dialogHost; + set + { + if (_dialogHostEx is not null) + { + throw new InvalidOperationException( + "Cannot set DialogHost when DialogHostEx is already set." + ); + } + + if (IsShowing) + { + throw new InvalidOperationException( + "Cannot change DialogHost while the dialog is being shown." + ); + } + + if (ReferenceEquals(_dialogHost, value)) + { + return; + } + + if (_dialogHost is not null) + { + ContentDialogHostBehavior.SetIsEnabled(_dialogHost, false); + } + + _dialogHost = value; + + if (_dialogHost is not null) + { + ContentDialogHostBehavior.SetIsEnabled(_dialogHost, true); + } + + UpdateIsLegacyHost(); + } + } + /// - /// Gets or sets inside of which the dialogue will be placed. The new will replace the current . + /// Gets or sets inside of which the dialogue will be placed. /// - public ContentPresenter? DialogHost { get; set; } = default; + /// + /// Thrown if trying to set DialogHostEx when DialogHost is already set, or if trying to change DialogHostEx while the dialog is being shown. + /// + public ContentDialogHost? DialogHostEx + { + get => _dialogHostEx; + set + { + if (_dialogHost is not null) + { + throw new InvalidOperationException( + "Cannot set DialogHostEx when DialogHost is already set." + ); + } + + if (IsShowing) + { + throw new InvalidOperationException( + "Cannot change DialogHostEx while the dialog is being shown." + ); + } + + if (!ReferenceEquals(_dialogHostEx, value)) + { + _dialogHostEx = value; + } + + UpdateIsLegacyHost(); + } + } [Obsolete("ContentPresenter is deprecated. Please use DialogHost instead.")] public ContentPresenter? ContentPresenter { get; set; } = default; protected TaskCompletionSource? Tcs { get; set; } + // Helper indicating whether the dialog is currently shown (the async operation hasn't completed yet) + private bool IsShowing => Tcs is not null && !Tcs.Task.IsCompleted; + + private void UpdateIsLegacyHost() + { + SetValue(IsLegacyHostPropertyKey, _dialogHostEx is null); + } + /// /// Shows the dialog /// @@ -529,12 +713,22 @@ public ContentDialog(ContentPresenter? dialogHost) )] public async Task ShowAsync(CancellationToken cancellationToken = default) { - if (DialogHost is null) + if (_dialogHost is null && _dialogHostEx is null) { throw new InvalidOperationException("DialogHost was not set"); } - Tcs = new TaskCompletionSource(); + // Uses `RunContinuationsAsynchronously` to execute continuations asynchronously + // rather than synchronously on the caller's stack when TCS completes. + // + // Benefits: + // - Prevents UI-thread reentrancy + // - Eliminates deadlock risks + // - Ensures predictable continuation scheduling + Tcs = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously + ); + CancellationTokenRegistration tokenRegistration = cancellationToken.Register( o => Tcs.TrySetCanceled((CancellationToken)o!), cancellationToken @@ -544,7 +738,15 @@ public async Task ShowAsync(CancellationToken cancellationT try { - DialogHost.Content = this; + if (_dialogHostEx is not null) + { + _dialogHostEx.Content = this; + } + else + { + _dialogHost!.Content = this; + } + result = await Tcs.Task; return result; @@ -556,7 +758,19 @@ public async Task ShowAsync(CancellationToken cancellationT #else tokenRegistration.Dispose(); #endif - DialogHost.Content = null; + + // DialogHost is a public container. To prevent the new dialog from being closed immediately when + // it opens due to the unconditional clearing of the Content upon the closure of the previous dialog, + // only clear the DialogHost content if this instance is still the current content. + if (_dialogHostEx is not null && ReferenceEquals(_dialogHostEx.Content, this)) + { + _dialogHostEx.Content = null; + } + else if (_dialogHost is not null && ReferenceEquals(_dialogHost.Content, this)) + { + _dialogHost.Content = null; + } + OnClosed(result); } } @@ -633,17 +847,50 @@ protected override Size MeasureOverride(Size availableSize) return desiredSize; } + protected override AutomationPeer OnCreateAutomationPeer() + { + return new ContentDialogAutomationPeer(this); + } + + private void OnLoadedInternal() + { + if (!IsFocusInsideDialog()) + { + SetInitialFocus(); + } + + OnLoaded(); + RaiseEvent(new RoutedEventArgs(OpenedEvent)); + } + /// /// Occurs after Loaded event /// - protected virtual void OnLoaded() + protected virtual void OnLoaded() { } + + private void OnUnloadedInternal() { - // Focus is only needed at runtime. - _ = Focus(); + if (!ReferenceEquals(_dialogHostEx?.Content, this) && !ReferenceEquals(_dialogHost?.Content, this)) + { + // If a new dialog instance is created and shown (e.g., via ShowAsync) while this dialog is still displayed, + // this instance will be removed from the visual tree. If the Hide method has not been called to complete the async operation, + // the ShowAsync task will be left dangling — waiting indefinitely without returning. + // Therefore, when this instance is removed from the visual tree, we must check the async task status: + // if not completed, return ContentDialogResult.None to resolve it. + if (Tcs is { Task.IsCompleted: false }) + { + _ = Tcs.TrySetResult(ContentDialogResult.None); + } + } - RaiseEvent(new RoutedEventArgs(OpenedEvent)); + OnUnloaded(); } + /// + /// Occurs after Unloaded event + /// + protected virtual void OnUnloaded() { } + private Size GetNewDialogSize(Size desiredSize) { // TODO: Handle negative values diff --git a/src/Wpf.Ui/Controls/ContentDialog/ContentDialog.xaml b/src/Wpf.Ui/Controls/ContentDialog/ContentDialog.xaml index 39a19f225..e2bedf59b 100644 --- a/src/Wpf.Ui/Controls/ContentDialog/ContentDialog.xaml +++ b/src/Wpf.Ui/Controls/ContentDialog/ContentDialog.xaml @@ -24,9 +24,10 @@ - + + @@ -35,18 +36,21 @@ - + + Visibility="{TemplateBinding IsFooterVisible, Converter={StaticResource BoolToVisibilityConverter}}"> @@ -134,6 +137,7 @@ + Visibility="{TemplateBinding IsPrimaryButtonEnabled, Converter={StaticResource BoolToVisibilityConverter}}" /> + Visibility="{TemplateBinding IsSecondaryButtonEnabled, Converter={StaticResource BoolToVisibilityConverter}}" /> + IsDefault="{TemplateBinding DefaultButton, Converter={StaticResource EnumToBoolConverter}, ConverterParameter={x:Static controls:ContentDialogButton.Close}}" /> @@ -204,10 +202,38 @@ + + + + + + + + + + + + + + + + + + + + + - + \ No newline at end of file diff --git a/src/Wpf.Ui/Controls/ContentDialog/ContentDialogHost.cs b/src/Wpf.Ui/Controls/ContentDialog/ContentDialogHost.cs new file mode 100644 index 000000000..6ec7ead8e --- /dev/null +++ b/src/Wpf.Ui/Controls/ContentDialog/ContentDialogHost.cs @@ -0,0 +1,234 @@ +// This Source Code Form is subject to the terms of the MIT License. +// If a copy of the MIT was not distributed with this file, You can obtain one at https://opensource.org/licenses/MIT. +// Copyright (C) Leszek Pomianowski and WPF UI Contributors. +// All Rights Reserved. + +using System.Runtime.CompilerServices; +using System.Windows.Controls; +using System.Windows.Threading; + +// ReSharper disable once CheckNamespace +namespace Wpf.Ui.Controls; + +#pragma warning disable IDE0008 // Use explicit type instead of 'var' + +/// +/// Provides a host control for displaying modal content dialogs within a WPF window. +/// Ensures that only one dialog host is registered per window and manages dialog +/// presentation and interaction blocking as needed. +/// +/// +/// XAML (place near the root of the Window): +/// +/// <Window x:Class="MyApp.MainWindow" xmlns:ui="clr-namespace:Wpf.Ui.Controls;assembly=Wpf.Ui"> +/// <Grid> +/// <ui:ContentDialogHost x:Name="RootDialogHost" /> +/// </Grid> +/// </Window> +/// +/// C# (showing a simple dialog via the host): +/// +/// var dialog = new ContentDialog(RootDialogHost) +/// { +/// Title = "Confirm", +/// Content = "Are you sure?", +/// PrimaryButtonText = "Yes", +/// CloseButtonText = "No", +/// }; +/// +/// var result = await dialog.ShowAsync(); +/// +/// +/// +/// +/// Use this control to present modal dialogs that overlay application content +/// and optionally disable interaction with sibling elements. +/// +/// +/// Placement Requirements: +/// 1. Place near the root of the window's visual tree to ensure broad coverage. +/// 2. Position as the last sibling among its peers to guarantee the highest Z-order. +/// +/// +/// Only one instance of can be registered per ; attempting to +/// register multiple instances will result in an exception. To retrieve the dialog host associated +/// with a specific window, use . +/// +/// +public class ContentDialogHost : ContentControl +{ + /// Identifies the dependency property. + public static readonly DependencyProperty IsDisableSiblingsEnabledProperty = DependencyProperty.Register( + nameof(IsDisableSiblingsEnabled), + typeof(bool), + typeof(ContentDialogHost), + new PropertyMetadata(false, OnIsDisableSiblingsEnabledChanged) + ); + + // Enforce single host per Window + private static readonly ConditionalWeakTable WindowHosts = new(); + +#if NET9_0_OR_GREATER + private static readonly Lock WindowHostsLock = new(); +#else + private static readonly object WindowHostsLock = new(); +#endif + + private readonly ContentDialogHostController _controller; + + static ContentDialogHost() + { + DefaultStyleKeyProperty.OverrideMetadata( + typeof(ContentDialogHost), + new FrameworkPropertyMetadata(typeof(ContentDialogHost)) + ); + } + + /// + /// Initializes a new instance of the class. + /// + public ContentDialogHost() + { + _controller = new ContentDialogHostController(this); + + Loaded += ContentDialogHost_Loaded; + Unloaded += ContentDialogHost_Unloaded; + } + + /// + /// Gets or sets a value indicating whether sibling elements of the dialog host should be + /// disabled while the dialog is displayed. The default value is . + /// + /// + /// to disable sibling elements; to leave + /// sibling elements unaffected. + /// + /// + /// When enabled, sibling elements in the host window may appear disabled while the + /// is displayed. This option is disabled by default and + /// is intended for scenarios where background interaction must be prevented. + /// + public bool IsDisableSiblingsEnabled + { + get => (bool)GetValue(IsDisableSiblingsEnabledProperty); + set => SetValue(IsDisableSiblingsEnabledProperty, value); + } + + /// + /// Returns the instance registered for the specified , if any. + /// + /// Window to query for a registered . + /// The registered for the given window, or if none is registered. + /// + /// + /// var host = ContentDialogHost.GetForWindow(Window.GetWindow(someElement)); + /// + /// + public static ContentDialogHost? GetForWindow(Window? window) + { + if (window == null) + { + return null; + } + + lock (WindowHostsLock) + { + return WindowHosts.TryGetValue(window, out var existing) ? existing : null; + } + } + + protected override void OnContentChanged(object? oldContent, object? newContent) + { + // Transition: no dialog -> dialog (first open) + if (oldContent == null && newContent != null) + { + _controller.HandleDialogAdded(); + base.OnContentChanged(oldContent, newContent); + return; + } + + // Transition: dialog -> no dialog (last close) + if (oldContent != null && newContent == null) + { + base.OnContentChanged(oldContent, newContent); + _controller.HandleDialogRemoved(); + return; + } + + // Replacement: oldContent != null && newContent != null + base.OnContentChanged(oldContent, newContent); + } + + private static void OnIsDisableSiblingsEnabledChanged( + DependencyObject d, + DependencyPropertyChangedEventArgs e + ) + { + if (d is ContentDialogHost host) + { + host._controller.IsDisableSiblingsEnabled = (bool)e.NewValue; + } + } + + private void ContentDialogHost_Loaded(object? sender, RoutedEventArgs e) + { + var window = Window.GetWindow(this); + if (window == null) + { + // Try again later if window not available yet + _ = Dispatcher.BeginInvoke(new Action(RegisterHostForWindow), DispatcherPriority.Loaded); + return; + } + + RegisterHost(window); + } + + private void ContentDialogHost_Unloaded(object? sender, RoutedEventArgs e) + { + var window = Window.GetWindow(this); + if (window == null) + { + return; + } + + lock (WindowHostsLock) + { + if (WindowHosts.TryGetValue(window, out var existing) && ReferenceEquals(existing, this)) + { + WindowHosts.Remove(window); + } + } + } + + private void RegisterHostForWindow() + { + var window = Window.GetWindow(this); + if (window != null) + { + RegisterHost(window); + } + } + + private void RegisterHost(Window window) + { + lock (WindowHostsLock) + { + if (WindowHosts.TryGetValue(window, out var existing)) + { + if (!ReferenceEquals(existing, this)) + { + throw new InvalidOperationException( + "Only one ContentDialogHost instance is allowed per Window." + ); + } + + // already registered for this window and it's this instance + return; + } + + WindowHosts.Add(window, this); + } + } +} + +#pragma warning restore IDE0008 // Use explicit type instead of 'var' diff --git a/src/Wpf.Ui/Controls/ContentDialog/ContentDialogHost.xaml b/src/Wpf.Ui/Controls/ContentDialog/ContentDialogHost.xaml new file mode 100644 index 000000000..bb5c6e978 --- /dev/null +++ b/src/Wpf.Ui/Controls/ContentDialog/ContentDialogHost.xaml @@ -0,0 +1,32 @@ + + + + +