From ea33ee601dc71c81cee57e861ff6ac957ef8f533 Mon Sep 17 00:00:00 2001 From: Macocian Alexandru Victor Date: Wed, 22 May 2024 12:53:50 +0200 Subject: [PATCH] Freeze layouts calculations on resize (Closes #734) (#735) --- Daybreak/Behaviors/LayoutFreezeDecorator.cs | 28 ++++ .../Configuration/ProjectConfiguration.cs | 3 + Daybreak/Launch/Launcher.cs | 4 + Daybreak/Launch/MainWindow.xaml.cs | 5 + Daybreak/Services/Window/IWindowEventsHook.cs | 12 ++ Daybreak/Services/Window/WindowEventsHook.cs | 126 ++++++++++++++++++ Daybreak/Views/FocusView.xaml.cs | 75 +++++++++++ Daybreak/Views/LauncherView.xaml.cs | 5 +- 8 files changed, 256 insertions(+), 2 deletions(-) create mode 100644 Daybreak/Behaviors/LayoutFreezeDecorator.cs create mode 100644 Daybreak/Services/Window/IWindowEventsHook.cs create mode 100644 Daybreak/Services/Window/WindowEventsHook.cs diff --git a/Daybreak/Behaviors/LayoutFreezeDecorator.cs b/Daybreak/Behaviors/LayoutFreezeDecorator.cs new file mode 100644 index 00000000..7ad20e39 --- /dev/null +++ b/Daybreak/Behaviors/LayoutFreezeDecorator.cs @@ -0,0 +1,28 @@ +using System.Windows; +using System.Windows.Controls; + +namespace Daybreak.Behaviors; +internal sealed class LayoutFreezeDecorator : Decorator +{ + public bool IsLayoutFrozen { get; set; } + + protected override Size MeasureOverride(Size constraint) + { + if (this.IsLayoutFrozen) + { + return new Size(0, 0); + } + + return base.MeasureOverride(constraint); + } + + protected override Size ArrangeOverride(Size arrangeSize) + { + if (this.IsLayoutFrozen) + { + return arrangeSize; + } + + return base.ArrangeOverride(arrangeSize); + } +} diff --git a/Daybreak/Configuration/ProjectConfiguration.cs b/Daybreak/Configuration/ProjectConfiguration.cs index 62b32a04..9e1dbdde 100644 --- a/Daybreak/Configuration/ProjectConfiguration.cs +++ b/Daybreak/Configuration/ProjectConfiguration.cs @@ -86,6 +86,8 @@ using Daybreak.Services.Browser; using Daybreak.Services.ApplicationArguments; using Daybreak.Services.ApplicationArguments.ArgumentHandling; +using Daybreak.Services.Window; +using Daybreak.Launch; namespace Daybreak.Configuration; @@ -228,6 +230,7 @@ public override void RegisterServices(IServiceCollection services) services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton, WindowEventsHook>(); services.AddScoped(); services.AddScoped(sp => sp.GetRequiredService().Cast()); services.AddScoped(); diff --git a/Daybreak/Launch/Launcher.cs b/Daybreak/Launch/Launcher.cs index 996284c6..459d7f23 100644 --- a/Daybreak/Launch/Launcher.cs +++ b/Daybreak/Launch/Launcher.cs @@ -13,6 +13,7 @@ using Daybreak.Services.Startup; using Daybreak.Services.Themes; using Daybreak.Services.Updater.PostUpdate; +using Daybreak.Services.Window; using Daybreak.Views; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -150,6 +151,9 @@ protected override void ApplicationStarting() this.logger.LogError(e, "Encountered exception while loading plugins. Aborting..."); this.exceptionHandler.HandleException(e); } + + // Trigger hooks into MainWindow + this.ServiceProvider.GetRequiredService>(); startupStatus.CurrentStep = StartupStatus.Custom("Registering view container"); this.RegisterViewContainer(); diff --git a/Daybreak/Launch/MainWindow.xaml.cs b/Daybreak/Launch/MainWindow.xaml.cs index d69b1d66..d17658ea 100644 --- a/Daybreak/Launch/MainWindow.xaml.cs +++ b/Daybreak/Launch/MainWindow.xaml.cs @@ -152,6 +152,11 @@ private async void UpdateRandomImage() return; } + if (!this.IsEnabled) + { + return; + } + var response = await this.backgroundProvider.GetBackground(); if (response.ImageSource is null) { diff --git a/Daybreak/Services/Window/IWindowEventsHook.cs b/Daybreak/Services/Window/IWindowEventsHook.cs new file mode 100644 index 00000000..7e69d153 --- /dev/null +++ b/Daybreak/Services/Window/IWindowEventsHook.cs @@ -0,0 +1,12 @@ +using System; + +namespace Daybreak.Services.Window; + +public interface IWindowEventsHook : IDisposable + where T : System.Windows.Window +{ + void RegisterHookOnSizeOrMoveBegin(Action hook); + void RegisterHookOnSizeOrMoveEnd(Action hook); + void UnregisterHookOnSizeOrMoveBegin(Action hook); + void UnregisterHookOnSizeOrMoveEnd(Action hook); +} diff --git a/Daybreak/Services/Window/WindowEventsHook.cs b/Daybreak/Services/Window/WindowEventsHook.cs new file mode 100644 index 00000000..04288685 --- /dev/null +++ b/Daybreak/Services/Window/WindowEventsHook.cs @@ -0,0 +1,126 @@ +using Daybreak.Behaviors; +using System; +using System.Collections.Generic; +using System.Core.Extensions; +using System.Extensions; +using System.Windows; +using System.Windows.Interop; + +namespace Daybreak.Services.Window; + +internal sealed class WindowEventsHook : IWindowEventsHook + where T : System.Windows.Window +{ + private const int WM_ENTERSIZEMOVE = 0x0231; + private const int WM_EXITSIZEMOVE = 0x0232; + + private readonly List sizeMoveBeginHooks = []; + private readonly List sizeMoveEndHooks = []; + private readonly T window; + + private HwndSource? hwndSource; + + public WindowEventsHook( + T window) + { + this.window = window.ThrowIfNull(); + + // If the window is loaded, hook directly. Otherwise, wait for the window to finish loading before hooking + if (this.window.IsLoaded) + { + this.HookIntoWindow(); + } + else + { + this.window.Loaded += this.MainWindow_Loaded; + } + } + + public void RegisterHookOnSizeOrMoveBegin(Action hook) + { + this.sizeMoveBeginHooks.Add(hook); + } + + public void RegisterHookOnSizeOrMoveEnd(Action hook) + { + this.sizeMoveEndHooks.Add(hook); + } + + public void UnregisterHookOnSizeOrMoveBegin(Action hook) + { + this.sizeMoveBeginHooks.Remove(hook); + } + + public void UnregisterHookOnSizeOrMoveEnd(Action hook) + { + this.sizeMoveEndHooks.Remove(hook); + } + + private void MainWindow_Loaded(object sender, System.Windows.RoutedEventArgs e) + { + this.HookIntoWindow(); + } + + private void HookIntoWindow() + { + this.hwndSource = PresentationSource.FromVisual(this.window).Cast(); + this.hwndSource.AddHook(this.WndProc); + var contentCache = this.window.Content.Cast(); + var decorator = new LayoutFreezeDecorator(); + this.window.Content = decorator; + decorator.Child = contentCache; + } + + private IntPtr WndProc(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled) + { + switch (msg) + { + case WM_ENTERSIZEMOVE: + // Disable layout changes while resizing + if (this.window.Content is LayoutFreezeDecorator freezeDecorator) + { + freezeDecorator.IsLayoutFrozen = true; + } + + this.window.Dispatcher.Invoke(() => + { + foreach (var action in this.sizeMoveBeginHooks) + { + action(); + } + }); + + break; + case WM_EXITSIZEMOVE: + // Re-enable layout changes after resizing is done + if (this.window.Content is LayoutFreezeDecorator freezeDecorator2) + { + freezeDecorator2.IsLayoutFrozen = false; + // Force layout recalculation to fit the new content to the new size + freezeDecorator2.InvalidateArrange(); + freezeDecorator2.InvalidateMeasure(); + freezeDecorator2.InvalidateVisual(); + } + + this.window.Dispatcher.Invoke(() => + { + foreach (var action in this.sizeMoveEndHooks) + { + action(); + } + }); + + break; + } + + return IntPtr.Zero; + } + + public void Dispose() + { + this.hwndSource?.Dispose(); + this.sizeMoveBeginHooks.Clear(); + this.sizeMoveEndHooks.Clear(); + this.window.Loaded -= this.MainWindow_Loaded; + } +} diff --git a/Daybreak/Views/FocusView.xaml.cs b/Daybreak/Views/FocusView.xaml.cs index dd77b0a7..7ecabc40 100644 --- a/Daybreak/Views/FocusView.xaml.cs +++ b/Daybreak/Views/FocusView.xaml.cs @@ -12,6 +12,7 @@ using Daybreak.Services.Notifications; using Daybreak.Services.Scanner; using Daybreak.Services.Screens; +using Daybreak.Services.Window; using Daybreak.Views.Trade; using Microsoft.Extensions.Logging; using System; @@ -47,6 +48,7 @@ public partial class FocusView : UserControl private static readonly TimeSpan InventoryDataFrequency = TimeSpan.FromSeconds(1); private static readonly TimeSpan CartoDataFrequency = TimeSpan.FromSeconds(1); + private readonly IWindowEventsHook mainWindowEventsHook; private readonly INotificationService notificationService; private readonly IBuildTemplateManager buildTemplateManager; private readonly IApplicationLauncher applicationLauncher; @@ -104,6 +106,10 @@ public partial class FocusView : UserControl [GenerateDependencyProperty] private CartoProgressContext cartoTitle = default!; + [GenerateDependencyProperty] + private bool pauseDataFetching; + + private IWindowEventsHook? minimapWindowEventsHook; private MinimapWindow? minimapWindow; private bool browserMaximized = false; private bool minimapMaximized = false; @@ -111,6 +117,7 @@ public partial class FocusView : UserControl private CancellationTokenSource? cancellationTokenSource; public FocusView( + IWindowEventsHook mainWindowEventsHook, INotificationService notificationService, IBuildTemplateManager buildTemplateManager, IApplicationLauncher applicationLauncher, @@ -123,6 +130,7 @@ public FocusView( ILiveUpdateableOptions minimapWindowOptions, ILogger logger) { + this.mainWindowEventsHook = mainWindowEventsHook.ThrowIfNull(); this.notificationService = notificationService.ThrowIfNull(); this.buildTemplateManager = buildTemplateManager.ThrowIfNull(); this.applicationLauncher = applicationLauncher.ThrowIfNull(); @@ -208,6 +216,12 @@ private async void PeriodicallyReadPathingData(CancellationToken cancellationTok return; } + if (this.PauseDataFetching) + { + await Task.Delay(PathingDataFrequency, cancellationToken); + continue; + } + if (this.DataContext is not GuildWarsApplicationLaunchContext context) { await Task.Delay(PathingDataFrequency, cancellationToken); @@ -282,6 +296,12 @@ private async void PeriodicallyReadGameState(CancellationToken cancellationToken return; } + if (this.PauseDataFetching) + { + await Task.Delay(GameStateFrequency, cancellationToken); + continue; + } + if (this.DataContext is not GuildWarsApplicationLaunchContext context) { await Task.Delay(GameStateFrequency, cancellationToken); @@ -361,6 +381,12 @@ private async void PeriodicallyReadCartoData(CancellationToken cancellationToken return; } + if (this.PauseDataFetching) + { + await Task.Delay(CartoDataFrequency, cancellationToken); + continue; + } + if (this.DataContext is not GuildWarsApplicationLaunchContext context) { await Task.Delay(CartoDataFrequency, cancellationToken); @@ -486,6 +512,12 @@ private async void PeriodicallyReadGameData(CancellationToken cancellationToken) return; } + if (this.PauseDataFetching) + { + await Task.Delay(GameDataFrequency, cancellationToken); + continue; + } + if (this.DataContext is not GuildWarsApplicationLaunchContext context) { await Task.Delay(GameDataFrequency, cancellationToken); @@ -590,6 +622,12 @@ private async void PeriodicallyReadInventoryData(CancellationToken cancellationT return; } + if (this.PauseDataFetching) + { + await Task.Delay(InventoryDataFrequency, cancellationToken); + continue; + } + if (this.DataContext is not GuildWarsApplicationLaunchContext context) { await Task.Delay(InventoryDataFrequency, cancellationToken); @@ -666,6 +704,12 @@ private async void PeriodicallyReadMainPlayerContextData(CancellationToken cance return; } + if (this.PauseDataFetching) + { + await Task.Delay(MainPlayerDataFrequency, cancellationToken); + continue; + } + if (this.DataContext is not GuildWarsApplicationLaunchContext context) { await Task.Delay(MainPlayerDataFrequency, cancellationToken); @@ -762,6 +806,8 @@ private async void FocusView_Loaded(object _, RoutedEventArgs e) return; } + this.mainWindowEventsHook.RegisterHookOnSizeOrMoveBegin(this.OnMainWindowSizeOrMoveStart); + this.mainWindowEventsHook.RegisterHookOnSizeOrMoveEnd(this.OnMainWindowSizeOrMoveEnd); this.Browser.BrowserHistoryManager.SetBrowserHistory(this.liveUpdateableOptions.Value.BrowserHistory); this.InventoryVisible = this.liveUpdateableOptions.Value.InventoryComponentVisible; this.MinimapVisible = this.liveUpdateableOptions.Value.MinimapComponentVisible; @@ -786,6 +832,8 @@ private async void FocusView_Loaded(object _, RoutedEventArgs e) private void FocusView_Unloaded(object _, RoutedEventArgs e) { + this.mainWindowEventsHook.UnregisterHookOnSizeOrMoveBegin(this.OnMainWindowSizeOrMoveStart); + this.mainWindowEventsHook.UnregisterHookOnSizeOrMoveEnd(this.OnMainWindowSizeOrMoveEnd); this.cancellationTokenSource?.Cancel(); this.cancellationTokenSource = null; this.GameData = default; @@ -993,6 +1041,9 @@ private void MinimapExtractButton_Clicked(object sender, EventArgs e) this.MinimapHolder.Children.Remove(this.MinimapComponent); this.minimapWindow.Content = this.MinimapComponent; this.minimapWindow.Show(); + this.minimapWindowEventsHook = new WindowEventsHook(this.minimapWindow); + this.minimapWindowEventsHook.RegisterHookOnSizeOrMoveBegin(this.OnMinimapWindowSizeOrMoveStart); + this.minimapWindowEventsHook.RegisterHookOnSizeOrMoveEnd(this.OnMinimapWindowSizeOrMoveEnd); this.RowAutoMargin.RecalculateRows(); } @@ -1026,7 +1077,31 @@ private void MinimapWindow_Closed(object? sender, EventArgs e) this.minimapWindow.Closed -= this.MinimapWindow_Closed; this.minimapWindow.Content = default!; this.minimapWindow = default; + this.minimapWindowEventsHook?.Dispose(); + this.minimapWindowEventsHook = default; this.MinimapHolder.Children.Insert(0, this.MinimapComponent); this.RowAutoMargin.RecalculateRows(); } + + private void OnMainWindowSizeOrMoveStart() + { + this.IsEnabled = false; + this.PauseDataFetching = true; + } + + private void OnMainWindowSizeOrMoveEnd() + { + this.IsEnabled = true; + this.PauseDataFetching = false; + } + + private void OnMinimapWindowSizeOrMoveStart() + { + this.PauseDataFetching = true; + } + + private void OnMinimapWindowSizeOrMoveEnd() + { + this.PauseDataFetching = false; + } } diff --git a/Daybreak/Views/LauncherView.xaml.cs b/Daybreak/Views/LauncherView.xaml.cs index 6f43a3fe..14c4c744 100644 --- a/Daybreak/Views/LauncherView.xaml.cs +++ b/Daybreak/Views/LauncherView.xaml.cs @@ -38,8 +38,8 @@ public partial class LauncherView : UserControl private readonly IViewManager viewManager; private readonly IScreenManager screenManager; private readonly ILiveOptions focusViewOptions; - private readonly CancellationTokenSource cancellationTokenSource = new(); + private CancellationTokenSource? cancellationTokenSource; private bool launching; [GenerateDependencyProperty] @@ -104,7 +104,7 @@ private void RetrieveLaunchConfigurations() private async Task PeriodicallyCheckSelectedConfigState() { - while (!this.cancellationTokenSource.IsCancellationRequested) + while (this.cancellationTokenSource is not null && !this.cancellationTokenSource.IsCancellationRequested) { if (!this.launching) { @@ -122,6 +122,7 @@ private void StartupView_Loaded(object sender, RoutedEventArgs e) return; } + this.cancellationTokenSource = new CancellationTokenSource(); this.RetrieveLaunchConfigurations(); new TaskFactory().StartNew(this.PeriodicallyCheckSelectedConfigState, this.cancellationTokenSource.Token, TaskCreationOptions.LongRunning, TaskScheduler.Current); }