From d57a53fcea345eab846df0dabcdc84dd2ed00253 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?kusutori=E3=82=AF=E3=82=B9=E3=81=A8=E3=82=8A?= <100983934+kusutori@users.noreply.github.com> Date: Sat, 17 Oct 2026 21:23:30 +0800 Subject: [PATCH 1/2] Fix ContentDialog theme forwarding and inheritance --- .../templates/dialogs-and-flyouts.md.dt | 13 ++ docs/guide/dialogs-and-flyouts.md | 13 ++ .../V1Protocol/ContentDialogThemeBinding.cs | 58 ++++++ .../Core/V1Protocol/OverlayLifecycle.cs | 35 +++- .../Fixtures/ContentDialogThemeFixture.cs | 186 ++++++++++++++++++ .../ContentDialogThemeOpenMatrixFixture.cs | 140 +++++++++++++ ...ontentDialogThemeSecondaryWindowFixture.cs | 84 ++++++++ .../SelfTest/SelfTestFixtureRegistry.cs | 8 + 8 files changed, 527 insertions(+), 10 deletions(-) create mode 100644 src/Reactor/Core/V1Protocol/ContentDialogThemeBinding.cs create mode 100644 tests/Reactor.AppTests.Host/SelfTest/Fixtures/ContentDialogThemeFixture.cs create mode 100644 tests/Reactor.AppTests.Host/SelfTest/Fixtures/ContentDialogThemeOpenMatrixFixture.cs create mode 100644 tests/Reactor.AppTests.Host/SelfTest/Fixtures/ContentDialogThemeSecondaryWindowFixture.cs diff --git a/docs/_pipeline/templates/dialogs-and-flyouts.md.dt b/docs/_pipeline/templates/dialogs-and-flyouts.md.dt index d04c7781d..a5735f42f 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 resumes following the owner. An explicit +`ElementTheme.Default` is still a native theme request, not an absent override. +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..cd981bd5a 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 resumes following the owner. An explicit +`ElementTheme.Default` is still a native theme request, not an absent override. +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/V1Protocol/ContentDialogThemeBinding.cs b/src/Reactor/Core/V1Protocol/ContentDialogThemeBinding.cs new file mode 100644 index 000000000..087bfa39f --- /dev/null +++ b/src/Reactor/Core/V1Protocol/ContentDialogThemeBinding.cs @@ -0,0 +1,58 @@ +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) + { + _requestedTheme = element.Modifiers?.RequestedTheme; + _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 ObserveSetters() => _setterOverride = _dialog.RequestedTheme != _appliedTheme; + + 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 a1cfb53da..7797b330f 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. @@ -107,6 +115,7 @@ public static UIElement MountContentDialog(Reconciler reconciler, ContentDialogE } Reconciler.ApplySetters(n.Setters, dialog); + live.Theme.ObserveSetters(); return null; } @@ -136,9 +145,10 @@ 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; } private static void ShowContentDialog(Reconciler reconciler, ContentDialogElement cdEl, FrameworkElement anchor, Action requestRerender) @@ -259,10 +269,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); // True when this call still owns the placeholder's tracking entry at // close time. False once unmount teardown has taken it, or once a // re-open has installed a newer dialog over it. @@ -270,6 +280,10 @@ private static async void ShowContentDialogCore(Reconciler reconciler, ContentDi WinUI.ContentDialogResult winUiResult; try { + theme.Apply(cdEl); + // ApplySetters last so caller .Set(...) wins (including theme/XamlRoot). + Reconciler.ApplySetters(cdEl.Setters, dialog); + theme.ObserveSetters(); winUiResult = await dialog.ShowAsync(); } finally @@ -277,7 +291,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. No-op when it was never tagged. 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..3dc772ccd --- /dev/null +++ b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ContentDialogThemeFixture.cs @@ -0,0 +1,186 @@ +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; + FrameworkElement? anchor = null; + var host = H.CreateHost(); + host.Mount(_ => Component(new( + state, setter => setState = setter, element => anchor = element))); + 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); + + state = state with { ParentTheme = appTheme }; + setState!(state); + 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 CheckTheme("ExplicitThemeReachesRealDialog", appTheme); + owner.RequestedTheme = appTheme; + await Harness.Render(); + owner.RequestedTheme = opposite; + await CheckTheme("ExplicitThemeWinsOverAncestor", appTheme); + + state = state with { DialogTheme = opposite }; + setState(state); + await CheckTheme("ExplicitThemeUpdatesWhileOpen", opposite); + + state = state with { DialogTheme = ElementTheme.Default }; + setState(state); + await Harness.Render(); + H.Check("DialogTheme_ExplicitDefaultIsNotUnset", + ReferenceEquals(Current(), mounted) && Current().RequestedTheme == ElementTheme.Default); + + state = state with { DialogTheme = null }; + setState(state); + await CheckTheme("RemovingOverrideResumesFollowing", opposite); + owner.RequestedTheme = appTheme; + await CheckTheme("FollowingRemainsLiveAfterRemoval", appTheme); + + state = state with { NativeOverride = true }; + setState(state); + await CheckTheme("NativeSetterWins", opposite); + owner.RequestedTheme = opposite; + await Harness.Render(); + owner.RequestedTheme = appTheme; + await CheckTheme("FollowingDoesNotOverwriteNativeSetter", opposite); + + state = state with { NativeOverride = false }; + setState(state); + await CheckTheme("RemovingNativeSetterResumesFollowing", appTheme); + + state = state with { IsOpen = false }; + setState(state); + H.Check("DialogTheme_Closes", await ContentDialogProbe.WaitForClosed(H, Title)); + var closedTheme = mounted.RequestedTheme; + owner.RequestedTheme = opposite; + await Harness.Render(); + H.Check("DialogTheme_CloseStopsFollowing", mounted.RequestedTheme == closedTheme, + $"Before ancestor change: {closedTheme}; after: {mounted.RequestedTheme}"); + + state = state with { IsOpen = true }; + setState(state); + 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); + H.Check("DialogTheme_UnmountCloses", await ContentDialogProbe.WaitForClosed(H, Title)); + var unmountedTheme = mounted.RequestedTheme; + anchor!.RequestedTheme = appTheme; + await Harness.Render(); + H.Check("DialogTheme_UnmountStopsFollowing", mounted.RequestedTheme == unmountedTheme); + } + 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); + + private sealed record ScenarioProps( + ScenarioState Initial, + Action> CaptureSetter, + Action CaptureAnchor); + + 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, + }; + 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.OnMountAdd(Props.CaptureAnchor) : 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..9c1ab434c --- /dev/null +++ b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ContentDialogThemeOpenMatrixFixture.cs @@ -0,0 +1,140 @@ +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 reference = await ReadNativeReference(requested); + 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 ? reference.ActualTheme : opposite, + explicitTheme ? 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 : 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}"); + } + 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), "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/SelfTestFixtureRegistry.cs b/tests/Reactor.AppTests.Host/SelfTest/SelfTestFixtureRegistry.cs index bcff429bf..59f4226d5 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 @@ -2450,6 +2454,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), From 9a2874cb3b3152e0f033f82b209e3b2b43d34605 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?kusutori=E3=82=AF=E3=82=B9=E3=81=A8=E3=82=8A?= <100983934+kusutori@users.noreply.github.com> Date: Fri, 23 Oct 2026 19:42:14 +0800 Subject: [PATCH 2/2] Fix dialog setter ownership, inherited Default and initial content theme --- .../templates/dialogs-and-flyouts.md.dt | 4 +- docs/guide/dialogs-and-flyouts.md | 4 +- src/Reactor/Core/Reconciler.cs | 22 +++-- .../V1Protocol/ContentDialogThemeBinding.cs | 21 ++++- .../Core/V1Protocol/OverlayLifecycle.cs | 8 +- .../Fixtures/ContentDialogThemeFixture.cs | 92 +++++++++++++------ .../ContentDialogThemeOpenMatrixFixture.cs | 19 +++- 7 files changed, 119 insertions(+), 51 deletions(-) diff --git a/docs/_pipeline/templates/dialogs-and-flyouts.md.dt b/docs/_pipeline/templates/dialogs-and-flyouts.md.dt index a5735f42f..66195e159 100644 --- a/docs/_pipeline/templates/dialogs-and-flyouts.md.dt +++ b/docs/_pipeline/templates/dialogs-and-flyouts.md.dt @@ -108,8 +108,8 @@ 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 resumes following the owner. An explicit -`ElementTheme.Default` is still a native theme request, not an absent override. +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 diff --git a/docs/guide/dialogs-and-flyouts.md b/docs/guide/dialogs-and-flyouts.md index cd981bd5a..a060e9cd5 100644 --- a/docs/guide/dialogs-and-flyouts.md +++ b/docs/guide/dialogs-and-flyouts.md @@ -116,8 +116,8 @@ 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 resumes following the owner. An explicit -`ElementTheme.Default` is still a native theme request, not an absent override. +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 diff --git a/src/Reactor/Core/Reconciler.cs b/src/Reactor/Core/Reconciler.cs index b54518ee1..0c0f53acf 100644 --- a/src/Reactor/Core/Reconciler.cs +++ b/src/Reactor/Core/Reconciler.cs @@ -784,14 +784,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 index 087bfa39f..79208443a 100644 --- a/src/Reactor/Core/V1Protocol/ContentDialogThemeBinding.cs +++ b/src/Reactor/Core/V1Protocol/ContentDialogThemeBinding.cs @@ -25,14 +25,31 @@ internal ContentDialogThemeBinding(FrameworkElement anchor, WinUI.ContentDialog internal void Apply(ContentDialogElement element) { - _requestedTheme = element.Modifiers?.RequestedTheme; + 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 ObserveSetters() => _setterOverride = _dialog.RequestedTheme != _appliedTheme; + internal void ApplySetters(ContentDialogElement element) + { + // 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) { diff --git a/src/Reactor/Core/V1Protocol/OverlayLifecycle.cs b/src/Reactor/Core/V1Protocol/OverlayLifecycle.cs index b5e75b9ff..316814f9e 100644 --- a/src/Reactor/Core/V1Protocol/OverlayLifecycle.cs +++ b/src/Reactor/Core/V1Protocol/OverlayLifecycle.cs @@ -114,8 +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.ObserveSetters(); + live.Theme.ApplySetters(n); return null; } @@ -265,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, @@ -293,9 +291,9 @@ private static async void ShowContentDialogCore(Reconciler reconciler, ContentDi try { theme.Apply(cdEl); + dialog.Content = reconciler.MountDialogContent(anchor, cdEl.Content, requestRerender, dialog.RequestedTheme); // ApplySetters last so caller .Set(...) wins (including theme/XamlRoot). - Reconciler.ApplySetters(cdEl.Setters, dialog); - theme.ObserveSetters(); + theme.ApplySetters(cdEl); winUiResult = await dialog.ShowAsync(); } finally diff --git a/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ContentDialogThemeFixture.cs b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ContentDialogThemeFixture.cs index 3dc772ccd..31e4555b8 100644 --- a/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ContentDialogThemeFixture.cs +++ b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ContentDialogThemeFixture.cs @@ -17,10 +17,9 @@ public override async Task RunAsync() var opposite = appTheme == ElementTheme.Dark ? ElementTheme.Light : ElementTheme.Dark; var state = new ScenarioState(opposite); Action? setState = null; - FrameworkElement? anchor = null; var host = H.CreateHost(); host.Mount(_ => Component(new( - state, setter => setState = setter, element => anchor = element))); + 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); @@ -36,6 +35,7 @@ public override async Task RunAsync() state = state with { ParentTheme = appTheme }; setState!(state); + await Harness.Render(); await CheckTheme("FollowsParentRender", appTheme); var owner = H.FindControl(panel => @@ -54,7 +54,8 @@ public override async Task RunAsync() await CheckTheme("FollowsNativeAncestorChange", opposite); state = state with { DialogTheme = appTheme }; - setState(state); + setState!(state); + await Harness.Render(); await CheckTheme("ExplicitThemeReachesRealDialog", appTheme); owner.RequestedTheme = appTheme; await Harness.Render(); @@ -62,44 +63,83 @@ public override async Task RunAsync() await CheckTheme("ExplicitThemeWinsOverAncestor", appTheme); state = state with { DialogTheme = opposite }; - setState(state); + setState!(state); + await Harness.Render(); await CheckTheme("ExplicitThemeUpdatesWhileOpen", opposite); state = state with { DialogTheme = ElementTheme.Default }; setState(state); await Harness.Render(); - H.Check("DialogTheme_ExplicitDefaultIsNotUnset", - ReferenceEquals(Current(), mounted) && Current().RequestedTheme == ElementTheme.Default); + await CheckTheme("ExplicitDefaultFollowsOwner", opposite); + owner.RequestedTheme = appTheme; + await CheckTheme("ExplicitDefaultFollowsNativeChange", appTheme); + owner.RequestedTheme = opposite; state = state with { DialogTheme = null }; - setState(state); + setState!(state); + await Harness.Render(); await CheckTheme("RemovingOverrideResumesFollowing", opposite); owner.RequestedTheme = appTheme; await CheckTheme("FollowingRemainsLiveAfterRemoval", appTheme); state = state with { NativeOverride = true }; - setState(state); + setState!(state); + await Harness.Render(); await CheckTheme("NativeSetterWins", opposite); owner.RequestedTheme = opposite; await Harness.Render(); owner.RequestedTheme = appTheme; await CheckTheme("FollowingDoesNotOverwriteNativeSetter", opposite); - state = state with { NativeOverride = false }; - setState(state); + // 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); - state = state with { IsOpen = false }; - setState(state); + 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)); - var closedTheme = mounted.RequestedTheme; - owner.RequestedTheme = opposite; - await Harness.Render(); - H.Check("DialogTheme_CloseStopsFollowing", mounted.RequestedTheme == closedTheme, - $"Before ancestor change: {closedTheme}; after: {mounted.RequestedTheme}"); state = state with { IsOpen = true }; - setState(state); + 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); @@ -114,12 +154,9 @@ public override async Task RunAsync() await CheckTheme("ReopenUsesCurrentOwnerTheme", opposite); state = state with { IncludeDialog = false }; - setState(state); - H.Check("DialogTheme_UnmountCloses", await ContentDialogProbe.WaitForClosed(H, Title)); - var unmountedTheme = mounted.RequestedTheme; - anchor!.RequestedTheme = appTheme; + setState!(state); await Harness.Render(); - H.Check("DialogTheme_UnmountStopsFollowing", mounted.RequestedTheme == unmountedTheme); + H.Check("DialogTheme_UnmountCloses", await ContentDialogProbe.WaitForClosed(H, Title)); } finally { @@ -151,12 +188,12 @@ private sealed record ScenarioState( ElementTheme? DialogTheme = null, bool IsOpen = true, bool IncludeDialog = true, - bool NativeOverride = false); + bool NativeOverride = false, + bool ButtonEnabled = true); private sealed record ScenarioProps( ScenarioState Initial, - Action> CaptureSetter, - Action CaptureAnchor); + Action> CaptureSetter); private sealed class Scenario : Component { @@ -168,6 +205,7 @@ public override Element Render() 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) @@ -178,7 +216,7 @@ public override Element Render() } return VStack( TextBlock("Owner"), - state.IncludeDialog ? dialog.OnMountAdd(Props.CaptureAnchor) : null) + 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 index 9c1ab434c..949c1b858 100644 --- a/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ContentDialogThemeOpenMatrixFixture.cs +++ b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ContentDialogThemeOpenMatrixFixture.cs @@ -26,7 +26,10 @@ public override async Task RunAsync() // 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 reference = await ReadNativeReference(requested); + 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 }) { @@ -38,8 +41,8 @@ public override async Task RunAsync() state, // The nearest owner disagrees with the outer root, so using // the window root instead of the declaration site is wrong. - explicitTheme ? reference.ActualTheme : opposite, - explicitTheme ? opposite : reference.ActualTheme, + explicitTheme && requested != ElementTheme.Default ? reference.ActualTheme : opposite, + explicitTheme && requested != ElementTheme.Default ? opposite : reference.ActualTheme, explicitTheme ? requested : null, setter => setState = setter))); if (!openAtMount) @@ -60,7 +63,7 @@ public override async Task RunAsync() // prove the real, visible dialog received the theme. var current = ContentDialogProbe.FindOpen(H, Title)!; var text = ContentDialogProbe.FindText(current, ContentText); - var expectedRequest = explicitTheme ? requested : reference.ActualTheme; + 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}"); @@ -71,6 +74,10 @@ public override async Task RunAsync() 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 { @@ -132,7 +139,9 @@ public override Element Render() { var (state, setState) = UseState(Props.Initial); Props.CaptureSetter(setState); - var dialog = ContentDialog(Title, TextBlock(ContentText), "OK") with { IsOpen = state.IsOpen }; + 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); }