diff --git a/CHANGELOG.md b/CHANGELOG.md index d9729dd5..b9b54098 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ ### Changelog +* Progress ring displays when downloading modlist images +* GUI releases memory of download modlists better when navigating around +* Fixed phrasing after failed installations to say "failed". + #### Version - 1.0 beta 15 - 1/6/2020 * Don't delete the download folder when deleting empty folders during an update * If `Game Folder Files` exists in the MO2 folder during compilation the Game folder will be ignored as a file source diff --git a/Wabbajack.Common/Extensions/RxExt.cs b/Wabbajack.Common/Extensions/RxExt.cs index bdefd987..e0ad02ce 100644 --- a/Wabbajack.Common/Extensions/RxExt.cs +++ b/Wabbajack.Common/Extensions/RxExt.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Linq; using System.Reactive; using System.Reactive.Concurrency; @@ -67,7 +67,7 @@ namespace Wabbajack /// On/Off signal of whether to subscribe to source observable /// Value to fire when switching off /// Observable that publishes data from source, if the switch is on. - public static IObservable FilterSwitch(this IObservable source, IObservable filterSwitch, T valueWhenOff) + public static IObservable FlowSwitch(this IObservable source, IObservable filterSwitch, T valueWhenOff) { return filterSwitch .DistinctUntilChanged() @@ -88,7 +88,7 @@ namespace Wabbajack /// Inspiration: /// http://reactivex.io/documentation/operators/debounce.html /// https://stackoverflow.com/questions/20034476/how-can-i-use-reactive-extensions-to-throttle-events-using-a-max-window-size - public static IObservable Debounce(this IObservable source, TimeSpan interval, IScheduler scheduler = null) + public static IObservable Debounce(this IObservable source, TimeSpan interval, IScheduler scheduler) { scheduler = scheduler ?? Scheduler.Default; return Observable.Create(o => @@ -209,15 +209,6 @@ namespace Wabbajack }); } - public static IObservable DelayInitial(this IObservable source, TimeSpan delay) - { - return source.FlowSwitch( - Observable.Return(System.Reactive.Unit.Default) - .Delay(delay) - .Select(_ => true) - .StartWith(false)); - } - public static IObservable DelayInitial(this IObservable source, TimeSpan delay, IScheduler scheduler) { return source.FlowSwitch( @@ -226,5 +217,21 @@ namespace Wabbajack .Select(_ => true) .StartWith(false)); } + + public static IObservable DisposeOld(this IObservable source) + where T : IDisposable + { + return source + .StartWith(default(T)) + .Pairwise() + .Do(x => + { + if (x.Previous != null) + { + x.Previous.Dispose(); + } + }) + .Select(x => x.Current); + } } } diff --git a/Wabbajack.Common/Utils.cs b/Wabbajack.Common/Utils.cs index 721a4d62..8da3094a 100644 --- a/Wabbajack.Common/Utils.cs +++ b/Wabbajack.Common/Utils.cs @@ -1131,9 +1131,7 @@ namespace Wabbajack.Common public static HashSet ToHashSet(this IEnumerable coll) { - var hs = new HashSet(); - coll.Do(v => hs.Add(v)); - return hs; + return new HashSet(coll); } public static HashSet ToHashSet(this T[] coll) diff --git a/Wabbajack.Lib/Extensions/ReactiveUIExt.cs b/Wabbajack.Lib/Extensions/ReactiveUIExt.cs index c18543da..eb2a79bc 100644 --- a/Wabbajack.Lib/Extensions/ReactiveUIExt.cs +++ b/Wabbajack.Lib/Extensions/ReactiveUIExt.cs @@ -2,10 +2,12 @@ using System.Linq; using System.Linq.Expressions; using System.Reactive; +using System.Reactive.Disposables; using System.Reactive.Linq; using DynamicData; using DynamicData.Kernel; using ReactiveUI; +using Wabbajack.Lib; namespace Wabbajack { @@ -78,6 +80,29 @@ namespace Wabbajack return new ChangeSet(changes); } + public static ObservableAsPropertyHelper ToGuiProperty( + this IObservable source, + ViewModel vm, + string property, + TRet initialValue = default, + bool deferSubscription = false) + { + return source + .ToProperty(vm, property, initialValue, deferSubscription, RxApp.MainThreadScheduler) + .DisposeWith(vm.CompositeDisposable); + } + + public static void ToGuiProperty( + this IObservable source, + ViewModel vm, + string property, + out ObservableAsPropertyHelper result, + TRet initialValue = default, + bool deferSubscription = false) + { + source.ToProperty(vm, property, out result, initialValue, deferSubscription, RxApp.MainThreadScheduler) + .DisposeWith(vm.CompositeDisposable); + } internal static Optional> Reduce(Optional> previous, Change next) { diff --git a/Wabbajack/UI/FilePickerVM.cs b/Wabbajack/UI/FilePickerVM.cs index 81c462de..4f20675a 100644 --- a/Wabbajack/UI/FilePickerVM.cs +++ b/Wabbajack/UI/FilePickerVM.cs @@ -80,7 +80,7 @@ namespace Wabbajack this.WhenAny(x => x.TargetPath) // Dont want to debounce the initial value, because we know it's null .Skip(1) - .Debounce(TimeSpan.FromMilliseconds(200), RxApp.TaskpoolScheduler) + .Debounce(TimeSpan.FromMilliseconds(200), RxApp.MainThreadScheduler) .StartWith(default(string)), resultSelector: (existsOption, type, path) => (ExistsOption: existsOption, Type: type, Path: path)) .StartWith((ExistsOption: ExistCheckOption, Type: PathType, Path: TargetPath)) @@ -147,9 +147,8 @@ namespace Wabbajack } }) .DistinctUntilChanged() - .ObserveOnGuiThread() .StartWith(false) - .ToProperty(this, nameof(Exists)); + .ToGuiProperty(this, nameof(Exists)); var passesFilters = Observable.CombineLatest( this.WhenAny(x => x.TargetPath), @@ -218,12 +217,11 @@ namespace Wabbajack if (filter.Failed) return filter; return ErrorResponse.Convert(err); }) - .ObserveOnGuiThread() - .ToProperty(this, nameof(ErrorState)); + .ToGuiProperty(this, nameof(ErrorState)); _inError = this.WhenAny(x => x.ErrorState) .Select(x => !x.Succeeded) - .ToProperty(this, nameof(InError)); + .ToGuiProperty(this, nameof(InError)); // Doesn't derive from ErrorState, as we want to bubble non-empty tooltips, // which is slightly different logic @@ -244,8 +242,7 @@ namespace Wabbajack if (!string.IsNullOrWhiteSpace(filters)) return filters; return err?.Reason; }) - .ObserveOnGuiThread() - .ToProperty(this, nameof(ErrorTooltip)); + .ToGuiProperty(this, nameof(ErrorTooltip)); } public ICommand ConstructTypicalPickerCommand() diff --git a/Wabbajack/View Models/BackNavigatingVM.cs b/Wabbajack/View Models/BackNavigatingVM.cs index 59408f39..ec1a16ac 100644 --- a/Wabbajack/View Models/BackNavigatingVM.cs +++ b/Wabbajack/View Models/BackNavigatingVM.cs @@ -35,9 +35,8 @@ namespace Wabbajack canExecute: this.ConstructCanNavigateBack() .ObserveOnGuiThread()); - _IsActive = mainWindowVM.WhenAny(x => x.ActivePane) - .Select(x => object.ReferenceEquals(this, x)) - .ToProperty(this, nameof(IsActive)); + _IsActive = this.ConstructIsActive(mainWindowVM) + .ToGuiProperty(this, nameof(IsActive)); } } @@ -48,5 +47,11 @@ namespace Wabbajack return vm.WhenAny(x => x.NavigateBackTarget) .Select(x => x != null); } + + public static IObservable ConstructIsActive(this IBackNavigatingVM vm, MainWindowVM mwvm) + { + return mwvm.WhenAny(x => x.ActivePane) + .Select(x => object.ReferenceEquals(vm, x)); + } } } diff --git a/Wabbajack/View Models/Compilers/CompilerVM.cs b/Wabbajack/View Models/Compilers/CompilerVM.cs index 066b2ba0..48d5dfdc 100644 --- a/Wabbajack/View Models/Compilers/CompilerVM.cs +++ b/Wabbajack/View Models/Compilers/CompilerVM.cs @@ -115,15 +115,15 @@ namespace Wabbajack pair.Previous?.Unload(); }) .Select(p => p.Current) - .ToProperty(this, nameof(Compiler)); + .ToGuiProperty(this, nameof(Compiler)); // Let sub VM determine what settings we're displaying and when _currentModlistSettings = this.WhenAny(x => x.Compiler.ModlistSettings) - .ToProperty(this, nameof(CurrentModlistSettings)); + .ToGuiProperty(this, nameof(CurrentModlistSettings)); _image = this.WhenAny(x => x.CurrentModlistSettings.ImagePath.TargetPath) // Throttle so that it only loads image after any sets of swaps have completed - .Throttle(TimeSpan.FromMilliseconds(50), RxApp.TaskpoolScheduler) + .Throttle(TimeSpan.FromMilliseconds(50), RxApp.MainThreadScheduler) .DistinctUntilChanged() .ObserveOnGuiThread() .Select(path => @@ -135,12 +135,11 @@ namespace Wabbajack } return null; }) - .ToProperty(this, nameof(Image)); + .ToGuiProperty(this, nameof(Image)); _compiling = this.WhenAny(x => x.Compiler.ActiveCompilation) .Select(compilation => compilation != null) - .ObserveOnGuiThread() - .ToProperty(this, nameof(Compiling)); + .ToGuiProperty(this, nameof(Compiling)); BackCommand = ReactiveCommand.Create( execute: () => @@ -175,8 +174,8 @@ namespace Wabbajack return compiler.PercentCompleted.StartWith(0); }) .Switch() - .Debounce(TimeSpan.FromMilliseconds(25)) - .ToProperty(this, nameof(PercentCompleted)); + .Debounce(TimeSpan.FromMilliseconds(25), RxApp.MainThreadScheduler) + .ToGuiProperty(this, nameof(PercentCompleted)); BeginCommand = ReactiveCommand.CreateFromTask( canExecute: this.WhenAny(x => x.Compiler.CanCompile) @@ -217,8 +216,7 @@ namespace Wabbajack _ActiveGlobalUserIntervention = activeInterventions.Connect() .Filter(x => x.CpuID == WorkQueue.UnassignedCpuId) .QueryWhenChanged(query => query.FirstOrDefault()) - .ObserveOnGuiThread() - .ToProperty(this, nameof(ActiveGlobalUserIntervention)); + .ToGuiProperty(this, nameof(ActiveGlobalUserIntervention)); CloseWhenCompleteCommand = ReactiveCommand.Create( canExecute: this.WhenAny(x => x.Completed) @@ -243,26 +241,31 @@ namespace Wabbajack } }); - _progressTitle = Observable.CombineLatest( - this.WhenAny(x => x.Compiling), - this.WhenAny(x => x.StartedCompilation), - resultSelector: (compiling, started) => + _progressTitle = this.WhenAnyValue( + x => x.Compiling, + x => x.StartedCompilation, + x => x.Completed, + selector: (compiling, started, completed) => { if (compiling) { return "Compiling"; } + else if (started) + { + if (completed == null) return "Compiling"; + return completed.Value.Succeeded ? "Compiled" : "Failed"; + } else { - return started ? "Compiled" : "Configuring"; + return "Configuring"; } }) - .ToProperty(this, nameof(ProgressTitle)); + .ToGuiProperty(this, nameof(ProgressTitle)); _CurrentCpuCount = this.WhenAny(x => x.Compiler.ActiveCompilation.Queue.CurrentCpuCount) .Switch() - .ObserveOnGuiThread() - .ToProperty(this, nameof(CurrentCpuCount)); + .ToGuiProperty(this, nameof(CurrentCpuCount)); } } } diff --git a/Wabbajack/View Models/Compilers/MO2CompilerVM.cs b/Wabbajack/View Models/Compilers/MO2CompilerVM.cs index 78128111..4f924ef7 100644 --- a/Wabbajack/View Models/Compilers/MO2CompilerVM.cs +++ b/Wabbajack/View Models/Compilers/MO2CompilerVM.cs @@ -68,7 +68,7 @@ namespace Wabbajack return null; } }) - .ToProperty(this, nameof(Mo2Folder)); + .ToGuiProperty(this, nameof(Mo2Folder)); _moProfile = this.WhenAny(x => x.ModListLocation.TargetPath) .Select(loc => { @@ -82,7 +82,7 @@ namespace Wabbajack return null; } }) - .ToProperty(this, nameof(MOProfile)); + .ToGuiProperty(this, nameof(MOProfile)); // Wire missing Mo2Folder to signal error state for ModList Location ModListLocation.AdditionalError = this.WhenAny(x => x.Mo2Folder) @@ -98,7 +98,7 @@ namespace Wabbajack (this).WhenAny(x => x.ModListLocation.TargetPath), resultSelector: (state, path) => (State: state, Path: path)) // A short throttle is a quick hack to make the above changes "atomic" - .Throttle(TimeSpan.FromMilliseconds(25)) + .Throttle(TimeSpan.FromMilliseconds(25), RxApp.MainThreadScheduler) .Select(u => { if (u.State.Failed) return null; @@ -116,9 +116,7 @@ namespace Wabbajack pair.Current?.Init(); }) .Select(x => x.Current) - // Save to property - .ObserveOnGuiThread() - .ToProperty(this, nameof(ModlistSettings)); + .ToGuiProperty(this, nameof(ModlistSettings)); CanCompile = Observable.CombineLatest( this.WhenAny(x => x.ModListLocation.InError), @@ -143,8 +141,8 @@ namespace Wabbajack .DisposeWith(CompositeDisposable); // If Mo2 folder changes and download location is empty, set it for convenience - (this).WhenAny(x => x.Mo2Folder) - .DelayInitial(TimeSpan.FromMilliseconds(100)) + this.WhenAny(x => x.Mo2Folder) + .DelayInitial(TimeSpan.FromMilliseconds(100), RxApp.MainThreadScheduler) .Where(x => Directory.Exists(x)) .FlowSwitch( (this).WhenAny(x => x.DownloadLocation.Exists) diff --git a/Wabbajack/View Models/Compilers/VortexCompilerVM.cs b/Wabbajack/View Models/Compilers/VortexCompilerVM.cs index e3498651..08dcc7b8 100644 --- a/Wabbajack/View Models/Compilers/VortexCompilerVM.cs +++ b/Wabbajack/View Models/Compilers/VortexCompilerVM.cs @@ -94,9 +94,7 @@ namespace Wabbajack current?.Init(); }) .Select(x => x.Current) - // Save to property - .ObserveOnGuiThread() - .ToProperty(this, nameof(ModlistSettings)); + .ToGuiProperty(this, nameof(ModlistSettings)); CanCompile = Observable.CombineLatest( this.WhenAny(x => x.GameLocation.InError), diff --git a/Wabbajack/View Models/Installers/InstallerVM.cs b/Wabbajack/View Models/Installers/InstallerVM.cs index 8378c9be..f1dbf785 100644 --- a/Wabbajack/View Models/Installers/InstallerVM.cs +++ b/Wabbajack/View Models/Installers/InstallerVM.cs @@ -30,8 +30,8 @@ namespace Wabbajack public MainWindowVM MWVM { get; } - public BitmapImage WabbajackLogo { get; } = UIUtils.BitmapImageFromStream(Application.GetResourceStream(new Uri("pack://application:,,,/Wabbajack;component/Resources/Wabba_Mouth_No_Text.png")).Stream); - public BitmapImage WabbajackErrLogo { get; } = UIUtils.BitmapImageFromStream(Application.GetResourceStream(new Uri("pack://application:,,,/Wabbajack;component/Resources/Wabba_Ded.png")).Stream); + public static BitmapImage WabbajackLogo { get; } = UIUtils.BitmapImageFromStream(Application.GetResourceStream(new Uri("pack://application:,,,/Wabbajack;component/Resources/Wabba_Mouth_No_Text.png")).Stream); + public static BitmapImage WabbajackErrLogo { get; } = UIUtils.BitmapImageFromStream(Application.GetResourceStream(new Uri("pack://application:,,,/Wabbajack;component/Resources/Wabba_Ded.png")).Stream); private readonly ObservableAsPropertyHelper _modList; public ModListVM ModList => _modList.Value; @@ -89,6 +89,12 @@ namespace Wabbajack private readonly ObservableAsPropertyHelper<(int CurrentCPUs, int DesiredCPUs)> _CurrentCpuCount; public (int CurrentCPUs, int DesiredCPUs) CurrentCpuCount => _CurrentCpuCount.Value; + private readonly ObservableAsPropertyHelper _LoadingModlist; + public bool LoadingModlist => _LoadingModlist.Value; + + private readonly ObservableAsPropertyHelper _IsActive; + public bool IsActive => _IsActive.Value; + // Command properties public IReactiveCommand ShowReportCommand { get; } public IReactiveCommand OpenReadmeCommand { get; } @@ -141,7 +147,7 @@ namespace Wabbajack pair.Previous?.Unload(); }) .Select(p => p.Current) - .ToProperty(this, nameof(Installer)); + .ToGuiProperty(this, nameof(Installer)); // Load settings MWVM.Settings.SaveSignal @@ -151,27 +157,64 @@ namespace Wabbajack }) .DisposeWith(CompositeDisposable); - _modList = this.WhenAny(x => x.ModListLocation.TargetPath) + _IsActive = this.ConstructIsActive(MWVM) + .ToGuiProperty(this, nameof(IsActive)); + + // Active path represents the path to currently have loaded + // If we're not actively showing, then "unload" the active path + var activePath = Observable.CombineLatest( + this.WhenAny(x => x.ModListLocation.TargetPath), + this.WhenAny(x => x.IsActive), + resultSelector: (path, active) => (path, active)) + .Select(x => + { + if (!x.active) return default(string); + return x.path; + }) + // Throttle slightly so changes happen more atomically + .Throttle(TimeSpan.FromMilliseconds(50), RxApp.MainThreadScheduler) + .Replay(1) + .RefCount(); + + _modList = activePath .ObserveOn(RxApp.TaskpoolScheduler) + // Convert from active path to modlist VM .Select(modListPath => { if (modListPath == null) return default(ModListVM); if (!File.Exists(modListPath)) return default(ModListVM); return new ModListVM(modListPath); }) + .DisposeOld() .ObserveOnGuiThread() .StartWith(default(ModListVM)) - .ToProperty(this, nameof(ModList)); + .ToGuiProperty(this, nameof(ModList)); + + // Force GC collect when modlist changes, just to make sure we clean up any loose large items immediately + this.WhenAny(x => x.ModList) + .Delay(TimeSpan.FromMilliseconds(50), RxApp.MainThreadScheduler) + .Subscribe(x => + { + GC.Collect(); + }); + + _LoadingModlist = Observable.Merge( + // When active path changes, mark as loading + activePath + .Select(_ => true), + // When the resulting modlist comes in, mark it as done + this.WhenAny(x => x.ModList) + .Select(_ => false)) + .ToGuiProperty(this, nameof(LoadingModlist)); _htmlReport = this.WhenAny(x => x.ModList) .Select(modList => modList?.ReportHTML) - .ToProperty(this, nameof(HTMLReport)); + .ToGuiProperty(this, nameof(HTMLReport)); _installing = this.WhenAny(x => x.Installer.ActiveInstallation) .Select(i => i != null) - .ObserveOnGuiThread() - .ToProperty(this, nameof(Installing)); + .ToGuiProperty(this, nameof(Installing)); _TargetManager = this.WhenAny(x => x.ModList) .Select(modList => modList?.ModManager) - .ToProperty(this, nameof(TargetManager)); + .ToGuiProperty(this, nameof(TargetManager)); // Add additional error check on ModList ModListLocation.AdditionalError = this.WhenAny(x => x.ModList) @@ -209,8 +252,8 @@ namespace Wabbajack return installer.PercentCompleted.StartWith(0f); }) .Switch() - .Debounce(TimeSpan.FromMilliseconds(25)) - .ToProperty(this, nameof(PercentCompleted)); + .Debounce(TimeSpan.FromMilliseconds(25), RxApp.MainThreadScheduler) + .ToGuiProperty(this, nameof(PercentCompleted)); Slideshow = new SlideShow(this); @@ -219,23 +262,24 @@ namespace Wabbajack _image = Observable.CombineLatest( this.WhenAny(x => x.ModList.Error), this.WhenAny(x => x.ModList) - .Select(x => x?.ImageObservable ?? Observable.Return(WabbajackLogo)) + .Select(x => x?.ImageObservable ?? Observable.Return(default(BitmapImage))) .Switch() - .StartWith(WabbajackLogo), + .StartWith(default(BitmapImage)), this.WhenAny(x => x.Slideshow.Image) .StartWith(default(BitmapImage)), this.WhenAny(x => x.Installing), - resultSelector: (err, modList, slideshow, installing) => + this.WhenAny(x => x.LoadingModlist), + resultSelector: (err, modList, slideshow, installing, loading) => { if (err != null) { return WabbajackErrLogo; } - var ret = installing ? slideshow : modList; - return ret ?? WabbajackLogo; + if (loading) return default; + return installing ? slideshow : modList; }) .Select(x => x) - .ToProperty(this, nameof(Image)); + .ToGuiProperty(this, nameof(Image)); _titleText = Observable.CombineLatest( this.WhenAny(x => x.ModList) .Select(modList => modList?.Name ?? string.Empty), @@ -243,7 +287,7 @@ namespace Wabbajack .StartWith(default(string)), this.WhenAny(x => x.Installing), resultSelector: (modList, mod, installing) => installing ? mod : modList) - .ToProperty(this, nameof(TitleText)); + .ToGuiProperty(this, nameof(TitleText)); _authorText = Observable.CombineLatest( this.WhenAny(x => x.ModList) .Select(modList => modList?.Author ?? string.Empty), @@ -251,7 +295,7 @@ namespace Wabbajack .StartWith(default(string)), this.WhenAny(x => x.Installing), resultSelector: (modList, mod, installing) => installing ? mod : modList) - .ToProperty(this, nameof(AuthorText)); + .ToGuiProperty(this, nameof(AuthorText)); _description = Observable.CombineLatest( this.WhenAny(x => x.ModList) .Select(modList => modList?.Description ?? string.Empty), @@ -259,7 +303,7 @@ namespace Wabbajack .StartWith(default(string)), this.WhenAny(x => x.Installing), resultSelector: (modList, mod, installing) => installing ? mod : modList) - .ToProperty(this, nameof(Description)); + .ToGuiProperty(this, nameof(Description)); _modListName = Observable.CombineLatest( this.WhenAny(x => x.ModList.Error) .Select(x => x != null), @@ -270,7 +314,7 @@ namespace Wabbajack if (err) return "Corrupted Modlist"; return name; }) - .ToProperty(this, nameof(ModListName)); + .ToGuiProperty(this, nameof(ModListName)); // Define commands ShowReportCommand = ReactiveCommand.Create(ShowReport); @@ -285,21 +329,27 @@ namespace Wabbajack .Select(x => x?.StartsWith("https://") ?? false) .ObserveOnGuiThread()); - _progressTitle = Observable.CombineLatest( - this.WhenAny(x => x.Installing), - this.WhenAny(x => x.StartedInstallation), - resultSelector: (installing, started) => + _progressTitle = this.WhenAnyValue( + x => x.Installing, + x => x.StartedInstallation, + x => x.Completed, + selector: (installing, started, completed) => { if (installing) { return "Installing"; } + else if (started) + { + if (completed == null) return "Installing"; + return completed.Value.Succeeded ? "Installed" : "Failed"; + } else { - return started ? "Installed" : "Configuring"; + return "Configuring"; } }) - .ToProperty(this, nameof(ProgressTitle)); + .ToGuiProperty(this, nameof(ProgressTitle)); UIUtils.BindCpuStatus( this.WhenAny(x => x.Installer.ActiveInstallation) @@ -354,8 +404,7 @@ namespace Wabbajack _ActiveGlobalUserIntervention = activeInterventions.Connect() .Filter(x => x.CpuID == WorkQueue.UnassignedCpuId) .QueryWhenChanged(query => query.FirstOrDefault()) - .ObserveOnGuiThread() - .ToProperty(this, nameof(ActiveGlobalUserIntervention)); + .ToGuiProperty(this, nameof(ActiveGlobalUserIntervention)); CloseWhenCompleteCommand = ReactiveCommand.Create( canExecute: this.WhenAny(x => x.Completed) @@ -378,8 +427,7 @@ namespace Wabbajack _CurrentCpuCount = this.WhenAny(x => x.Installer.ActiveInstallation.Queue.CurrentCpuCount) .Switch() - .ObserveOnGuiThread() - .ToProperty(this, nameof(CurrentCpuCount)); + .ToGuiProperty(this, nameof(CurrentCpuCount)); } private void ShowReport() diff --git a/Wabbajack/View Models/Installers/MO2InstallerVM.cs b/Wabbajack/View Models/Installers/MO2InstallerVM.cs index 5d52714a..8ced7dd8 100644 --- a/Wabbajack/View Models/Installers/MO2InstallerVM.cs +++ b/Wabbajack/View Models/Installers/MO2InstallerVM.cs @@ -88,7 +88,7 @@ namespace Wabbajack // Load settings _CurrentSettings = installerVM.WhenAny(x => x.ModListLocation.TargetPath) .Select(path => path == null ? null : installerVM.MWVM.Settings.Installer.Mo2ModlistSettings.TryCreate(path)) - .ToProperty(this, nameof(CurrentSettings)); + .ToGuiProperty(this, nameof(CurrentSettings)); this.WhenAny(x => x.CurrentSettings) .Pairwise() .Subscribe(settingsPair => diff --git a/Wabbajack/View Models/Installers/VortexInstallerVM.cs b/Wabbajack/View Models/Installers/VortexInstallerVM.cs index f4606f07..352aa7b2 100644 --- a/Wabbajack/View Models/Installers/VortexInstallerVM.cs +++ b/Wabbajack/View Models/Installers/VortexInstallerVM.cs @@ -39,7 +39,7 @@ namespace Wabbajack Parent = installerVM; _TargetGame = installerVM.WhenAny(x => x.ModList.SourceModList.GameType) - .ToProperty(this, nameof(TargetGame)); + .ToGuiProperty(this, nameof(TargetGame)); CanInstall = Observable.CombineLatest( this.WhenAny(x => x.TargetGame) diff --git a/Wabbajack/View Models/ModListGalleryVM.cs b/Wabbajack/View Models/ModListGalleryVM.cs index 84ff0a96..5204b99c 100644 --- a/Wabbajack/View Models/ModListGalleryVM.cs +++ b/Wabbajack/View Models/ModListGalleryVM.cs @@ -21,30 +21,43 @@ namespace Wabbajack public ObservableCollectionExtended ModLists { get; } = new ObservableCollectionExtended(); - public IReactiveCommand RefreshCommand { get; } - private int missingHashFallbackCounter; public ModListGalleryVM(MainWindowVM mainWindowVM) : base(mainWindowVM) { MWVM = mainWindowVM; - RefreshCommand = ReactiveCommand.Create(() => { }); - RefreshCommand.StartingExecution() - .StartWith(Unit.Default) + Observable.Return(Unit.Default) .ObserveOn(RxApp.TaskpoolScheduler) .SelectTask(async _ => { return (await ModlistMetadata.LoadFromGithub()) .AsObservableChangeSet(x => x.DownloadMetadata?.Hash ?? $"Fallback{missingHashFallbackCounter++}"); }) + // Unsubscribe and release when not active + .FlowSwitch( + this.WhenAny(x => x.IsActive), + valueWhenOff: Observable.Return(ChangeSet.Empty)) + // Convert to VM and bind to resulting list .Switch() .ObserveOnGuiThread() .Transform(m => new ModListMetadataVM(this, m)) + .DisposeMany() .Bind(ModLists) .Subscribe() .DisposeWith(CompositeDisposable); + + // Extra GC when navigating away, just to immediately clean up modlist metadata + this.WhenAny(x => x.IsActive) + .Where(x => !x) + .Skip(1) + .Delay(TimeSpan.FromMilliseconds(50), RxApp.MainThreadScheduler) + .Subscribe(_ => + { + GC.Collect(); + }) + .DisposeWith(CompositeDisposable); } } } diff --git a/Wabbajack/View Models/ModListMetadataVM.cs b/Wabbajack/View Models/ModListMetadataVM.cs index 6355da64..9b72bed2 100644 --- a/Wabbajack/View Models/ModListMetadataVM.cs +++ b/Wabbajack/View Models/ModListMetadataVM.cs @@ -45,6 +45,9 @@ namespace Wabbajack private readonly ObservableAsPropertyHelper _Image; public BitmapImage Image => _Image.Value; + private readonly ObservableAsPropertyHelper _LoadingImage; + public bool LoadingImage => _LoadingImage.Value; + public ModListMetadataVM(ModListGalleryVM parent, ModlistMetadata metadata) { _parent = parent; @@ -119,11 +122,18 @@ namespace Wabbajack return true; } }) - .ToProperty(this, nameof(Exists)); + .ToGuiProperty(this, nameof(Exists)); - _Image = Observable.Return(Metadata.Links.ImageUri) - .DownloadBitmapImage((ex) => Utils.Log($"Error downloading modlist image {Metadata.Title}")) - .ToProperty(this, nameof(Image)); + var imageObs = Observable.Return(Metadata.Links.ImageUri) + .DownloadBitmapImage((ex) => Utils.Log($"Error downloading modlist image {Metadata.Title}")); + + _Image = imageObs + .ToGuiProperty(this, nameof(Image)); + + _LoadingImage = imageObs + .Select(x => false) + .StartWith(true) + .ToGuiProperty(this, nameof(LoadingImage)); } private Task Download() diff --git a/Wabbajack/View Models/ModListVM.cs b/Wabbajack/View Models/ModListVM.cs index b08f933f..4bdb4009 100644 --- a/Wabbajack/View Models/ModListVM.cs +++ b/Wabbajack/View Models/ModListVM.cs @@ -13,7 +13,7 @@ namespace Wabbajack { public class ModListVM : ViewModel { - public ModList SourceModList { get; } + public ModList SourceModList { get; private set; } public Exception Error { get; } public string ModListPath { get; } public string Name => SourceModList?.Name; @@ -42,6 +42,7 @@ namespace Wabbajack } ImageObservable = Observable.Return(Unit.Default) + // Download and retrieve bytes on background thread .ObserveOn(RxApp.TaskpoolScheduler) .Select(filePath => { @@ -66,6 +67,7 @@ namespace Wabbajack return default(MemoryStream); } }) + // Create Bitmap image on GUI thread .ObserveOnGuiThread() .Select(memStream => { @@ -80,6 +82,11 @@ namespace Wabbajack return default(BitmapImage); } }) + // If ever would return null, show WJ logo instead + .Select(x => + { + return x ?? InstallerVM.WabbajackLogo; + }) .Replay(1) .RefCount(); } @@ -116,5 +123,13 @@ namespace Wabbajack } } } + + public override void Dispose() + { + base.Dispose(); + // Just drop reference explicitly, as it's large, so it can be GCed + // Even if someone is holding a stale reference to the VM + this.SourceModList = null; + } } } diff --git a/Wabbajack/View Models/Settings/LoginManagerVM.cs b/Wabbajack/View Models/Settings/LoginManagerVM.cs index a30cd5e4..1487adf7 100644 --- a/Wabbajack/View Models/Settings/LoginManagerVM.cs +++ b/Wabbajack/View Models/Settings/LoginManagerVM.cs @@ -36,8 +36,7 @@ namespace Wabbajack { Login = login; _MetaInfo = (login.MetaInfo ?? Observable.Return("")) - .ObserveOnGuiThread() - .ToProperty(this, nameof(MetaInfo)); + .ToGuiProperty(this, nameof(MetaInfo)); } } } diff --git a/Wabbajack/View Models/SlideShow.cs b/Wabbajack/View Models/SlideShow.cs index 625eb603..7a64cacd 100644 --- a/Wabbajack/View Models/SlideShow.cs +++ b/Wabbajack/View Models/SlideShow.cs @@ -66,7 +66,7 @@ namespace Wabbajack this.WhenAny(x => x.Installer.Installing), resultSelector: (enabled, installing) => enabled && installing)) // Block spam - .Debounce(TimeSpan.FromMilliseconds(250)) + .Debounce(TimeSpan.FromMilliseconds(250), RxApp.MainThreadScheduler) .Scan( seed: 0, accumulator: (i, _) => i + 1) @@ -80,19 +80,20 @@ namespace Wabbajack { if (modList?.SourceModList?.Archives == null) { - return Observable.Empty() + return Observable.Empty() .ToObservableChangeSet(x => x.ModID); } return modList.SourceModList.Archives .Select(m => m.State) .OfType() - .Select(nexus => new ModVM(nexus)) // Shuffle it .Shuffle(_random) .AsObservableChangeSet(x => x.ModID); }) // Switch to the new list after every ModList change .Switch() + .Transform(nexus => new ModVM(nexus)) + .DisposeMany() // Filter out any NSFW slides if we don't want them .AutoRefreshOnObservable(slide => this.WhenAny(x => x.ShowNSFW)) .Filter(slide => !slide.IsNSFW || ShowNSFW) @@ -108,15 +109,14 @@ namespace Wabbajack return query.Items.ElementAtOrDefault(index); }) .StartWith(default(ModVM)) - .ObserveOnGuiThread() - .ToProperty(this, nameof(TargetMod)); + .ToGuiProperty(this, nameof(TargetMod)); // Mark interest and materialize image of target mod _image = this.WhenAny(x => x.TargetMod) // We want to Switch here, not SelectMany, as we want to hotswap to newest target without waiting on old ones .Select(x => x?.ImageObservable ?? Observable.Return(default(BitmapImage))) .Switch() - .ToProperty(this, nameof(Image)); + .ToGuiProperty(this, nameof(Image)); VisitNexusSiteCommand = ReactiveCommand.Create( execute: () => Process.Start(TargetMod.ModURL), diff --git a/Wabbajack/Views/Common/DetailImageView.xaml b/Wabbajack/Views/Common/DetailImageView.xaml index 43bd46f1..476a69ef 100644 --- a/Wabbajack/Views/Common/DetailImageView.xaml +++ b/Wabbajack/Views/Common/DetailImageView.xaml @@ -4,10 +4,12 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:icon="http://metro.mahapps.com/winfx/xaml/iconpacks" + xmlns:lib="clr-namespace:Wabbajack.Lib;assembly=Wabbajack.Lib" xmlns:local="clr-namespace:Wabbajack" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" d:DesignHeight="450" d:DesignWidth="800" + x:TypeArguments="lib:ViewModel" ClipToBounds="True" mc:Ignorable="d"> diff --git a/Wabbajack/Views/Common/DetailImageView.xaml.cs b/Wabbajack/Views/Common/DetailImageView.xaml.cs index 4fe4a7f4..96357d9f 100644 --- a/Wabbajack/Views/Common/DetailImageView.xaml.cs +++ b/Wabbajack/Views/Common/DetailImageView.xaml.cs @@ -1,15 +1,20 @@ using ReactiveUI; +using ReactiveUI.Fody.Helpers; +using System; using System.Linq; +using System.Reactive; +using System.Reactive.Disposables; using System.Reactive.Linq; using System.Windows; using System.Windows.Media; +using Wabbajack.Lib; namespace Wabbajack { /// /// Interaction logic for DetailImageView.xaml /// - public partial class DetailImageView : UserControlRx + public partial class DetailImageView : UserControlRx { public ImageSource Image { @@ -51,30 +56,36 @@ namespace Wabbajack public static readonly DependencyProperty DescriptionProperty = DependencyProperty.Register(nameof(Description), typeof(string), typeof(DetailImageView), new FrameworkPropertyMetadata(default(string), FrameworkPropertyMetadataOptions.BindsTwoWayByDefault, WireNotifyPropertyChanged)); - private readonly ObservableAsPropertyHelper _showAuthor; - public bool ShowAuthor => _showAuthor.Value; + [Reactive] + public bool ShowAuthor { get; private set; } - private readonly ObservableAsPropertyHelper _showDescription; - public bool ShowDescription => _showDescription.Value; + [Reactive] + public bool ShowDescription { get; private set; } - private readonly ObservableAsPropertyHelper _showTitle; - public bool ShowTitle => _showTitle.Value; + [Reactive] + public bool ShowTitle { get; private set; } public DetailImageView() { InitializeComponent(); - _showAuthor = this.WhenAny(x => x.Author) - .Select(x => !string.IsNullOrWhiteSpace(x)) - .ToProperty(this, nameof(ShowAuthor)); + this.WhenActivated(dispose => + { + this.WhenAny(x => x.Author) + .Select(x => !string.IsNullOrWhiteSpace(x)) + .Subscribe(x => ShowAuthor = x) + .DisposeWith(dispose); - _showDescription = this.WhenAny(x => x.Description) - .Select(x => !string.IsNullOrWhiteSpace(x)) - .ToProperty(this, nameof(ShowDescription)); + this.WhenAny(x => x.Description) + .Select(x => !string.IsNullOrWhiteSpace(x)) + .Subscribe(x => ShowDescription = x) + .DisposeWith(dispose); - _showTitle = this.WhenAny(x => x.Title) - .Select(x => !string.IsNullOrWhiteSpace(x)) - .ToProperty(this, nameof(ShowTitle)); + this.WhenAny(x => x.Title) + .Select(x => !string.IsNullOrWhiteSpace(x)) + .Subscribe(x => ShowTitle = x) + .DisposeWith(dispose); + }); } } } diff --git a/Wabbajack/Views/Common/TopProgressView.xaml b/Wabbajack/Views/Common/TopProgressView.xaml index 6f362405..a47aa8d3 100644 --- a/Wabbajack/Views/Common/TopProgressView.xaml +++ b/Wabbajack/Views/Common/TopProgressView.xaml @@ -3,11 +3,13 @@ xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" + xmlns:lib="clr-namespace:Wabbajack.Lib;assembly=Wabbajack.Lib" xmlns:local="clr-namespace:Wabbajack" xmlns:mahapps="clr-namespace:MahApps.Metro.Controls;assembly=MahApps.Metro" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" d:DesignHeight="450" d:DesignWidth="800" + x:TypeArguments="lib:ViewModel" BorderThickness="0" mc:Ignorable="d"> diff --git a/Wabbajack/Views/Common/TopProgressView.xaml.cs b/Wabbajack/Views/Common/TopProgressView.xaml.cs index 79cc9640..8a150a5f 100644 --- a/Wabbajack/Views/Common/TopProgressView.xaml.cs +++ b/Wabbajack/Views/Common/TopProgressView.xaml.cs @@ -2,13 +2,17 @@ using System.Windows; using System.Windows.Controls; using ReactiveUI; +using System; +using ReactiveUI.Fody.Helpers; +using Wabbajack.Lib; +using System.Reactive.Disposables; namespace Wabbajack { /// /// Interaction logic for TopProgressView.xaml /// - public partial class TopProgressView : UserControlRx + public partial class TopProgressView : UserControlRx { public double ProgressPercent { @@ -50,18 +54,22 @@ namespace Wabbajack public static readonly DependencyProperty ShadowMarginProperty = DependencyProperty.Register(nameof(ShadowMargin), typeof(bool), typeof(TopProgressView), new FrameworkPropertyMetadata(true)); - private readonly ObservableAsPropertyHelper _ProgressOpacityPercent; - public double ProgressOpacityPercent => _ProgressOpacityPercent.Value; + [Reactive] + public double ProgressOpacityPercent { get; private set; } public TopProgressView() { InitializeComponent(); - _ProgressOpacityPercent = this.WhenAny(x => x.ProgressPercent) - .Select(x => - { - return 0.3 + x * 0.7; - }) - .ToProperty(this, nameof(ProgressOpacityPercent)); + this.WhenActivated(dispose => + { + this.WhenAny(x => x.ProgressPercent) + .Select(x => + { + return 0.3 + x * 0.7; + }) + .Subscribe(x => ProgressOpacityPercent = x) + .DisposeWith(dispose); + }); } } } diff --git a/Wabbajack/Views/Installers/InstallationView.xaml b/Wabbajack/Views/Installers/InstallationView.xaml index cd4b479e..f3f11ee4 100644 --- a/Wabbajack/Views/Installers/InstallationView.xaml +++ b/Wabbajack/Views/Installers/InstallationView.xaml @@ -7,6 +7,7 @@ xmlns:icon="http://metro.mahapps.com/winfx/xaml/iconpacks" xmlns:lib="clr-namespace:Wabbajack.Lib;assembly=Wabbajack.Lib" xmlns:local="clr-namespace:Wabbajack" + xmlns:mahapps="clr-namespace:MahApps.Metro.Controls;assembly=MahApps.Metro" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" d:DataContext="{d:DesignInstance local:InstallerVM}" d:DesignHeight="500" @@ -48,11 +49,14 @@ Grid.Row="1" Margin="5,0,5,5"> - + + + + diff --git a/Wabbajack/Views/ModListTileView.xaml b/Wabbajack/Views/ModListTileView.xaml index dcee13dc..cb642064 100644 --- a/Wabbajack/Views/ModListTileView.xaml +++ b/Wabbajack/Views/ModListTileView.xaml @@ -86,6 +86,7 @@ BorderBrush="{StaticResource ButtonNormalBorder}" BorderThickness="0,0,0,1"> +