diff --git a/WheelWizard.Test/Views/Shell/ViewModels/HomeViewModelTests.cs b/WheelWizard.Test/Views/Shell/ViewModels/HomeViewModelTests.cs index 17627a39..baf0750d 100644 --- a/WheelWizard.Test/Views/Shell/ViewModels/HomeViewModelTests.cs +++ b/WheelWizard.Test/Views/Shell/ViewModels/HomeViewModelTests.cs @@ -41,12 +41,12 @@ public async Task OverlappingClicks_StartOneOperation_AndRestoreInteraction() await fixture.Model.ExecuteMainAsync(); Assert.True(fixture.Model.IsBusy); Assert.False(fixture.Model.CanExecuteMain); - fixture.Presentation.Received(1).SetApplicationInteractable(false); + fixture.Presentation.Received(1).SetCloseGuarded(true); install.SetResult(Ok()); await first; await fixture.Launcher.Received(1).Install(); - fixture.Presentation.Received(1).SetApplicationInteractable(true); + fixture.Presentation.Received(1).SetCloseGuarded(false); Assert.False(fixture.Model.IsBusy); Assert.True(fixture.Model.IsInteractable); } @@ -62,7 +62,7 @@ public async Task ThrowingUpdate_RestoresNavigationAndShowsError() await fixture.Model.ExecuteMainAsync(); fixture.Presentation.Received(1).ShowError(Arg.Is(error => error.Exception is IOException)); - fixture.Presentation.Received(1).SetApplicationInteractable(true); + fixture.Presentation.Received(1).SetCloseGuarded(false); Assert.True(fixture.Model.CanExecuteMain); } diff --git a/WheelWizard.UI.Test/ApplicationCompositionTests.cs b/WheelWizard.UI.Test/ApplicationCompositionTests.cs index 7b883f34..3e4d4ec9 100644 --- a/WheelWizard.UI.Test/ApplicationCompositionTests.cs +++ b/WheelWizard.UI.Test/ApplicationCompositionTests.cs @@ -214,6 +214,47 @@ public async Task MainWindowAndPages_ConstructWithoutStaticServiceInitialization Assert.Equal(LocalizationProvider.Translate("component.dropdown.search_placeholder"), languages.SearchPlaceholder); Assert.Equal(LocalizationProvider.Translate("component.dropdown.empty"), languages.EmptyText); } + + // Closing during setup asks first, and the question stays answerable. + var onClosing = typeof(Layout).GetMethod( + "OnClosing", + System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic + )!; + original.SetCloseGuarded(true); + var guardedClose = WindowCompositionTests.CreateClosingArgs(false); + onClosing.Invoke(original, [guardedClose]); + Assert.True(guardedClose.Cancel); + var quitQuestion = Assert.IsType(original.Modals.TopModal); + Assert.True(quitQuestion.IsEffectivelyEnabled); + var quitClosed = quitQuestion.ShowDialog(); + quitQuestion.FindControl("NoButton")!.RaiseEvent(new RoutedEventArgs(Button.ClickEvent)); + await quitClosed.WaitAsync(TimeSpan.FromSeconds(2)); + await Dispatcher.UIThread.InvokeAsync(() => { }, DispatcherPriority.Background); + Assert.True(original.IsVisible); + var programmaticClose = WindowCompositionTests.CreateClosingArgs(true); + onClosing.Invoke(original, [programmaticClose]); + Assert.False(programmaticClose.Cancel); + original.SetCloseGuarded(false); + var unguardedClose = WindowCompositionTests.CreateClosingArgs(false); + onClosing.Invoke(original, [unguardedClose]); + Assert.False(unguardedClose.Cancel); + Assert.Null(original.Modals.TopModal); + + // Quitting during setup still asks a page with unsaved work. + navigation.NavigateTo(); + var guardedPage = Assert.IsType(navigation.CurrentPage); + original.SetCloseGuarded(true); + onClosing.Invoke(original, [WindowCompositionTests.CreateClosingArgs(false)]); + var setupQuestion = Assert.IsType(original.Modals.TopModal); + var setupClosed = setupQuestion.ShowDialog(); + setupQuestion.FindControl("YesButton")!.RaiseEvent(new RoutedEventArgs(Button.ClickEvent)); + await setupClosed.WaitAsync(TimeSpan.FromSeconds(2)); + await Dispatcher.UIThread.InvokeAsync(() => { }, DispatcherPriority.Background); + Assert.Equal(1, guardedPage.ConfirmCount); + Assert.True(original.IsVisible); + original.SetCloseGuarded(false); + guardedPage.HasUnsavedWork = false; + Assert.Equal(Avalonia.Controls.ShutdownMode.OnMainWindowClose, desktop.ShutdownMode); desktop.DidNotReceive().Shutdown(Arg.Any()); } @@ -229,6 +270,18 @@ public async Task MainWindowAndPages_ConstructWithoutStaticServiceInitialization } } + public sealed class GuardedTestPage : UserControl, INavigationGuard + { + public bool HasUnsavedWork { get; set; } = true; + public int ConfirmCount { get; private set; } + + public Task ConfirmLeaveAsync() + { + ConfirmCount++; + return Task.FromResult(false); + } + } + public sealed class OfflineApiCaller : IApiCaller where TApi : class { diff --git a/WheelWizard.UI.Test/InAppModalTests.cs b/WheelWizard.UI.Test/InAppModalTests.cs index 1a2195f0..cc4b2179 100644 --- a/WheelWizard.UI.Test/InAppModalTests.cs +++ b/WheelWizard.UI.Test/InAppModalTests.cs @@ -525,12 +525,9 @@ private sealed class ModalTestWindow : BaseWindow, IDisposable 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; + protected override Control InteractionContent => _body; public ModalTestWindow() { @@ -544,7 +541,6 @@ public ModalTestWindow() _body.Children.Add(BackgroundButton); Grid.SetRow(_body, 1); _frame.Children.Add(_body); - _frame.Children.Add(_overlay); Content = _frame; } } diff --git a/WheelWizard.UI.Test/ModDetailsTests.cs b/WheelWizard.UI.Test/ModDetailsTests.cs index 23bdb0e6..df9542e3 100644 --- a/WheelWizard.UI.Test/ModDetailsTests.cs +++ b/WheelWizard.UI.Test/ModDetailsTests.cs @@ -229,15 +229,13 @@ private sealed class TestWindow : BaseWindow, IDisposable public void Dispose() => Close(); private readonly Grid _body = new(); - private readonly Border _overlay = new(); protected override Control InteractionContent => _body; - protected override Control InteractionOverlay => _overlay; public TestWindow() { Width = 656; Height = 876; - Content = new Grid { Children = { _body, _overlay } }; + Content = new Grid { Children = { _body } }; } } } diff --git a/WheelWizard.UI.Test/UiTestHost.cs b/WheelWizard.UI.Test/UiTestHost.cs index a15363a7..253759c5 100644 --- a/WheelWizard.UI.Test/UiTestHost.cs +++ b/WheelWizard.UI.Test/UiTestHost.cs @@ -6,15 +6,13 @@ namespace WheelWizard.UI.Test; internal sealed class UiTestHost : BaseWindow, IDisposable { public Border Body { get; } = new(); - private readonly Border _overlay = new(); protected override Control InteractionContent => Body; - protected override Control InteractionOverlay => _overlay; public UiTestHost() { Width = 656; Height = 876; - Content = new Grid { Children = { Body, _overlay } }; + Content = new Grid { Children = { Body } }; } public void Dispose() => Close(); diff --git a/WheelWizard.UI.Test/WindowCompositionTests.cs b/WheelWizard.UI.Test/WindowCompositionTests.cs index cdf9344b..839f5ebf 100644 --- a/WheelWizard.UI.Test/WindowCompositionTests.cs +++ b/WheelWizard.UI.Test/WindowCompositionTests.cs @@ -228,25 +228,6 @@ public async Task ProgressDismissal_DoesNotCancelButExplicitCancelDoes() Assert.True(owner.CanInteract); } - [AvaloniaFact] - public void DisabledWindow_RejectsNativeCloseButAllowsCompletionAndShutdown() - { - using var window = new TestWindow(); - window.SetInteractable(false); - var native = CreateClosingArgs(false); - window.RequestClose(native); - Assert.True(native.Cancel); - foreach (var args in new[] { CreateClosingArgs(true), CreateClosingArgs(false, WindowCloseReason.ApplicationShutdown) }) - { - window.RequestClose(args); - Assert.False(args.Cancel); - } - window.SetInteractable(true); - var allowed = CreateClosingArgs(false); - window.RequestClose(allowed); - Assert.False(allowed.Cancel); - } - [AvaloniaFact] public void MessagePopup_GrowsForLateContentWithoutEnlargingItsTitle() { @@ -361,7 +342,7 @@ public async Task PlayerProfilePopup_FitsLateProfileAndHistoryWithoutScalingCont Assert.False(owner.CanInteract); } - private static WindowClosingEventArgs CreateClosingArgs( + internal static WindowClosingEventArgs CreateClosingArgs( bool programmatic, WindowCloseReason reason = WindowCloseReason.WindowClosing ) => @@ -378,11 +359,7 @@ private sealed class TestWindow : BaseWindow, IDisposable { public void Dispose() => Close(); - public void RequestClose(WindowClosingEventArgs args) => OnClosing(args); - - private readonly Border _overlay = new(); private readonly Border _content = new(); - protected override Control InteractionOverlay => _overlay; protected override Control InteractionContent => _content; public bool CanInteract => _content.IsEnabled; diff --git a/WheelWizard/Resources/Languages/en.yml b/WheelWizard/Resources/Languages/en.yml index 1871bd34..ce27ad71 100644 --- a/WheelWizard/Resources/Languages/en.yml +++ b/WheelWizard/Resources/Languages/en.yml @@ -175,6 +175,8 @@ en: redo: "Redo" leave: "Leave" keep_editing: "Keep editing" + quit: "Quit" + keep_installing: "Keep installing" surprise_me: "Surprise me" unfavorite: "Unfavorite" favorite: "Favorite" @@ -340,6 +342,9 @@ en: leave_mii_editor: title: "Leave without saving?" extra: "Your changes to this Mii will be lost." + quit_during_setup: + title: "Quit while installing?" + extra: "An install or update is still running. Quitting now can leave the game incomplete." recomp_uninstall: title: "Uninstall WiiCompiled?" extra_shared: "This removes the WiiCompiled installation. Your saves stay in Dolphin's Wii folder, and Retro Rewind stays installed." diff --git a/WheelWizard/Views/DesignTime/DevToolWindow.axaml b/WheelWizard/Views/DesignTime/DevToolWindow.axaml index 39d5cf92..3dc1f09d 100644 --- a/WheelWizard/Views/DesignTime/DevToolWindow.axaml +++ b/WheelWizard/Views/DesignTime/DevToolWindow.axaml @@ -46,14 +46,12 @@ - - - - Popups.Create().Show(); - private void ForceEnableLayout_OnClick(object sender, RoutedEventArgs e) => ViewUtils.GetLayout().SetInteractable(true); - private void ClearCache_OnClick(object sender, RoutedEventArgs e) => ((MemoryCache)Cache).Clear(); private void KitchenSink_OnClick(object sender, RoutedEventArgs e) diff --git a/WheelWizard/Views/Shell/BaseWindow.cs b/WheelWizard/Views/Shell/BaseWindow.cs index ca52752c..351e3b00 100644 --- a/WheelWizard/Views/Shell/BaseWindow.cs +++ b/WheelWizard/Views/Shell/BaseWindow.cs @@ -17,10 +17,10 @@ public abstract class BaseWindow : Window internal static BaseWindow GetModalOwner() { - var foreground = OpenWindows.LastOrDefault(window => window.IsActive && window.IsVisible && window.Modals.IsEnabled); + var foreground = OpenWindows.LastOrDefault(window => window.IsActive && window.IsVisible); if (foreground != null) return foreground; - var current = OpenWindows.LastOrDefault(window => window.IsVisible && window.Modals.IsEnabled); + var current = OpenWindows.LastOrDefault(window => window.IsVisible); if (current != null) return current; if ( @@ -57,11 +57,7 @@ private void InstallModalHost() internal void UpdateModalInteraction() { - InteractionOverlay.IsVisible = _disableCount != 0; - InteractionContent.IsEnabled = _disableCount == 0; - ModalInteractionContent.IsEnabled = _disableCount == 0 && !(_modals?.HasModals ?? false); - if (_modals != null) - _modals.IsEnabled = _disableCount == 0; + InteractionContent.IsEnabled = !(_modals?.HasModals ?? false); UpdateModalChromeDimming(); } @@ -75,14 +71,10 @@ internal void UpdateModalChromeDimming() } } - private int _disableCount = 0; private readonly Action? _setWaylandSizeConstraints; private bool _waylandSizeUpdateQueued; - protected abstract Control InteractionOverlay { get; } // Just the visual part of the interaction - protected abstract Control InteractionContent { get; } // The content that will be disabled when the overlay is shown - - protected virtual Control ModalInteractionContent => InteractionContent; + protected abstract Control InteractionContent { get; } // The content that will be disabled while a modal is shown private void InstallMacWindowMenu() { @@ -162,28 +154,10 @@ protected override void OnOpened(EventArgs e) base.OnOpened(e); } - protected override void OnClosing(WindowClosingEventArgs e) - { - // Blocking application work still gates native close; in-app modals leave window controls usable. - if (_disableCount != 0 && !e.IsProgrammatic && e.CloseReason == WindowCloseReason.WindowClosing) - e.Cancel = true; - base.OnClosing(e); - } - protected override void OnClosed(EventArgs e) { _modals?.CloseAll(); OpenWindows.Remove(this); base.OnClosed(e); } - - public void SetInteractable(bool value) - { - if (!value) - _disableCount++; - else if (_disableCount > 0) - _disableCount--; - - UpdateModalInteraction(); - } } diff --git a/WheelWizard/Views/Shell/Layout.axaml b/WheelWizard/Views/Shell/Layout.axaml index 96b1eecb..368bc7f5 100644 --- a/WheelWizard/Views/Shell/Layout.axaml +++ b/WheelWizard/Views/Shell/Layout.axaml @@ -21,7 +21,7 @@ xmlns:design="clr-namespace:WheelWizard.Views.DesignTime" xmlns:miiImages="clr-namespace:WheelWizard.MiiImages.Views" xmlns:shellControls="clr-namespace:WheelWizard.Views.Shell.Controls"> - - diff --git a/WheelWizard/Views/Shell/Layout.axaml.cs b/WheelWizard/Views/Shell/Layout.axaml.cs index bf2416c8..704a1e00 100644 --- a/WheelWizard/Views/Shell/Layout.axaml.cs +++ b/WheelWizard/Views/Shell/Layout.axaml.cs @@ -19,6 +19,7 @@ using WheelWizard.Settings.Views; using WheelWizard.Shared.MessageTranslations; using WheelWizard.Shared.Polling; +using WheelWizard.Views.Components; using WheelWizard.Views.Dialogs; using WheelWizard.Views.Shell.Controls; using WheelWizard.Views.Shell.Navigation; @@ -37,9 +38,7 @@ public partial class Layout : BaseWindow, IPollingListener private INavigationService Navigation { get; } private readonly IAutoUpdaterSingletonService _autoUpdater; - protected override Control InteractionOverlay => DisabledDarkenEffect; - protected override Control InteractionContent => WindowFrame; - protected override Control ModalInteractionContent => CompleteGrid; + protected override Control InteractionContent => CompleteGrid; public const double WindowHeight = 876; public const double WindowWidth = 656; @@ -286,33 +285,57 @@ private void NavigateFromSidebar(Type pageType) private bool _closeConfirmed; private bool _closePromptOpen; + private int _closeGuards; + + /// While guarded, closing the window asks before abandoning the running work. + public void SetCloseGuarded(bool guarded) => _closeGuards = Math.Max(0, _closeGuards + (guarded ? 1 : -1)); protected override async void OnClosing(WindowClosingEventArgs e) { - if (!_closeConfirmed && Navigation.CurrentPage is INavigationGuard { HasUnsavedWork: true } guard && InteractionContent.IsEnabled) + var confirm = GetCloseConfirmation(e); + if (confirm == null) { - e.Cancel = true; base.OnClosing(e); - if (_closePromptOpen) - return; - _closePromptOpen = true; - try - { - if (!await guard.ConfirmLeaveAsync()) - return; - _closeConfirmed = true; - Close(); - } - finally - { - _closePromptOpen = false; - } return; } + e.Cancel = true; base.OnClosing(e); + if (_closePromptOpen) + return; + _closePromptOpen = true; + try + { + if (!await confirm()) + return; + _closeConfirmed = true; + Close(); + } + finally + { + _closePromptOpen = false; + } } + private Func>? GetCloseConfirmation(WindowClosingEventArgs e) + { + if (_closeConfirmed) + return null; + var setup = _closeGuards > 0 && !e.IsProgrammatic && e.CloseReason == WindowCloseReason.WindowClosing; + var page = Navigation.CurrentPage is INavigationGuard { HasUnsavedWork: true } guard ? guard : null; + if (!setup && page == null) + return null; + return async () => (!setup || await ConfirmQuitDuringSetupAsync()) && (page == null || await page.ConfirmLeaveAsync()); + } + + private static Task ConfirmQuitDuringSetupAsync() => + new YesNoWindow() + .SetMainText(t("question.quit_during_setup.title")) + .SetExtraText(t("question.quit_during_setup.extra")) + .SetButtonText(t("action.quit"), t("action.keep_installing")) + .SetButtonTones(ActionButtonTone.Danger, ActionButtonTone.Secondary) + .AwaitAnswer(); + public void NavigateToPage(UserControl page) { var oldPage = ContentArea.Content as Control; @@ -584,7 +607,7 @@ private static IBrush GetResourceBrush(string resourceName) => private void SupportUs_OnClick(object? sender, RoutedEventArgs e) => ViewUtils.OpenLink(BrandingService.Branding.SupportUrl.ToString()); - public bool CompleteContentEnabled => ModalInteractionContent.IsEnabled; + public bool CompleteContentEnabled => InteractionContent.IsEnabled; public void ShowAppInfo() => Navigation.NavigateTo(typeof(AppInfo)); diff --git a/WheelWizard/Views/Shell/Presentation/HomePresentation.cs b/WheelWizard/Views/Shell/Presentation/HomePresentation.cs index 30b9ffd0..d1776c43 100644 --- a/WheelWizard/Views/Shell/Presentation/HomePresentation.cs +++ b/WheelWizard/Views/Shell/Presentation/HomePresentation.cs @@ -13,7 +13,7 @@ public sealed class HomePresentation(INavigationService navigation) : IHomePrese public void OpenSettings() => navigation.NavigateTo(); - public void SetApplicationInteractable(bool interactable) => ViewUtils.GetLayout().SetInteractable(interactable); + public void SetCloseGuarded(bool guarded) => ViewUtils.GetLayout().SetCloseGuarded(guarded); public async Task ShowLaunchPromptAsync(HomeLaunchPrompt prompt) => await new YesNoWindow() diff --git a/WheelWizard/Views/Shell/ViewModels/HomeViewModel.cs b/WheelWizard/Views/Shell/ViewModels/HomeViewModel.cs index 36c75e75..fe94b54e 100644 --- a/WheelWizard/Views/Shell/ViewModels/HomeViewModel.cs +++ b/WheelWizard/Views/Shell/ViewModels/HomeViewModel.cs @@ -14,7 +14,7 @@ public interface IHomePresentation { void ShowError(OperationError error); void OpenSettings(); - void SetApplicationInteractable(bool interactable); + void SetCloseGuarded(bool guarded); Task ShowLaunchPromptAsync(HomeLaunchPrompt prompt); } @@ -177,7 +177,7 @@ private async Task RunActionAsync(Func> action, bool setup try { if (setup) - _presentation.SetApplicationInteractable(false); + _presentation.SetCloseGuarded(true); var result = await action(); if (showResultErrors && result.IsFailure && !_disposed) _presentation.ShowError(result.Error); @@ -190,7 +190,7 @@ private async Task RunActionAsync(Func> action, bool setup finally { if (setup) - _presentation.SetApplicationInteractable(true); + _presentation.SetCloseGuarded(false); var remaining = TimeSpan.FromSeconds(2) - _time.GetElapsedTime(started); try {