From 52fa03fae3268c8f8d2b3f4ae9474065d2be1501 Mon Sep 17 00:00:00 2001 From: WantToBeeMe | Dirk <93130991+DirkDoes@users.noreply.github.com> Date: Wed, 14 Oct 2026 19:49:05 +0200 Subject: [PATCH 1/2] feat: render dialogs as in-app modals --- .../Shell/Startup/DesktopStartupTests.cs | 39 +- .../ApplicationCompositionTests.cs | 12 +- WheelWizard.UI.Test/InAppModalTests.cs | 648 ++++++++++++++++++ WheelWizard.UI.Test/WindowCompositionTests.cs | 77 ++- .../Presentation/UpdatePresentation.cs | 3 + .../Presentation/ModOperationPresentation.cs | 3 + .../Views/Dialogs/ModBrowserWindow.axaml.cs | 2 +- .../Mods/Views/Dialogs/ModContent.axaml.cs | 9 +- .../Dialogs/ModIndependentWindow.axaml.cs | 6 +- .../Features/Mods/Views/ModsPage.axaml.cs | 1 + .../Dialogs/AddFriendConfirmationWindow.axaml | 63 +- .../AddFriendConfirmationWindow.axaml.cs | 1 + .../Views/Dialogs/PlayerProfileWindow.axaml | 13 +- .../Dialogs/PlayerProfileWindow.axaml.cs | 5 +- .../Views/UserProfilePage.axaml.cs | 3 +- .../Views/Dialogs/MiiSelectorWindow.axaml | 17 +- .../Views/Dialogs/MiiSelectorWindow.axaml.cs | 20 +- WheelWizard/Views/Components/ModalHeading.cs | 72 ++ .../Views/DesignTime/DevToolWindow.axaml | 19 +- .../Views/DesignTime/DevToolWindow.axaml.cs | 1 + .../DesignTime/MiiAnimatorDevWindow.axaml | 9 +- .../DesignTime/MiiAnimatorDevWindow.axaml.cs | 1 + WheelWizard/Views/Dialogs/Base/ModalHost.cs | 256 +++++++ .../Views/Dialogs/Base/PopupContent.cs | 231 ++++++- .../Views/Dialogs/Base/PopupWindow.axaml.cs | 1 + .../Views/Dialogs/MessageBoxWindow.axaml | 31 +- .../Views/Dialogs/MessageBoxWindow.axaml.cs | 5 +- .../Dialogs/MiiRenderingSetupPopup.axaml | 101 +-- WheelWizard/Views/Dialogs/OptionsWindow.axaml | 12 +- .../Views/Dialogs/OptionsWindow.axaml.cs | 17 +- .../Views/Dialogs/ProgressWindow.axaml | 43 +- .../Views/Dialogs/ProgressWindow.axaml.cs | 17 +- .../Views/Dialogs/TextInputWindow.axaml | 49 +- .../Views/Dialogs/TextInputWindow.axaml.cs | 19 +- WheelWizard/Views/Dialogs/YesNoWindow.axaml | 25 +- .../Views/Dialogs/YesNoWindow.axaml.cs | 1 + WheelWizard/Views/Shell/BaseWindow.cs | 84 ++- WheelWizard/Views/Shell/Layout.axaml.cs | 25 +- .../Views/Shell/Startup/DesktopStartup.cs | 2 +- 39 files changed, 1661 insertions(+), 282 deletions(-) create mode 100644 WheelWizard.UI.Test/InAppModalTests.cs create mode 100644 WheelWizard/Views/Components/ModalHeading.cs create mode 100644 WheelWizard/Views/Dialogs/Base/ModalHost.cs diff --git a/WheelWizard.Test/Views/Shell/Startup/DesktopStartupTests.cs b/WheelWizard.Test/Views/Shell/Startup/DesktopStartupTests.cs index b046a691..290b97a2 100644 --- a/WheelWizard.Test/Views/Shell/Startup/DesktopStartupTests.cs +++ b/WheelWizard.Test/Views/Shell/Startup/DesktopStartupTests.cs @@ -40,7 +40,7 @@ public async Task InstalledResources_ShowTheWindowBeforeStartupActions() } [Fact] - public async Task DeclinedResourceSetup_ShutsDownWithoutOpeningMainWindow() + public async Task DeclinedResourceSetup_ShutsDownTheOpenedMainWindow() { var fixture = new Fixture(); fixture.Resources.GetResolvedResourcePath().Returns(new OperationError { Message = "Missing" }); @@ -49,12 +49,12 @@ public async Task DeclinedResourceSetup_ShutsDownWithoutOpeningMainWindow() await fixture.Coordinator.StartAsync(fixture.Desktop, new StartupOptions(null, false), CancellationToken.None); fixture.Desktop.Received(1).Shutdown(); - await fixture.Windows.DidNotReceiveWithAnyArgs().ShowAsync(default!, default); + await fixture.Windows.Received(1).ShowAsync(fixture.Desktop, Arg.Any()); await fixture.Startup.DidNotReceiveWithAnyArgs().RunAsync(default!, default); } [Fact] - public async Task ShutdownDuringResourceSetup_DoesNotOpenAWindowAfterCompletion() + public async Task ShutdownDuringResourceSetup_DoesNotRunStartupAfterCompletion() { var fixture = new Fixture(); fixture.Resources.GetResolvedResourcePath().Returns(new OperationError { Message = "Missing" }); @@ -66,10 +66,41 @@ public async Task ShutdownDuringResourceSetup_DoesNotOpenAWindowAfterCompletion( await start; setup.SetResult(true); - await fixture.Windows.DidNotReceiveWithAnyArgs().ShowAsync(default!, default); + await fixture.Windows.Received(1).ShowAsync(fixture.Desktop, Arg.Any()); await fixture.Startup.DidNotReceiveWithAnyArgs().RunAsync(default!, default); } + [Fact] + public async Task MissingResources_ShowSetupInsideTheOpenedWindowBeforeStartup() + { + var fixture = new Fixture(); + fixture.Resources.GetResolvedResourcePath().Returns(new OperationError { Message = "Missing" }); + var sequence = new List(); + fixture + .Windows.ShowAsync(fixture.Desktop, Arg.Any()) + .Returns(_ => + { + sequence.Add("window"); + return Task.CompletedTask; + }); + fixture + .Setup.ShowAsync() + .Returns(_ => + { + sequence.Add("setup"); + return Task.FromResult(true); + }); + fixture + .Startup.RunAsync(Arg.Any(), Arg.Any()) + .Returns(_ => + { + sequence.Add("startup"); + return Task.CompletedTask; + }); + await fixture.Coordinator.StartAsync(fixture.Desktop, new StartupOptions(null, false), CancellationToken.None); + Assert.Equal(["window", "setup", "startup"], sequence); + } + private sealed class Fixture { public IClassicDesktopStyleApplicationLifetime Desktop { get; } = Substitute.For(); diff --git a/WheelWizard.UI.Test/ApplicationCompositionTests.cs b/WheelWizard.UI.Test/ApplicationCompositionTests.cs index 0a7da488..2c046589 100644 --- a/WheelWizard.UI.Test/ApplicationCompositionTests.cs +++ b/WheelWizard.UI.Test/ApplicationCompositionTests.cs @@ -88,8 +88,6 @@ public async Task MainWindowAndPages_ConstructWithoutStaticServiceInitialization services.GetRequiredService().Install(Application.Current!.Resources); services.GetRequiredService().Install(Application.Current.Resources); var desktop = Substitute.For(); - PopupWindow? confirmation = null; - using var popupOpened = Window.WindowOpenedEvent.AddClassHandler((popup, _) => confirmation = popup); var windows = services.GetRequiredService(); try { @@ -177,9 +175,10 @@ public async Task MainWindowAndPages_ConstructWithoutStaticServiceInitialization Assert.Equal("Settings", settingsPage.FindControl("SettingsTitle")!.Text); // Cancel leaves both the setting and the selected language unchanged. languages.SelectedIndex = 1; - var cancelPopup = Assert.IsType(confirmation); - var cancelQuestion = Assert.IsType(cancelPopup.FindControl("PopupContent")!.Content); + var cancelQuestion = Assert.IsType(original.Modals.TopModal); + var cancelClosed = cancelQuestion.ShowDialog(); cancelQuestion.FindControl("NoButton")!.RaiseEvent(new RoutedEventArgs(Button.ClickEvent)); + await cancelClosed.WaitAsync(TimeSpan.FromSeconds(2)); await Dispatcher.UIThread.InvokeAsync(() => { }, DispatcherPriority.Background); Assert.Equal("en", settings.WW_LANGUAGE.Value); Assert.Equal(0, languages.SelectedIndex); @@ -189,9 +188,10 @@ public async Task MainWindowAndPages_ConstructWithoutStaticServiceInitialization languages.SelectedItem = languages.Items.Single(item => item!.ToString() == LocalizationProvider.GetLanguageDisplayName(language) ); - var popup = Assert.IsType(confirmation); - var question = Assert.IsType(popup.FindControl("PopupContent")!.Content); + var question = Assert.IsType(original.Modals.TopModal); + var questionClosed = question.ShowDialog(); question.FindControl("YesButton")!.RaiseEvent(new RoutedEventArgs(Button.ClickEvent)); + await questionClosed.WaitAsync(TimeSpan.FromSeconds(2)); await Dispatcher.UIThread.InvokeAsync(() => { }, DispatcherPriority.Background); original.UpdateLayout(); Assert.Equal(language, settings.WW_LANGUAGE.Value); diff --git a/WheelWizard.UI.Test/InAppModalTests.cs b/WheelWizard.UI.Test/InAppModalTests.cs new file mode 100644 index 00000000..9a009265 --- /dev/null +++ b/WheelWizard.UI.Test/InAppModalTests.cs @@ -0,0 +1,648 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Headless; +using Avalonia.Headless.XUnit; +using Avalonia.Input; +using Avalonia.Threading; +using Avalonia.VisualTree; +using NSubstitute; +using WheelWizard.CustomDistributions; +using WheelWizard.CustomDistributions.Presentation; +using WheelWizard.Shared; +using WheelWizard.Views.Components; +using WheelWizard.Views.Dialogs; +using WheelWizard.Views.Dialogs.Base; +using WheelWizard.Views.Shell; +using WheelWizard.WiiManagement.MiiManagement.Domain.Mii; +using WheelWizard.WiiManagement.MiiManagement.Views; +using WheelWizard.WiiManagement.MiiManagement.Views.Dialogs; + +namespace WheelWizard.UI.Test; + +public class InAppModalTests +{ + [AvaloniaTheory] + [InlineData("backdrop")] + [InlineData("escape")] + [InlineData("close")] + public async Task Dismissal_UsesCancelOutcomeInsideSameWindow(string gesture) + { + var owner = new ModalTestWindow(); + owner.Show(); + var nativeWindows = 0; + using var opened = Window.WindowOpenedEvent.AddClassHandler((_, _) => nativeWindows++); + var modal = new TextInputWindow().SetMainText("Name").SetInitialText("Keep me"); + try + { + var answer = modal.ShowDialog(); + Layout(owner); + Assert.Same(owner, TopLevel.GetTopLevel(modal)); + Assert.Equal(0, nativeWindows); + Assert.False(owner.CanInteract); + Assert.IsType(owner.FocusManager!.GetFocusedElement()); + Dismiss(owner, gesture); + Assert.Null(await answer.WaitAsync(TimeSpan.FromSeconds(2))); + Assert.True(owner.CanInteract); + Assert.False(owner.Modals.HasModals); + } + finally + { + owner.Close(); + } + } + + [AvaloniaTheory] + [InlineData("backdrop")] + [InlineData("escape")] + public async Task NondismissibleModal_RequiresExplicitAction(string gesture) + { + var owner = new ModalTestWindow(); + owner.Show(); + var modal = new TestModal(); + try + { + var completion = modal.ShowDialog(); + Layout(owner); + Dismiss(owner, gesture); + Assert.Same(modal, owner.Modals.TopModal); + Assert.False(completion.IsCompleted); + modal.Close(); + await completion.WaitAsync(TimeSpan.FromSeconds(2)); + Assert.Equal(1, modal.ClosedCount); + Assert.True(owner.CanInteract); + } + finally + { + owner.Close(); + } + } + + [AvaloniaFact] + public async Task NestedModal_RestoresInputFocusAndKeepsUnderlyingModalBlocked() + { + var owner = new ModalTestWindow(); + owner.Show(); + owner.BackgroundButton.Focus(); + var first = new TextInputWindow().SetInitialText("Unsaved input"); + var second = new MessageBoxWindow().SetTitleText("Nested prompt"); + try + { + var firstClosed = first.ShowDialog(); + Layout(owner); + var input = first.FindControl("InputField")!; + input.Focus(); + var secondClosed = second.ShowDialog(); + Layout(owner); + Assert.False(first.IsEffectivelyEnabled); + Assert.Same(second, owner.Modals.TopModal); + for (var i = 0; i < 8; i++) + { + owner.KeyPress(Key.Tab, RawInputModifiers.None, PhysicalKey.Tab, null); + var focus = Assert.IsAssignableFrom(owner.FocusManager!.GetFocusedElement()); + Assert.Contains(owner.Modals, focus.GetVisualAncestors()); + Assert.DoesNotContain(first, focus.GetVisualAncestors()); + } + second.Close(); + await secondClosed.WaitAsync(TimeSpan.FromSeconds(2)); + Assert.Same(first, owner.Modals.TopModal); + Assert.Same(input, owner.FocusManager!.GetFocusedElement()); + Assert.Equal("Unsaved input", input.Text); + Assert.False(owner.CanInteract); + first.Close(); + await firstClosed.WaitAsync(TimeSpan.FromSeconds(2)); + Assert.True(owner.CanInteract); + Assert.Same(owner.BackgroundButton, owner.FocusManager.GetFocusedElement()); + } + finally + { + owner.Close(); + } + } + + [AvaloniaFact] + public async Task ClosingWindow_CompletesAllModalTasksAndAllowsRehosting() + { + var owner = new ModalTestWindow(); + owner.Show(); + var first = new TestModal(); + var second = new TestModal(); + var firstTask = first.ShowDialog(); + var secondTask = second.ShowDialog(); + owner.Close(); + await Task.WhenAll(firstTask, secondTask).WaitAsync(TimeSpan.FromSeconds(2)); + Assert.Equal(1, first.ClosedCount); + Assert.Equal(1, second.ClosedCount); + Assert.False(owner.Modals.HasModals); + var next = new ModalTestWindow(); + next.Show(); + try + { + first.SetModalOwner(next); + first.Show(); + Assert.Same(next, TopLevel.GetTopLevel(first)); + } + finally + { + next.Close(); + } + } + + [AvaloniaFact] + public async Task ExplicitOwner_KeepsPromptInsideItsModWindow() + { + var main = new ModalTestWindow(); + main.Show(); + var mod = new ModTestWindow(main); + mod.Show(); + try + { + Layout(mod); + var size = mod.ClientSize; + var prompt = new MessageBoxWindow().SetTitleText("Mod question"); + prompt.SetModalOwner(mod); + var closed = prompt.ShowDialog(); + Layout(mod); + Assert.Same(mod, TopLevel.GetTopLevel(prompt)); + Assert.Same(prompt, mod.Modals.TopModal); + Assert.False(main.Modals.HasModals); + Assert.Equal(size, mod.ClientSize); + prompt.Close(); + await closed.WaitAsync(TimeSpan.FromSeconds(2)); + Assert.False(main.CanInteract); + mod.Close(); + Assert.True(main.CanInteract); + } + finally + { + mod.Close(); + main.Close(); + } + } + + [AvaloniaTheory] + [InlineData(480, 420)] + [InlineData(656, 876)] + public void TallTextModal_ScrollsBodyAndKeepsActionsInView(double width, double height) + { + var owner = new ModalTestWindow { Width = width, Height = height }; + owner.Show(); + var modal = new YesNoWindow() + .SetMainText("Long question") + .SetExtraText(string.Join("\n", Enumerable.Repeat("More information", 100))); + try + { + modal.Show(); + Layout(owner); + var scroll = modal.GetVisualDescendants().OfType().First(); + Assert.True(scroll.Extent.Height > scroll.Viewport.Height); + foreach (var name in new[] { "YesButton", "NoButton" }) + { + var button = modal.FindControl(name)!; + var position = button.TranslatePoint(default, owner)!.Value; + Assert.True(button.Bounds.Height > 0); + Assert.InRange(position.Y, 0, height - button.Bounds.Height); + Assert.InRange(position.X, 0, width - button.Bounds.Width); + } + } + finally + { + owner.Close(); + } + } + + [AvaloniaFact] + public async Task ClosingTransition_KeepsWindowBlockedUntilItCompletes_AndShutdownFinishesImmediately() + { + var resources = Application.Current!.Resources; + var key = WindowAppearance.ControlAnimationDurationResourceKey; + var hadDuration = resources.TryGetValue(key, out var previous); + resources[key] = TimeSpan.FromMilliseconds(120); + var owner = new ModalTestWindow(); + owner.Show(); + try + { + var modal = new TestModal(); + var closed = modal.ShowDialog(); + Layout(owner); + var close = owner.Modals.GetVisualDescendants().OfType().Single(); + Assert.Equal(ActionButtonVariant.Ghost, close.Variant); + Assert.NotNull(close.FindAncestorOfType()); + modal.Close(); + Assert.False(closed.IsCompleted); + Assert.False(owner.CanInteract); + Assert.Same(modal, owner.Modals.TopModal); + await closed.WaitAsync(TimeSpan.FromSeconds(2)); + Assert.True(owner.CanInteract); + Assert.Equal(1, modal.ClosedCount); + var shutdownModal = new TestModal(); + var shutdownClosed = shutdownModal.ShowDialog(); + Layout(owner); + shutdownModal.Close(); + owner.Close(); + Assert.True(shutdownClosed.IsCompleted); + Assert.Equal(1, shutdownModal.ClosedCount); + } + finally + { + owner.Close(); + if (hadDuration) + resources[key] = previous; + else + resources.Remove(key); + } + } + + [AvaloniaTheory] + [InlineData("cancel", false)] + [InlineData("close", false)] + [InlineData("close", true)] + public async Task DistributionProgress_ActualButtonsCancelWithoutEasyDismissal(string button, bool isUpdate) + { + var owner = new ModalTestWindow { Height = 420 }; + owner.Show(); + var modal = new ProgressWindow("Retro Rewind"); + var distribution = Substitute.For(); + var started = new TaskCompletionSource(); + var finished = new TaskCompletionSource(); + Task Start(DistributionOperation operation) + { + operation.Report( + new DistributionProgress( + Message: string.Join("\n", Enumerable.Repeat("Downloading Retro Rewind files...", 50)), + Goal: "Retro Rewind", + CanCancel: true + ) + ); + started.SetResult(operation); + return finished.Task; + } + distribution.InstallAsync(Arg.Any()).Returns(call => Start(call.Arg())); + distribution.UpdateAsync(Arg.Any()).Returns(call => Start(call.Arg())); + try + { + var closed = modal.ShowDialog(); + var running = isUpdate ? distribution.UpdateAsync(modal) : distribution.InstallAsync(modal); + var operation = await started.Task; + await Dispatcher.UIThread.InvokeAsync(() => { }, DispatcherPriority.Background); + Layout(owner); + var close = owner + .Modals.GetVisualDescendants() + .OfType() + .Single(control => Avalonia.Automation.AutomationProperties.GetName(control) == "Close"); + Assert.True(close.IsVisible); + Assert.True(modal.IsCancelable); + Assert.False(modal.IsDismissible); + Dismiss(owner, "backdrop"); + Dismiss(owner, "escape"); + Assert.False(operation.CancellationToken.IsCancellationRequested); + var target = button == "close" ? close : modal.FindControl("CancelButton")!; + var point = target.TranslatePoint(new Point(target.Bounds.Width / 2, target.Bounds.Height / 2), owner)!.Value; + owner.MouseDown(point, MouseButton.Left); + owner.MouseUp(point, MouseButton.Left); + Assert.True(operation.CancellationToken.IsCancellationRequested); + Assert.True(modal.WasCancellationRequested); + Assert.Same(modal, owner.Modals.TopModal); + Assert.False(closed.IsCompleted); + Assert.False(target.IsEnabled && target.IsVisible); + finished.SetResult(new OperationError { Message = "Cancelled" }); + Assert.True((await running).IsSuccess); + modal.Close(); + await closed.WaitAsync(TimeSpan.FromSeconds(2)); + } + finally + { + owner.Close(); + } + } + + [AvaloniaTheory] + [InlineData(false)] + [InlineData(true)] + public void ColoredHeaders_ReachThePanelEdgesWithoutAnExtraTitleBar(bool progress) + { + var owner = new ModalTestWindow(); + owner.Show(); + using var cancellation = new CancellationTokenSource(); + PopupContent modal = progress + ? new ProgressWindow("Progress") + .SetCancellationTokenSource(cancellation) + .SetGoal(string.Join(" ", Enumerable.Repeat("Installing Retro Rewind", 10))) + : new MessageBoxWindow().SetTitleText(string.Join(" ", Enumerable.Repeat("Success", 20))).SetInfoText("Completed"); + try + { + modal.Show(); + Layout(owner); + var colored = modal.FindControl(progress ? "GoalBorder" : "TitleBorder")!; + var close = owner + .Modals.GetVisualDescendants() + .OfType() + .Single(control => Avalonia.Automation.AutomationProperties.GetName(control) == "Close"); + var panel = colored.GetVisualAncestors().OfType().Single(border => border.Name == "ModalPanel"); + Assert.NotNull(close.FindAncestorOfType()); + var position = colored.TranslatePoint(default, panel)!.Value; + Assert.Equal(panel.BorderThickness.Left, position.X, 4); + Assert.Equal(panel.Bounds.Width - panel.BorderThickness.Left - panel.BorderThickness.Right, colored.Bounds.Width, 4); + Assert.Equal(panel.BorderThickness.Top, position.Y, 4); + Assert.True(colored.Bounds.Height > 0); + var title = modal.FindControl(progress ? "GoalTextBlock" : "MessageTitleBlock")!; + var titlePosition = title.TranslatePoint(default, colored)!.Value; + var closePosition = close.TranslatePoint(default, colored)!.Value; + Assert.True(titlePosition.X + title.Bounds.Width <= closePosition.X); + } + finally + { + owner.Close(); + } + } + + [AvaloniaFact] + public async Task WindowDragStrip_RemainsInteractiveWithoutDismissingOrEnablingThePage() + { + var owner = new ModalTestWindow(); + owner.Show(); + var modal = new YesNoWindow().SetMainText("Leave without saving?"); + try + { + var closed = modal.ShowDialog(); + Layout(owner); + Assert.False(owner.CanInteract); + Assert.True(owner.DragHandle.IsEffectivelyEnabled); + var chromeDimmer = owner.GetVisualDescendants().OfType().Single(border => border.Name == "ModalChromeDimmer"); + Assert.True(chromeDimmer.IsVisible); + Assert.False(chromeDimmer.IsHitTestVisible); + Assert.Equal(120d / 255 * owner.Modals.Children.OfType().Single().Opacity, chromeDimmer.Opacity, 6); + Assert.Equal(30, chromeDimmer.Bounds.Height); + var point = owner.DragHandle.TranslatePoint(new Point(200, 15), owner)!.Value; + owner.MouseDown(point, MouseButton.Left); + owner.MouseUp(point, MouseButton.Left); + Assert.Equal(1, owner.DragPresses); + Assert.False(closed.IsCompleted); + Assert.Same(modal, owner.Modals.TopModal); + Assert.DoesNotContain(owner.Modals.GetVisualDescendants().OfType(), text => text.Text == "Wheel Wizard"); + var title = modal.FindControl("MainTextBlock")!; + Assert.NotNull(title.FindAncestorOfType()); + Dismiss(owner, "close"); + await closed.WaitAsync(TimeSpan.FromSeconds(2)); + Assert.True(owner.CanInteract); + Assert.False(chromeDimmer.IsVisible); + } + finally + { + owner.Close(); + } + } + + [AvaloniaFact] + public void RenameInput_UsesNoteAndRequiresAValidChange_WithCharactersAlignedLeft() + { + var owner = new ModalTestWindow(); + owner.Show(); + var modal = new TextInputWindow() + .SetMainText("Rename Mii") + .SetNote("Use 3 to 10 characters.") + .SetInitialText("Mario") + .SetRequireChange() + .SetValidation( + (_, name) => + name.Length is >= 3 and <= 10 ? OperationResult.Ok() : (OperationResult)new OperationError { Message = "Invalid name" } + ) + .SetCustomCharacters(new[] { '★', 'é' }); + try + { + modal.Show(); + Layout(owner); + var input = modal.FindControl("InputField")!; + var apply = modal.FindControl("SubmitButton")!; + var characters = modal.FindControl("CustomCharsButton")!; + Assert.Equal("Use 3 to 10 characters.", input.Note); + Assert.False(modal.FindControl("ExtraTextBlock")!.IsVisible); + Assert.False(apply.IsEnabled); + input.Text = "Luigi"; + Layout(owner); + Assert.True(apply.IsEnabled); + input.Text = "Mario"; + Layout(owner); + Assert.False(apply.IsEnabled); + input.Text = "A"; + Layout(owner); + Assert.False(apply.IsEnabled); + Assert.NotNull(input.ErrorText); + input.Text = "Mario"; + Layout(owner); + Assert.Equal(Avalonia.Layout.HorizontalAlignment.Left, characters.HorizontalAlignment); + Assert.Equal(12, characters.Margin.Bottom); + Assert.Equal(0, characters.TranslatePoint(default, modal)!.Value.X, 4); + var point = characters.TranslatePoint(new Point(characters.Bounds.Width / 2, characters.Bounds.Height / 2), owner)!.Value; + owner.MouseDown(point, MouseButton.Left); + owner.MouseUp(point, MouseButton.Left); + Layout(owner); + var choices = modal.FindControl("CustomChars")!; + Assert.True(choices.IsVisible); + choices.Children.OfType().First().RaiseEvent(new Avalonia.Interactivity.RoutedEventArgs(Button.ClickEvent)); + Layout(owner); + Assert.Equal("Mario★", input.Text); + Assert.True(apply.IsEnabled); + } + finally + { + owner.Close(); + } + } + + [AvaloniaTheory] + [InlineData(false)] + [InlineData(true)] + public void SelectorAndOptionsTitles_BindToTheModalRatherThanTheSurroundingPage(bool selector) + { + var owner = new ModalTestWindow { DataContext = new { PopupTitle = "Wrong page title" } }; + owner.Show(); + PopupContent modal = selector ? new MiiSelectorWindow() : new OptionsWindow().SetWindowTitle("Choose a Dolphin executable"); + try + { + modal.Show(); + Layout(owner); + var heading = modal.GetVisualDescendants().OfType().Single(); + var title = Assert.IsType(heading.Content); + Assert.False(string.IsNullOrWhiteSpace(title.Text)); + Assert.Equal(modal.PopupTitle, title.Text); + Assert.NotEqual("Wrong page title", title.Text); + } + finally + { + owner.Close(); + } + } + + [AvaloniaFact] + public void ChromeDimming_FollowsAnimatedBackdropValues_AndHeaderControlsRemainUsable() + { + var resources = Application.Current!.Resources; + var key = WindowAppearance.ControlAnimationDurationResourceKey; + var hadDuration = resources.TryGetValue(key, out var previous); + resources[key] = TimeSpan.Zero; + var owner = new ModalTestWindow(); + owner.Show(); + var first = new MessageBoxWindow().SetTitleText("First prompt"); + var second = new MessageBoxWindow().SetTitleText("Second prompt"); + try + { + first.Show(); + Layout(owner); + var dimmer = owner.GetVisualDescendants().OfType().Single(border => border.Name == "ModalChromeDimmer"); + var firstLayer = owner.Modals.Children.OfType().Single(); + foreach (var opacity in new[] { 0d, 0.25, 0.75, 1d, 0.75, 0.25, 0d }) + { + firstLayer.SetValue(Visual.OpacityProperty, opacity, Avalonia.Data.BindingPriority.Animation); + Assert.Equal(120d / 255 * opacity, dimmer.Opacity, 6); + } + firstLayer.SetValue(Visual.OpacityProperty, 1d, Avalonia.Data.BindingPriority.Animation); + second.Show(); + Layout(owner); + var secondLayer = owner.Modals.Children.OfType().Last(); + secondLayer.SetValue(Visual.OpacityProperty, 0.5, Avalonia.Data.BindingPriority.Animation); + Assert.Equal(1 - (1 - 120d / 255) * (1 - 60d / 255), dimmer.Opacity, 6); + var point = owner.CaptionButton.TranslatePoint(new Point(20, 10), owner)!.Value; + owner.MouseDown(point, MouseButton.Left); + owner.MouseUp(point, MouseButton.Left); + Assert.Equal(1, owner.CaptionClicks); + Assert.Same(second, owner.Modals.TopModal); + Assert.False(owner.CanInteract); + var close = (WindowClosingEventArgs) + Activator.CreateInstance( + typeof(WindowClosingEventArgs), + System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic, + binder: null, + args: new object[] { WindowCloseReason.WindowClosing, false }, + culture: null + )!; + owner.RequestNativeClose(close); + Assert.False(close.Cancel); + } + finally + { + owner.Close(); + if (hadDuration) + resources[key] = previous; + else + resources.Remove(key); + } + } + + [AvaloniaFact] + public void MiiSelector_SaveRequiresADifferentSelection_AndReturningToOriginalDisablesIt() + { + var original = new Mii { MiiId = 1, Name = new MiiName("Mario") }; + var replacement = new Mii { MiiId = 2, Name = new MiiName("Luigi") }; + var modal = new MiiSelectorWindow().SetMiiOptions(new List { original, replacement }, original, "00:00:00:00:00:00"); + var save = modal.FindControl("SaveButton")!; + var choices = modal.FindControl("MiiList")!.Children.OfType().ToList(); + Assert.False(save.IsEnabled); + choices.Single(choice => choice.Mii == replacement).IsChecked = true; + Assert.True(save.IsEnabled); + choices.Single(choice => choice.Mii == original).IsChecked = true; + Assert.False(save.IsEnabled); + choices.ForEach(choice => choice.IsChecked = false); + Assert.False(save.IsEnabled); + } + + private static void Dismiss(Window owner, string gesture) + { + if (gesture == "escape") + owner.KeyPress(Key.Escape, RawInputModifiers.None, PhysicalKey.Escape, null); + else if (gesture == "backdrop") + { + owner.MouseDown(new Point(5, 50), MouseButton.Left); + owner.MouseUp(new Point(5, 50), MouseButton.Left); + } + else + owner + .GetVisualDescendants() + .OfType() + .Single(button => Avalonia.Automation.AutomationProperties.GetName(button) == "Close") + .RaiseEvent(new Avalonia.Interactivity.RoutedEventArgs(Button.ClickEvent)); + } + + private static void Layout(Window owner) + { + owner.UpdateLayout(); + Dispatcher.UIThread.RunJobs(); + owner.UpdateLayout(); + } + + private sealed class TestModal : PopupContent + { + public int ClosedCount { get; private set; } + + public TestModal() + : base(false, false, false, "Required action") + { + Content = new StackPanel + { + Width = 300, + Children = + { + new ModalHeading { Content = new TextBlock { Text = "Required action" } }, + new Button { Content = "Explicit action", Height = 120 }, + }, + }; + } + + protected override void BeforeClose() => ClosedCount++; + } + + private sealed class ModTestWindow : PopupWindow + { + public ModTestWindow(Window owner) + : base(true, false, false, "Mod browser") + { + Owner = owner; + this.FindControl("PopupContent")!.Content = new Border + { + Width = 600, + Height = 600, + }; + } + } + + private sealed class ModalTestWindow : BaseWindow + { + private readonly Grid _frame = new() { RowDefinitions = RowDefinitions.Parse("30,*") }; + private readonly Grid _body = new(); + public Border DragHandle { get; } = new() { Background = Avalonia.Media.Brushes.Gray }; + public int DragPresses { get; private set; } + public Button CaptionButton { get; } = + new() + { + Content = "Caption", + Width = 40, + Height = 20, + HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Right, + }; + public int CaptionClicks { get; private set; } + + public void RequestNativeClose(WindowClosingEventArgs args) => base.OnClosing(args); + + private readonly Border _overlay = new(); + public Button BackgroundButton { get; } = new() { Content = "Background" }; + public bool CanInteract => _body.IsEnabled; + protected override Control InteractionContent => _frame; + protected override Control InteractionOverlay => _overlay; + protected override Control ModalInteractionContent => _body; + + public ModalTestWindow() + { + Width = 656; + Height = 876; + ExtendClientAreaTitleBarHeightHint = 30; + DragHandle.PointerPressed += (_, _) => DragPresses++; + CaptionButton.Click += (_, _) => CaptionClicks++; + DragHandle.Child = CaptionButton; + _frame.Children.Add(DragHandle); + _body.Children.Add(BackgroundButton); + Grid.SetRow(_body, 1); + _frame.Children.Add(_body); + _frame.Children.Add(_overlay); + Content = _frame; + } + } +} diff --git a/WheelWizard.UI.Test/WindowCompositionTests.cs b/WheelWizard.UI.Test/WindowCompositionTests.cs index 2fb8bcbf..362e5ca7 100644 --- a/WheelWizard.UI.Test/WindowCompositionTests.cs +++ b/WheelWizard.UI.Test/WindowCompositionTests.cs @@ -86,12 +86,12 @@ public async Task LinuxManualUpdate_DirectsUsersToTheirPackageManager() { var environment = Substitute.For(); environment.IsLinux.Returns(true); - PopupWindow? popup = null; - using var opened = Window.WindowOpenedEvent.AddClassHandler((window, _) => popup = window); + var popup = new TestWindow { Width = 656, Height = 876 }; + popup.Show(); try { await new UpdatePresentation(environment).ShowManualUpdateAsync("2.0.0", "1.0.0"); - var content = Assert.IsType(popup!.FindControl("PopupContent")!.Content); + var content = Assert.IsType(popup.Modals.TopModal); Assert.Contains("through your package manager", content.FindControl("MessageInformationBlock")!.Text); Assert.Equal( "https://github.com/TeamWheelWizard/WheelWizard/releases", @@ -221,32 +221,38 @@ public void SettingsContent_StaysStationaryWhileNavigationIsRevealed() } } - [AvaloniaTheory] - [InlineData(true, false)] - [InlineData(false, false)] - [InlineData(true, true)] - public void ProgressNativeClose_CancelsOnlyCancelableUserRequests(bool canCancel, bool programmatic) + [AvaloniaFact] + public async Task ProgressDismissal_DoesNotCancelButExplicitCancelDoes() { using var cancellation = new CancellationTokenSource(); - var content = new WheelWizard.Views.Dialogs.ProgressWindow("Download"); - if (canCancel) - content.SetCancellationTokenSource(cancellation); - content.Show(); - var popup = Assert.IsType(TopLevel.GetTopLevel(content)); + var owner = new TestWindow { Width = 656, Height = 876 }; + owner.Show(); + var content = new ProgressWindow("Download").SetCancellationTokenSource(cancellation); try { - var args = CreateClosingArgs(programmatic); - typeof(PopupWindow) - .GetMethod("OnClosing", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)! - .Invoke(popup, [args]); - Assert.Equal(canCancel && !programmatic, cancellation.IsCancellationRequested); - Assert.Equal(canCancel && !programmatic, content.WasCancellationRequested); - Assert.Equal(!programmatic, args.Cancel); + var closed = content.ShowDialog(); + owner.UpdateLayout(); + Dispatcher.UIThread.RunJobs(); + Assert.Same(owner, TopLevel.GetTopLevel(content)); + owner.KeyPress(Avalonia.Input.Key.Escape, Avalonia.Input.RawInputModifiers.None, Avalonia.Input.PhysicalKey.Escape, null); + owner.MouseDown(new Point(5, 50), Avalonia.Input.MouseButton.Left); + owner.MouseUp(new Point(5, 50), Avalonia.Input.MouseButton.Left); + Assert.False(cancellation.IsCancellationRequested); + Assert.Same(content, owner.Modals.TopModal); + var cancel = content.FindControl("CancelButton")!; + var point = cancel.TranslatePoint(new Point(cancel.Bounds.Width / 2, cancel.Bounds.Height / 2), owner)!.Value; + owner.MouseDown(point, Avalonia.Input.MouseButton.Left); + owner.MouseUp(point, Avalonia.Input.MouseButton.Left); + Assert.True(cancellation.IsCancellationRequested); + Assert.True(content.WasCancellationRequested); + Assert.Same(content, owner.Modals.TopModal); + content.Close(); + await closed.WaitAsync(TimeSpan.FromSeconds(2)); + Assert.True(owner.CanInteract); } finally { - content.SetCancellationTokenSource(null); - content.Close(); + owner.Close(); } } @@ -360,7 +366,7 @@ public void PopupDimming_CoversTitleBarAndContent_AndRestoresTogether() { popup.Show(); popup.UpdateLayout(); - var frame = Assert.IsType(popup.Content); + var frame = popup.FindControl("WindowFrame")!; var overlay = popup.FindControl("DisabledDarkenEffect")!; popup.SetInteractable(false); popup.UpdateLayout(); @@ -470,13 +476,15 @@ public void Popup_AutoSizingFollowsContentGrowthAndShrinkage() public void MessagePopup_GrowsForLateContentWithoutEnlargingItsTitle() { var content = new MessageBoxWindow().SetTitleText("A stable title").SetInfoText("Loading..."); + var owner = new TestWindow { Width = 656, Height = 876 }; + owner.Show(); try { content.Show(); - var popup = Assert.IsType(TopLevel.GetTopLevel(content)); + var popup = Assert.IsType(TopLevel.GetTopLevel(content)); popup.UpdateLayout(); Dispatcher.UIThread.RunJobs(); - var initialSize = popup.ClientSize; + var initialSize = content.Bounds.Size; var title = content.FindControl("MessageTitleBlock")!; var titleSize = title.Bounds.Size; var titleFontSize = title.FontSize; @@ -485,8 +493,8 @@ public void MessagePopup_GrowsForLateContentWithoutEnlargingItsTitle() popup.UpdateLayout(); Dispatcher.UIThread.RunJobs(); - Assert.True(popup.ClientSize.Height > initialSize.Height); - Assert.Equal(initialSize.Width, popup.ClientSize.Width); + Assert.True(content.Bounds.Height > initialSize.Height); + Assert.Equal(initialSize.Width, content.Bounds.Width); Assert.Equal(titleSize, title.Bounds.Size); Assert.Equal(titleFontSize, title.FontSize); foreach (var button in content.GetVisualDescendants().OfType