diff --git a/docs/_pipeline/templates/dialogs-and-flyouts.md.dt b/docs/_pipeline/templates/dialogs-and-flyouts.md.dt index d04c7781d..66195e159 100644 --- a/docs/_pipeline/templates/dialogs-and-flyouts.md.dt +++ b/docs/_pipeline/templates/dialogs-and-flyouts.md.dt @@ -99,6 +99,19 @@ reconciles the dialog the same way it reconciles the rest of the tree: silent: `OnClosed` reports dismissals, not the owner going away, so a handler that writes back into already-unmounted state never runs. +### Dialog theme + +A dialog follows the effective theme (`ActualTheme`) at its declaration's +position, including a local ancestor override, even though WinUI displays the +dialog outside that subtree. Changes to the owner's effective theme also update +an already-open dialog; closing or unmounting detaches the listener. + +Use the existing `.RequestedTheme(ElementTheme.Dark)` modifier to override the +real dialog's theme explicitly. Changing that declaration updates the open dialog +in place; removing it or explicitly requesting `ElementTheme.Default` resumes +following the owner, consistent with theme inheritance elsewhere in Reactor. +Native `.Set(dialog => ...)` callbacks run last and can override the theme. + ### Three-button dialogs The three-button shape (`Primary` + `Secondary` + `Close`) is the diff --git a/docs/guide/dialogs-and-flyouts.md b/docs/guide/dialogs-and-flyouts.md index eb1da02e7..a060e9cd5 100644 --- a/docs/guide/dialogs-and-flyouts.md +++ b/docs/guide/dialogs-and-flyouts.md @@ -107,6 +107,19 @@ reconciles the dialog the same way it reconciles the rest of the tree: silent: `OnClosed` reports dismissals, not the owner going away, so a handler that writes back into already-unmounted state never runs. +### Dialog theme + +A dialog follows the effective theme (`ActualTheme`) at its declaration's +position, including a local ancestor override, even though WinUI displays the +dialog outside that subtree. Changes to the owner's effective theme also update +an already-open dialog; closing or unmounting detaches the listener. + +Use the existing `.RequestedTheme(ElementTheme.Dark)` modifier to override the +real dialog's theme explicitly. Changing that declaration updates the open dialog +in place; removing it or explicitly requesting `ElementTheme.Default` resumes +following the owner, consistent with theme inheritance elsewhere in Reactor. +Native `.Set(dialog => ...)` callbacks run last and can override the theme. + ### Three-button dialogs The three-button shape (`Primary` + `Secondary` + `Close`) is the diff --git a/src/Reactor/Core/Reconciler.cs b/src/Reactor/Core/Reconciler.cs index bf9bb2c20..bf8f43c36 100644 --- a/src/Reactor/Core/Reconciler.cs +++ b/src/Reactor/Core/Reconciler.cs @@ -820,14 +820,20 @@ private static bool ForwardsAnotherElement(UIElement control, Element element) /// placeholder's published owner= is the owner its body was declared under, so it is /// restored for the mount. Nested components still scope their own subtrees. /// - internal UIElement? MountDialogContent(FrameworkElement placeholder, Element content, Action requestRerender) - { - if (global::Microsoft.UI.Reactor.Hosting.ReactorFeatures.DevtoolsSupported && Diagnostics.ReactorSourcePublisher.IsEnabled - && CurrentDiagnosticOwner is null - && placeholder.GetValue(Diagnostics.ReactorDiagnostics.SourceProperty) is string published - && Diagnostics.ReactorSourcePublisher.Owner(published) is { } owner) - return MountUnderOwner(content, requestRerender, owner); - return Mount(content, requestRerender); + internal UIElement? MountDialogContent(FrameworkElement placeholder, Element content, Action requestRerender, ElementTheme theme) + { + var previousTheme = _ambientRequestedTheme; + _ambientRequestedTheme = theme; + try + { + if (global::Microsoft.UI.Reactor.Hosting.ReactorFeatures.DevtoolsSupported && Diagnostics.ReactorSourcePublisher.IsEnabled + && CurrentDiagnosticOwner is null + && placeholder.GetValue(Diagnostics.ReactorDiagnostics.SourceProperty) is string published + && Diagnostics.ReactorSourcePublisher.Owner(published) is { } owner) + return MountUnderOwner(content, requestRerender, owner); + return Mount(content, requestRerender); + } + finally { _ambientRequestedTheme = previousTheme; } } private UIElement? MountUnderOwner(Element element, Action requestRerender, string owner) diff --git a/src/Reactor/Core/V1Protocol/ContentDialogThemeBinding.cs b/src/Reactor/Core/V1Protocol/ContentDialogThemeBinding.cs new file mode 100644 index 000000000..8d2352523 --- /dev/null +++ b/src/Reactor/Core/V1Protocol/ContentDialogThemeBinding.cs @@ -0,0 +1,82 @@ +using Microsoft.UI.Xaml; +using WinUI = Microsoft.UI.Xaml.Controls; + +namespace Microsoft.UI.Reactor.Core.V1Protocol; + +/// +/// Bridges the declaration's theme to the side-mounted dialog. XamlRoot chooses +/// the window, but does not parent the dialog under its declaration's subtree. +/// +internal sealed class ContentDialogThemeBinding : IDisposable +{ + private readonly FrameworkElement _anchor; + private readonly WinUI.ContentDialog _dialog; + private ElementTheme? _requestedTheme; + private ElementTheme _appliedTheme; + private bool _setterOverride; + private bool _disposed; + + internal ContentDialogThemeBinding(FrameworkElement anchor, WinUI.ContentDialog dialog) + { + _anchor = anchor; + _dialog = dialog; + anchor.ActualThemeChanged += OnAnchorThemeChanged; + } + + internal void Apply(ContentDialogElement element) + { + var requested = element.Modifiers?.RequestedTheme; + _requestedTheme = requested is ElementTheme.Default ? null : requested; + _setterOverride = false; + ApplyTheme(_requestedTheme ?? _anchor.ActualTheme); + } + + // .Set is the escape hatch and runs last. Do not overwrite a theme it chose + // when an ancestor later changes; nor re-run arbitrary setters from an event. + internal void ApplySetters(ContentDialogElement element) + { + if (element.Setters.Length == 0) + { + _setterOverride = false; + ApplyTheme(_requestedTheme ?? _anchor.ActualTheme); + return; + } + + // A write of the same value is still an explicit override. Value-change + // notifications cannot distinguish that from a setter that never wrote. + _dialog.ClearValue(FrameworkElement.RequestedThemeProperty); + try + { + Reconciler.ApplySetters(element.Setters, _dialog); + } + finally + { + _setterOverride = _dialog.ReadLocalValue(FrameworkElement.RequestedThemeProperty) + != DependencyProperty.UnsetValue; + if (!_setterOverride) + ApplyTheme(_requestedTheme ?? _anchor.ActualTheme); + } + } + + private void OnAnchorThemeChanged(FrameworkElement sender, object args) + { + if (_disposed || _requestedTheme.HasValue || _setterOverride + || _dialog.RequestedTheme != _appliedTheme) + return; + ApplyTheme(_anchor.ActualTheme); + } + + private void ApplyTheme(ElementTheme theme) + { + _appliedTheme = theme; + if (_dialog.RequestedTheme != theme) + _dialog.RequestedTheme = theme; + } + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + _anchor.ActualThemeChanged -= OnAnchorThemeChanged; + } +} diff --git a/src/Reactor/Core/V1Protocol/OverlayLifecycle.cs b/src/Reactor/Core/V1Protocol/OverlayLifecycle.cs index fbc5b1a57..316814f9e 100644 --- a/src/Reactor/Core/V1Protocol/OverlayLifecycle.cs +++ b/src/Reactor/Core/V1Protocol/OverlayLifecycle.cs @@ -53,7 +53,9 @@ internal static class OverlayLifecycle // is poolable, so the placeholder can be recycled into an unrelated element // — ContentDialogHandler.Unmount takes the entry back out during the V1 // unmount dispatch, which runs before the control reaches the pool. - private static readonly global::System.Runtime.CompilerServices.ConditionalWeakTable s_liveDialogs = new(); + private sealed record LiveContentDialog(WinUI.ContentDialog Dialog, ContentDialogThemeBinding Theme); + + private static readonly global::System.Runtime.CompilerServices.ConditionalWeakTable s_liveDialogs = new(); public static UIElement MountContentDialog(Reconciler reconciler, ContentDialogElement cdEl, Action requestRerender) { @@ -77,20 +79,26 @@ public static UIElement MountContentDialog(Reconciler reconciler, ContentDialogE // No live dialog: never opened, still deferred waiting on XamlRoot, or // already dismissed. A rising edge above is the only way back. - if (!s_liveDialogs.TryGetValue(fe, out var dialog)) return null; + if (!s_liveDialogs.TryGetValue(fe, out var live)) return null; + var dialog = live.Dialog; - if (!n.IsOpen && o.IsOpen) + if (!n.IsOpen) { // Falling edge — honor the declared IsOpen. Hide() drives ShowAsync's // continuation, which unmounts the content and dispatches OnClosed, // exactly as a user dismissal does (and matching Popup, whose // IsOpen = false likewise raises Closed). - dialog.Hide(); + // Stop following as soon as closing starts, not only when ShowAsync + // completes after the close transition. Intervening renders must not + // re-sync a dialog that is already on its way out. + live.Theme.Dispose(); + if (o.IsOpen) dialog.Hide(); return null; } SyncContentDialogProps(dialog, n); TagDialogForDiagnostics(dialog, n); + live.Theme.Apply(n); // Reconcile the side-mounted content in place so transient state inside // the dialog (focus, scroll, caret) survives the owner's re-renders. @@ -106,7 +114,7 @@ public static UIElement MountContentDialog(Reconciler reconciler, ContentDialogE dialog.Content = reconciler.Mount(n.Content, requestRerender) as UIElement; } - Reconciler.ApplySetters(n.Setters, dialog); + live.Theme.ApplySetters(n); return null; } @@ -136,14 +144,15 @@ private static void SyncContentDialogProps(WinUI.ContentDialog dialog, ContentDi /// internal static WinUI.ContentDialog? TryTakeLiveContentDialog(FrameworkElement anchor) { - if (!s_liveDialogs.TryGetValue(anchor, out var dialog)) return null; + if (!s_liveDialogs.TryGetValue(anchor, out var live)) return null; s_liveDialogs.Remove(anchor); - return dialog; + live.Theme.Dispose(); + return live.Dialog; } /// The dialog currently showing for , without taking it. internal static WinUI.ContentDialog? PeekLiveContentDialog(FrameworkElement anchor) - => s_liveDialogs.TryGetValue(anchor, out var dialog) ? dialog : null; + => s_liveDialogs.TryGetValue(anchor, out var live) ? live.Dialog : null; private static void ShowContentDialog(Reconciler reconciler, ContentDialogElement cdEl, FrameworkElement anchor, Action requestRerender) { @@ -255,7 +264,6 @@ private static async void ShowContentDialogCore(Reconciler reconciler, ContentDi }; if (cdEl.SecondaryButtonText is not null) dialog.SecondaryButtonText = cdEl.SecondaryButtonText; if (cdEl.CloseButtonText is not null) dialog.CloseButtonText = cdEl.CloseButtonText; - dialog.Content = reconciler.MountDialogContent(anchor, cdEl.Content, requestRerender); if (xamlRoot is not null) dialog.XamlRoot = xamlRoot; TagDialogForDiagnostics(dialog, cdEl); // Resolve callbacks through the anchor's live Tag the way Flyout/Popup do, @@ -263,10 +271,10 @@ private static async void ShowContentDialogCore(Reconciler reconciler, ContentDi // while open so those closures go stale, and clearing the tag is how // unmount teardown suppresses an OnClosed it did not mean to raise. dialog.Opened += (_, _) => (Reconciler.GetElementTag(anchor) as ContentDialogElement)?.OnOpened?.Invoke(); - // ApplySetters last so caller .Set(...) wins (including overriding XamlRoot). - Reconciler.ApplySetters(cdEl.Setters, dialog); + var theme = new ContentDialogThemeBinding(anchor, dialog); + var liveDialog = new LiveContentDialog(dialog, theme); // Publish before showing so the very next render can reach the dialog. - s_liveDialogs.AddOrUpdate(anchor, dialog); + s_liveDialogs.AddOrUpdate(anchor, liveDialog); // The visible dialog describes the element too, not only its collapsed placeholder: an // out-of-process inspector sees the dialog. A deferred (Loaded) open finds the // placeholder already published; a mount-time open is covered by the reconciler, which @@ -282,6 +290,10 @@ private static async void ShowContentDialogCore(Reconciler reconciler, ContentDi WinUI.ContentDialogResult winUiResult; try { + theme.Apply(cdEl); + dialog.Content = reconciler.MountDialogContent(anchor, cdEl.Content, requestRerender, dialog.RequestedTheme); + // ApplySetters last so caller .Set(...) wins (including theme/XamlRoot). + theme.ApplySetters(cdEl); winUiResult = await dialog.ShowAsync(); } finally @@ -289,7 +301,8 @@ private static async void ShowContentDialogCore(Reconciler reconciler, ContentDi // In a finally so it also runs when ShowAsync throws — WinUI rejects // a second dialog on the same XamlRoot — which would otherwise strand // the tracking entry and leak the content subtree mounted above. - ownsClose = s_liveDialogs.TryGetValue(anchor, out var tracked) && ReferenceEquals(tracked, dialog); + theme.Dispose(); + ownsClose = s_liveDialogs.TryGetValue(anchor, out var tracked) && ReferenceEquals(tracked, liveDialog); if (ownsClose) s_liveDialogs.Remove(anchor); // A caller can keep the dialog through .Set(...); a closed dialog must not keep // resolving to (and keeping alive) its element, nor keep the published diff --git a/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ContentDialogThemeFixture.cs b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ContentDialogThemeFixture.cs new file mode 100644 index 000000000..c3a25dd44 --- /dev/null +++ b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ContentDialogThemeFixture.cs @@ -0,0 +1,254 @@ +using Microsoft.UI.Reactor.Core; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Automation; +using static Microsoft.UI.Reactor.Factories; +using WinUI = Microsoft.UI.Xaml.Controls; + +namespace Microsoft.UI.Reactor.AppTests.Host.SelfTest.Fixtures; + +internal sealed class ContentDialogThemeFixture(Harness h) : SelfTestFixtureBase(h) +{ + private const string Title = "Dialog theme probe"; + + public override async Task RunAsync() + { + var appTheme = Application.Current.RequestedTheme == ApplicationTheme.Dark + ? ElementTheme.Dark : ElementTheme.Light; + var opposite = appTheme == ElementTheme.Dark ? ElementTheme.Light : ElementTheme.Dark; + var state = new ScenarioState(opposite); + Action? setState = null; + var host = H.CreateHost(); + host.Mount(_ => Component(new( + state, setter => setState = setter))); + var mounted = await ContentDialogProbe.WaitForOpen(H, Title) + ?? throw new InvalidOperationException("Dialog did not open."); + await Harness.WaitFor(() => ContentDialogProbe.Walk(mounted, _ => true) is not null); + var editor = ContentDialogProbe.Walk(mounted, _ => true) + ?? throw new InvalidOperationException("Draft editor did not realize."); + editor.Text = "unsaved theme-switch draft"; + editor.Select(2, 4); + try + { + // The owner is opposite to the application, so accidentally using + // the app fallback cannot pass the default-inheritance assertion. + await CheckTheme("InheritsAtDeferredOpen", opposite); + + var themeChanges = 0; + var themeToken = mounted.RegisterPropertyChangedCallback( + FrameworkElement.RequestedThemeProperty, (_, _) => themeChanges++); + try + { + // Prove the observer catches real theme writes before using a + // zero count to detect unnecessary clear-and-restore work. + mounted.RequestedTheme = appTheme; + mounted.RequestedTheme = opposite; + H.Check("DialogTheme_ThemeWriteObserverPositiveControl", themeChanges == 2); + themeChanges = 0; + + state = state with { ButtonEnabled = false }; + setState!(state); + await Harness.Render(); + H.Check("DialogTheme_NoSetterRenderUpdatesDialog", !mounted.IsPrimaryButtonEnabled); + H.Check("DialogTheme_NoSetterRenderKeepsThemeStable", themeChanges == 0 + && mounted.RequestedTheme == opposite); + + state = state with { ButtonEnabled = true }; + setState(state); + await Harness.Render(); + H.Check("DialogTheme_RepeatedNoSetterRenderKeepsThemeStable", themeChanges == 0 + && mounted.IsPrimaryButtonEnabled && mounted.RequestedTheme == opposite); + } + finally + { + mounted.UnregisterPropertyChangedCallback(FrameworkElement.RequestedThemeProperty, themeToken); + } + + state = state with { ParentTheme = appTheme }; + setState!(state); + await Harness.Render(); + await CheckTheme("FollowsParentRender", appTheme); + + var owner = H.FindControl(panel => + AutomationProperties.GetName(panel) == "Dialog theme owner") + ?? throw new InvalidOperationException("Dialog owner is missing."); + // Establish a contradictory starting value even if the preceding + // parent-render check failed. Otherwise a missing subscription could + // leave the dialog at `opposite` and this next check would pass for + // precisely the wrong reason. + Current().RequestedTheme = appTheme; + if (!await Harness.WaitFor(() => Current().ActualTheme == appTheme)) + throw new InvalidOperationException("Native theme-change precondition could not be established."); + // No Reactor render: prove the ActualThemeChanged subscription, + // rather than merely testing that Update copies a theme property. + owner.RequestedTheme = opposite; + await CheckTheme("FollowsNativeAncestorChange", opposite); + + state = state with { DialogTheme = appTheme }; + setState!(state); + await Harness.Render(); + await CheckTheme("ExplicitThemeReachesRealDialog", appTheme); + owner.RequestedTheme = appTheme; + await Harness.Render(); + owner.RequestedTheme = opposite; + await CheckTheme("ExplicitThemeWinsOverAncestor", appTheme); + + state = state with { DialogTheme = opposite }; + setState!(state); + await Harness.Render(); + await CheckTheme("ExplicitThemeUpdatesWhileOpen", opposite); + + state = state with { DialogTheme = ElementTheme.Default }; + setState(state); + await Harness.Render(); + await CheckTheme("ExplicitDefaultFollowsOwner", opposite); + owner.RequestedTheme = appTheme; + await CheckTheme("ExplicitDefaultFollowsNativeChange", appTheme); + owner.RequestedTheme = opposite; + + state = state with { DialogTheme = null }; + setState!(state); + await Harness.Render(); + await CheckTheme("RemovingOverrideResumesFollowing", opposite); + owner.RequestedTheme = appTheme; + await CheckTheme("FollowingRemainsLiveAfterRemoval", appTheme); + + state = state with { NativeOverride = true }; + setState!(state); + await Harness.Render(); + await CheckTheme("NativeSetterWins", opposite); + owner.RequestedTheme = opposite; + await Harness.Render(); + owner.RequestedTheme = appTheme; + await CheckTheme("FollowingDoesNotOverwriteNativeSetter", opposite); + + // Setter equals the followed value. A value-comparison detector + // misses this assignment and later lets the owner overwrite it. + owner.RequestedTheme = opposite; + state = state with { ParentTheme = opposite, NativeOverride = true }; + setState!(state); + await Harness.Render(); + await CheckTheme("EqualNativeSetterWins", opposite); + // The owner's modifier is applied after its children update. Render + // once more with that owner already settled, changing only a dialog + // prop so the setter is observed against the same followed value. + state = state with { ButtonEnabled = false }; + setState!(state); + await Harness.Render(); + await CheckTheme("EqualNativeSetterAfterOwnerSettles", opposite); + owner.RequestedTheme = appTheme; + await Harness.WaitFor(() => owner.ActualTheme == appTheme); + await Harness.Render(); + await CheckTheme("EqualNativeSetterRemainsPinned", opposite); + + state = state with { NativeOverride = false, ParentTheme = appTheme, ButtonEnabled = true }; + setState!(state); + await Harness.Render(); + await CheckTheme("RemovingNativeSetterResumesFollowing", appTheme); + + WinUI.ContentDialogClosingDeferral? closingDeferral = null; + mounted.Closing += (_, args) => closingDeferral = args.GetDeferral(); + try + { + state = state with { IsOpen = false }; + setState!(state); + await Harness.Render(); + if (!await Harness.WaitFor(() => closingDeferral is not null)) + throw new InvalidOperationException("Dialog did not enter deferred closing."); + var closingTheme = mounted.RequestedTheme; + state = state with { ParentTheme = opposite, ButtonEnabled = false }; + setState(state); + await Harness.Render(); + owner.RequestedTheme = opposite; + await Harness.Render(); + H.Check("DialogTheme_ClosingStopsFollowing", mounted.RequestedTheme == closingTheme, + $"While closing: expected {closingTheme}; actual {mounted.RequestedTheme}"); + H.Check("DialogTheme_ClosingIgnoresPropUpdates", mounted.IsPrimaryButtonEnabled); + } + finally { closingDeferral?.Complete(); } + H.Check("DialogTheme_Closes", await ContentDialogProbe.WaitForClosed(H, Title)); + + state = state with { IsOpen = true }; + setState!(state); + await Harness.Render(); + mounted = await ContentDialogProbe.WaitForOpen(H, Title) + ?? throw new InvalidOperationException("Dialog did not reopen."); + await Harness.WaitFor(() => ContentDialogProbe.Walk(mounted, _ => true) is not null); + var reopenedEditor = ContentDialogProbe.Walk(mounted, _ => true) + ?? throw new InvalidOperationException("Reopened editor did not realize."); + // Pooling may legitimately reuse the old native TextBox. The user + // contract is a reset draft, not allocation of a different object. + H.Check("DialogTheme_ReopenResetsDraft", reopenedEditor.Text == "seed"); + editor = reopenedEditor; + editor.Text = "unsaved theme-switch draft"; + editor.Select(2, 4); + await CheckTheme("ReopenUsesCurrentOwnerTheme", opposite); + + state = state with { IncludeDialog = false }; + setState!(state); + await Harness.Render(); + H.Check("DialogTheme_UnmountCloses", await ContentDialogProbe.WaitForClosed(H, Title)); + } + finally + { + mounted.Hide(); + await ContentDialogProbe.WaitForClosed(H, Title); + } + + WinUI.ContentDialog Current() => ContentDialogProbe.FindOpen(H, Title) + ?? throw new InvalidOperationException("Dialog disappeared during theme update."); + + async Task CheckTheme(string check, ElementTheme expected) + { + await Harness.WaitFor(() => Current().ActualTheme == expected + && ContentDialogProbe.FindText(Current(), "Themed dialog content")?.ActualTheme == expected); + var current = Current(); + H.Check("DialogTheme_" + check, ReferenceEquals(current, mounted) + && current.RequestedTheme == expected && current.ActualTheme == expected + && ContentDialogProbe.FindText(current, "Themed dialog content")?.ActualTheme == expected, + $"Expected {expected}; requested {current.RequestedTheme}; actual {current.ActualTheme}"); + var liveEditor = ContentDialogProbe.Walk(current, _ => true); + H.Check("DialogTheme_" + check + "_PreservesDraft", ReferenceEquals(liveEditor, editor) + && liveEditor?.Text == "unsaved theme-switch draft" + && liveEditor.SelectionStart == 2 && liveEditor.SelectionLength == 4); + } + } + + private sealed record ScenarioState( + ElementTheme ParentTheme, + ElementTheme? DialogTheme = null, + bool IsOpen = true, + bool IncludeDialog = true, + bool NativeOverride = false, + bool ButtonEnabled = true); + + private sealed record ScenarioProps( + ScenarioState Initial, + Action> CaptureSetter); + + private sealed class Scenario : Component + { + public override Element Render() + { + var (state, setState) = UseState(Props.Initial); + Props.CaptureSetter(setState); + var dialog = ContentDialog(Title, + VStack(TextBlock("Themed dialog content"), TextBox("seed")), "OK") with + { + IsOpen = state.IsOpen, + IsPrimaryButtonEnabled = state.ButtonEnabled, + }; + if (state.DialogTheme is { } theme) dialog = dialog.RequestedTheme(theme); + if (state.NativeOverride) + { + var opposite = Application.Current.RequestedTheme == ApplicationTheme.Dark + ? ElementTheme.Light : ElementTheme.Dark; + dialog = dialog.Set(control => control.RequestedTheme = opposite); + } + return VStack( + TextBlock("Owner"), + state.IncludeDialog ? dialog : null) + .AutomationName("Dialog theme owner") + .RequestedTheme(state.ParentTheme); + } + } +} diff --git a/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ContentDialogThemeOpenMatrixFixture.cs b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ContentDialogThemeOpenMatrixFixture.cs new file mode 100644 index 000000000..949c1b858 --- /dev/null +++ b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ContentDialogThemeOpenMatrixFixture.cs @@ -0,0 +1,149 @@ +using Microsoft.UI.Reactor.Core; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Media; +using static Microsoft.UI.Reactor.Factories; +using WinUI = Microsoft.UI.Xaml.Controls; +using Color = global::Windows.UI.Color; + +namespace Microsoft.UI.Reactor.AppTests.Host.SelfTest.Fixtures; + +/// +/// Compare the rendered dialog to a conventional native WinUI dialog, not to a +/// second invocation of Reactor's theme resolver. Each case has a contradictory +/// outer theme, and both Light/Dark run regardless of the machine's app theme. +/// Separate fixtures keep a default-inheritance failure from hiding explicit +/// theme failures in a pre-fix/mutation run. +/// +internal sealed class ContentDialogThemeOpenMatrixFixture(Harness h, bool explicitTheme) : SelfTestFixtureBase(h) +{ + private const string Title = "Dialog theme open matrix"; + private const string ContentText = "Native theme comparison"; + + public override async Task RunAsync() + { + foreach (var requested in new[] { ElementTheme.Light, ElementTheme.Dark, ElementTheme.Default }) + { + // Default on an inherited owner just repeats the application fallback; + // test Default as an explicit request against an opposite local owner. + if (!explicitTheme && requested == ElementTheme.Default) continue; + var referenceTheme = requested == ElementTheme.Default + ? Application.Current.RequestedTheme == ApplicationTheme.Dark ? ElementTheme.Light : ElementTheme.Dark + : requested; + var reference = await ReadNativeReference(referenceTheme); + var opposite = reference.ActualTheme == ElementTheme.Dark ? ElementTheme.Light : ElementTheme.Dark; + foreach (var openAtMount in new[] { true, false }) + { + var caseName = $"{(explicitTheme ? "Explicit" : "Inherited")}_{requested}_{(openAtMount ? "Deferred" : "RisingEdge")}"; + var state = new ScenarioState(openAtMount); + Action? setState = null; + var host = H.CreateHost(); + host.Mount(_ => Component(new( + state, + // The nearest owner disagrees with the outer root, so using + // the window root instead of the declaration site is wrong. + explicitTheme && requested != ElementTheme.Default ? reference.ActualTheme : opposite, + explicitTheme && requested != ElementTheme.Default ? opposite : reference.ActualTheme, + explicitTheme ? requested : null, + setter => setState = setter))); + if (!openAtMount) + { + await Harness.Render(); + H.Check("DialogThemeMatrix_" + caseName + "_InitiallyClosed", + ContentDialogProbe.FindOpen(H, Title) is null); + setState!(state with { IsOpen = true }); + } + + var dialog = await ContentDialogProbe.WaitForOpen(H, Title) + ?? throw new InvalidOperationException($"{caseName}: dialog did not open."); + try + { + await Harness.WaitFor(() => ContentDialogProbe.FindText(dialog, ContentText)?.Foreground is SolidColorBrush); + // Re-find in the popup after it realizes. Reading only the + // element record or a control captured before opening cannot + // prove the real, visible dialog received the theme. + var current = ContentDialogProbe.FindOpen(H, Title)!; + var text = ContentDialogProbe.FindText(current, ContentText); + var expectedRequest = explicitTheme && requested != ElementTheme.Default ? requested : reference.ActualTheme; + H.Check("DialogThemeMatrix_" + caseName + "_RequestedTheme", + current.RequestedTheme == expectedRequest, + $"App={Application.Current.RequestedTheme}; expected={expectedRequest}; actual={current.RequestedTheme}"); + H.Check("DialogThemeMatrix_" + caseName + "_RenderedTheme", + current.ActualTheme == reference.ActualTheme && text?.ActualTheme == reference.ActualTheme, + $"Native={reference.ActualTheme}; dialog={current.ActualTheme}; content={text?.ActualTheme}"); + var foreground = (text?.Foreground as SolidColorBrush)?.Color; + H.Check("DialogThemeMatrix_" + caseName + "_NativeForeground", + foreground == reference.Foreground, + $"Native={reference.Foreground}; Reactor={foreground}"); + H.Check("DialogThemeMatrix_" + caseName + "_ContentThemeResource", + text?.Resources["DialogThemeProbeBrush"] is SolidColorBrush resourceBrush + && resourceBrush.Color == reference.Foreground, + $"ThemeRef must resolve against native reference theme {reference.ActualTheme} on first open."); + } + finally + { + dialog.Hide(); + await ContentDialogProbe.WaitForClosed(H, Title); + } + } + } + } + + private async Task ReadNativeReference(ElementTheme theme) + { + // Plain WinUI is the positive control: no Reactor modifiers or binding + // helper participate in computing the expected rendered theme/brush. + var native = new WinUI.ContentDialog + { + XamlRoot = H.Window.Content.XamlRoot, + Title = Title, + Content = new WinUI.TextBlock { Text = ContentText }, + PrimaryButtonText = "OK", + RequestedTheme = theme, + }; + var showing = native.ShowAsync(); + try + { + var opened = await ContentDialogProbe.WaitForOpen(H, Title) + ?? throw new InvalidOperationException("Native positive-control dialog did not open."); + await Harness.WaitFor(() => ContentDialogProbe.FindText(opened, ContentText)?.Foreground is SolidColorBrush); + var text = ContentDialogProbe.FindText(opened, ContentText) + ?? throw new InvalidOperationException("Native reference text did not realize."); + if (text.Foreground is not SolidColorBrush brush) + throw new InvalidOperationException("Native reference text has no measurable solid foreground."); + H.Check($"DialogThemeMatrix_Native_{theme}_PositiveControl", + ReferenceEquals(opened, native) && native.RequestedTheme == theme + && text.ActualTheme == native.ActualTheme + && (theme == ElementTheme.Default || native.ActualTheme == theme)); + return new(native.ActualTheme, brush.Color); + } + finally + { + native.Hide(); + await showing; + await ContentDialogProbe.WaitForClosed(H, Title); + } + } + + private sealed record NativeReference(ElementTheme ActualTheme, Color Foreground); + private sealed record ScenarioState(bool IsOpen); + private sealed record ScenarioProps( + ScenarioState Initial, + ElementTheme RootTheme, + ElementTheme OwnerTheme, + ElementTheme? DialogTheme, + Action> CaptureSetter); + + private sealed class Scenario : Component + { + public override Element Render() + { + var (state, setState) = UseState(Props.Initial); + Props.CaptureSetter(setState); + var dialog = ContentDialog(Title, TextBlock(ContentText) + .Resources(resources => resources.Set("DialogThemeProbeBrush", Theme.PrimaryText)), "OK") + with { IsOpen = state.IsOpen }; + if (Props.DialogTheme is { } theme) dialog = dialog.RequestedTheme(theme); + return VStack(VStack(dialog).RequestedTheme(Props.OwnerTheme)).RequestedTheme(Props.RootTheme); + } + } +} diff --git a/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ContentDialogThemeSecondaryWindowFixture.cs b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ContentDialogThemeSecondaryWindowFixture.cs new file mode 100644 index 000000000..cdef861f3 --- /dev/null +++ b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ContentDialogThemeSecondaryWindowFixture.cs @@ -0,0 +1,84 @@ +using Microsoft.UI.Reactor.Core; +using Microsoft.UI.Reactor.Hosting; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Media; +using static Microsoft.UI.Reactor.Factories; +using WinUI = Microsoft.UI.Xaml.Controls; + +namespace Microsoft.UI.Reactor.AppTests.Host.SelfTest.Fixtures; + +internal sealed class ContentDialogThemeSecondaryWindowFixture(Harness h) : SelfTestFixtureBase(h) +{ + private const string Title = "Secondary dialog theme"; + + public override async Task RunAsync() + { + var appTheme = Application.Current.RequestedTheme == ApplicationTheme.Dark + ? ElementTheme.Dark : ElementTheme.Light; + var opposite = appTheme == ElementTheme.Dark ? ElementTheme.Light : ElementTheme.Dark; + var primaryHost = H.CreateHost(); + primaryHost.Mount(_ => VStack(TextBlock("Primary window")).RequestedTheme(appTheme)); + var harnessRoot = (FrameworkElement)H.Window.Content; + var originalTheme = harnessRoot.ReadLocalValue(FrameworkElement.RequestedThemeProperty); + var secondary = new Window { Title = "Secondary theme owner" }; + var host = new ReactorHost(secondary); + try + { + host.Mount(_ => VStack(ContentDialog(Title, TextBlock("Secondary content"), "OK") + with { IsOpen = true }).RequestedTheme(opposite)); + secondary.Activate(); + // A newly allocated XamlRoot may exist before its visual tree is + // connected. GetOpenPopupsForXamlRoot rejects that intermediate root. + if (!await Harness.WaitFor(() => secondary.Content is FrameworkElement { IsLoaded: true } + && secondary.Content.XamlRoot?.IsHostVisible == true)) + throw new InvalidOperationException("Secondary window did not finish loading."); + await Harness.WaitFor(() => Find(secondary.Content.XamlRoot) is not null); + var dialog = Find(secondary.Content.XamlRoot) + ?? throw new InvalidOperationException("Secondary dialog did not open in its own XamlRoot."); + await Harness.WaitFor(() => ContentDialogProbe.FindText(dialog, "Secondary content") is not null); + H.Check("DialogThemeSecondary_UsesOwningWindow", dialog.XamlRoot == secondary.Content.XamlRoot + && dialog.XamlRoot != H.Window.Content.XamlRoot + && ContentDialogProbe.FindOpen(H, Title) is null); + CheckTheme("Initial", opposite, dialog); + ((FrameworkElement)secondary.Content).RequestedTheme = appTheme; + await Harness.WaitFor(() => Find(secondary.Content.XamlRoot)?.ActualTheme == appTheme); + CheckTheme("FollowsOnlyItsOwnOwner", appTheme, dialog); + + harnessRoot.RequestedTheme = opposite; + await Harness.Render(); + CheckTheme("OtherWindowDoesNotOverride", appTheme, dialog); + } + finally + { + host.Dispose(); + secondary.Close(); + // The harness root outlives this fixture; restore its own declaration. + if (ReferenceEquals(originalTheme, DependencyProperty.UnsetValue)) + harnessRoot.ClearValue(FrameworkElement.RequestedThemeProperty); + else + harnessRoot.SetValue(FrameworkElement.RequestedThemeProperty, originalTheme); + await ContentDialogProbe.WaitForNoneOpen(H); + } + + void CheckTheme(string name, ElementTheme expected, WinUI.ContentDialog original) + { + var current = Find(secondary.Content.XamlRoot); + H.Check("DialogThemeSecondary_" + name, ReferenceEquals(current, original) + && current?.RequestedTheme == expected && current.ActualTheme == expected + && ContentDialogProbe.FindText(current, "Secondary content")?.ActualTheme == expected, + $"App={appTheme}; expected={expected}; actual={current?.ActualTheme}"); + } + } + + private static WinUI.ContentDialog? Find(XamlRoot? root) + { + if (root is null) return null; + foreach (var popup in VisualTreeHelper.GetOpenPopupsForXamlRoot(root)) + { + if (popup.Child is not null && ContentDialogProbe.Walk( + popup.Child, dialog => dialog.Title as string == Title) is { } dialog) + return dialog; + } + return null; + } +} diff --git a/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ReactorSourceFixture.cs b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ReactorSourceFixture.cs index 3be8122bd..8fca76c12 100644 --- a/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ReactorSourceFixture.cs +++ b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ReactorSourceFixture.cs @@ -1423,13 +1423,17 @@ public override async Task RunAsync() var openValue = kept is null ? null : ReactorDiagnostics.GetSource(kept); var openSite = kept is null ? null : Microsoft.UI.Reactor.Diagnostics.ReactorSourceMap.GetSource(kept); shownDialog?.Hide(); - await Harness.Render(50); - await Harness.Render(50); + // Hide starts an asynchronous close transition. Its ShowAsync + // continuation owns attribution cleanup, so two fixed frame + // delays can observe a dialog that has not finished closing. + var attributionCleared = await Harness.WaitFor(() => + kept is not null && ReactorDiagnostics.GetSource(kept) is null); var closedValue = kept is null ? null : ReactorDiagnostics.GetSource(kept); var closedSite = kept is null ? null : Microsoft.UI.Reactor.Diagnostics.ReactorSourceMap.GetSource(kept); Console.WriteLine($"# kept dialog ({title}): open {openValue} @ {openSite}; closed {closedValue} @ {closedSite}"); H.Check($"ReactorSource_LiveDialog_{title}_ClosedDialogStopsDescribingItsElement", openValue?.Contains("|element=ContentDialog", StringComparison.Ordinal) == true + && attributionCleared && closedValue is null && (openSite is null || closedSite is null)); keptHost.Dispose(); @@ -1758,4 +1762,4 @@ public override async Task RunAsync() ReactorSourcePublisher.NoManagedAgent = noAgent; } } -} \ No newline at end of file +} diff --git a/tests/Reactor.AppTests.Host/SelfTest/SelfTestFixtureRegistry.cs b/tests/Reactor.AppTests.Host/SelfTest/SelfTestFixtureRegistry.cs index 3cd8f9dd9..849ad80b5 100644 --- a/tests/Reactor.AppTests.Host/SelfTest/SelfTestFixtureRegistry.cs +++ b/tests/Reactor.AppTests.Host/SelfTest/SelfTestFixtureRegistry.cs @@ -368,6 +368,10 @@ internal static class SelfTestFixtureRegistry "ContentDialogLive_RoutesClosedToLatestRender", "ContentDialogLive_ReopensAfterClose", "Diagnostics_ContentDialogTag", + "ContentDialogTheme_Lifecycle", + "ContentDialogTheme_ExplicitOpenMatrix", + "ContentDialogTheme_InheritedOpenMatrix", + "ContentDialogTheme_SecondaryWindow", // Flyout placement guard — Reactor must never write FlyoutPlacementMode.Auto // onto a WinUI FlyoutBase, because the show-time validator rejects it and // fail-fasts the process. The Flyout/ContentFlyout/fresh-create fixtures @@ -2503,6 +2507,10 @@ internal static string[] StaleTierDeclarations() => "ContentDialogLive_RoutesClosedToLatestRender" => new ContentDialogLiveContentFixtures.ContentDialog_RoutesClosedToLatestRender(harness), "ContentDialogLive_ReopensAfterClose" => new ContentDialogLiveContentFixtures.ContentDialog_ReopensAfterClose(harness), "Diagnostics_ContentDialogTag" => new ContentDialogTagFixtures.DialogIsTagged(harness), + "ContentDialogTheme_Lifecycle" => new ContentDialogThemeFixture(harness), + "ContentDialogTheme_ExplicitOpenMatrix" => new ContentDialogThemeOpenMatrixFixture(harness, explicitTheme: true), + "ContentDialogTheme_InheritedOpenMatrix" => new ContentDialogThemeOpenMatrixFixture(harness, explicitTheme: false), + "ContentDialogTheme_SecondaryWindow" => new ContentDialogThemeSecondaryWindowFixture(harness), // Flyout placement guard — Auto must never reach FlyoutBase.Placement. "FlyoutPlacement_Platform_FlyoutBase_PlacementDefault" => new FlyoutPlacementFixtures.Platform_FlyoutBase_PlacementDefault(harness), "FlyoutPlacement_Flyout_DefaultPlacement_Opens" => new FlyoutPlacementFixtures.Flyout_DefaultPlacement_Opens(harness),