Merge pull request #391 from Noggog/image-loading-gui-GC

Image Loading Display and GUI GC correctness pass
This commit is contained in:
Timothy Baldridge 2020-01-17 08:11:52 -07:00 committed by GitHub
commit f959c3871e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
26 changed files with 294 additions and 174 deletions

View File

@ -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

View File

@ -1,4 +1,4 @@
using System;
using System;
using System.Linq;
using System.Reactive;
using System.Reactive.Concurrency;
@ -67,7 +67,7 @@ namespace Wabbajack
/// <param name="filterSwitch">On/Off signal of whether to subscribe to source observable</param>
/// <param name="valueOnOff">Value to fire when switching off</param>
/// <returns>Observable that publishes data from source, if the switch is on.</returns>
public static IObservable<T> FilterSwitch<T>(this IObservable<T> source, IObservable<bool> filterSwitch, T valueWhenOff)
public static IObservable<T> FlowSwitch<T>(this IObservable<T> source, IObservable<bool> 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<T> Debounce<T>(this IObservable<T> source, TimeSpan interval, IScheduler scheduler = null)
public static IObservable<T> Debounce<T>(this IObservable<T> source, TimeSpan interval, IScheduler scheduler)
{
scheduler = scheduler ?? Scheduler.Default;
return Observable.Create<T>(o =>
@ -209,15 +209,6 @@ namespace Wabbajack
});
}
public static IObservable<T> DelayInitial<T>(this IObservable<T> source, TimeSpan delay)
{
return source.FlowSwitch(
Observable.Return(System.Reactive.Unit.Default)
.Delay(delay)
.Select(_ => true)
.StartWith(false));
}
public static IObservable<T> DelayInitial<T>(this IObservable<T> source, TimeSpan delay, IScheduler scheduler)
{
return source.FlowSwitch(
@ -226,5 +217,21 @@ namespace Wabbajack
.Select(_ => true)
.StartWith(false));
}
public static IObservable<T> DisposeOld<T>(this IObservable<T> source)
where T : IDisposable
{
return source
.StartWith(default(T))
.Pairwise()
.Do(x =>
{
if (x.Previous != null)
{
x.Previous.Dispose();
}
})
.Select(x => x.Current);
}
}
}

View File

@ -1131,9 +1131,7 @@ namespace Wabbajack.Common
public static HashSet<T> ToHashSet<T>(this IEnumerable<T> coll)
{
var hs = new HashSet<T>();
coll.Do(v => hs.Add(v));
return hs;
return new HashSet<T>(coll);
}
public static HashSet<T> ToHashSet<T>(this T[] coll)

View File

@ -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<TObject, TKey>(changes);
}
public static ObservableAsPropertyHelper<TRet> ToGuiProperty<TRet>(
this IObservable<TRet> 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<TRet>(
this IObservable<TRet> source,
ViewModel vm,
string property,
out ObservableAsPropertyHelper<TRet> result,
TRet initialValue = default,
bool deferSubscription = false)
{
source.ToProperty(vm, property, out result, initialValue, deferSubscription, RxApp.MainThreadScheduler)
.DisposeWith(vm.CompositeDisposable);
}
internal static Optional<Change<TObject, TKey>> Reduce<TObject, TKey>(Optional<Change<TObject, TKey>> previous, Change<TObject, TKey> next)
{

View File

@ -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()

View File

@ -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<bool> ConstructIsActive(this IBackNavigatingVM vm, MainWindowVM mwvm)
{
return mwvm.WhenAny(x => x.ActivePane)
.Select(x => object.ReferenceEquals(vm, x));
}
}
}

View File

@ -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));
}
}
}

View File

@ -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)

View File

@ -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),

View File

@ -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<ModListVM> _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<bool> _LoadingModlist;
public bool LoadingModlist => _LoadingModlist.Value;
private readonly ObservableAsPropertyHelper<bool> _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<BitmapImage, ImageSource>(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()

View File

@ -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 =>

View File

@ -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)

View File

@ -21,30 +21,43 @@ namespace Wabbajack
public ObservableCollectionExtended<ModListMetadataVM> ModLists { get; } = new ObservableCollectionExtended<ModListMetadataVM>();
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<ModlistMetadata, string>.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);
}
}
}

View File

@ -45,6 +45,9 @@ namespace Wabbajack
private readonly ObservableAsPropertyHelper<BitmapImage> _Image;
public BitmapImage Image => _Image.Value;
private readonly ObservableAsPropertyHelper<bool> _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()

View File

@ -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;
}
}
}

View File

@ -36,8 +36,7 @@ namespace Wabbajack
{
Login = login;
_MetaInfo = (login.MetaInfo ?? Observable.Return(""))
.ObserveOnGuiThread()
.ToProperty(this, nameof(MetaInfo));
.ToGuiProperty(this, nameof(MetaInfo));
}
}
}

View File

@ -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<ModVM>()
return Observable.Empty<NexusDownloader.State>()
.ToObservableChangeSet(x => x.ModID);
}
return modList.SourceModList.Archives
.Select(m => m.State)
.OfType<NexusDownloader.State>()
.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),

View File

@ -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">
<UserControl.Resources>

View File

@ -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
{
/// <summary>
/// Interaction logic for DetailImageView.xaml
/// </summary>
public partial class DetailImageView : UserControlRx
public partial class DetailImageView : UserControlRx<ViewModel>
{
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<bool> _showAuthor;
public bool ShowAuthor => _showAuthor.Value;
[Reactive]
public bool ShowAuthor { get; private set; }
private readonly ObservableAsPropertyHelper<bool> _showDescription;
public bool ShowDescription => _showDescription.Value;
[Reactive]
public bool ShowDescription { get; private set; }
private readonly ObservableAsPropertyHelper<bool> _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);
});
}
}
}

View File

@ -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">
<Grid>

View File

@ -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
{
/// <summary>
/// Interaction logic for TopProgressView.xaml
/// </summary>
public partial class TopProgressView : UserControlRx
public partial class TopProgressView : UserControlRx<ViewModel>
{
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<double> _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);
});
}
}
}

View File

@ -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">
<Border BorderBrush="{StaticResource BorderInterestBrush}" BorderThickness="1,0,1,1">
<local:DetailImageView
Title="{Binding TitleText, Mode=OneWay}"
Author="{Binding AuthorText, Mode=OneWay}"
Description="{Binding Description, Mode=OneWay}"
Image="{Binding Image, Mode=OneWay}" />
<Grid>
<local:DetailImageView
Title="{Binding TitleText, Mode=OneWay}"
Author="{Binding AuthorText, Mode=OneWay}"
Description="{Binding Description, Mode=OneWay}"
Image="{Binding Image, Mode=OneWay}" />
<mahapps:ProgressRing Visibility="{Binding LoadingModlist, Mode=OneWay, Converter={StaticResource bool2VisibilityHiddenConverter}}" />
</Grid>
</Border>
<Grid
Margin="0,20,25,0"
@ -422,8 +426,8 @@
<local:CpuView
Grid.Column="2"
ProgressPercent="{Binding PercentCompleted, Mode=OneWay}"
ViewModel="{Binding}"
SettingsHook="{Binding MWVM.Settings}"
ViewModel="{Binding}"
Visibility="{Binding ActiveGlobalUserIntervention, Converter={StaticResource IsNotNullVisibilityConverter}, ConverterParameter=False}" />
<local:AttentionBorder Grid.Column="2" Visibility="{Binding ActiveGlobalUserIntervention, Converter={StaticResource IsNotNullVisibilityConverter}}">
<local:AttentionBorder.DisplayContent>

View File

@ -86,6 +86,7 @@
BorderBrush="{StaticResource ButtonNormalBorder}"
BorderThickness="0,0,0,1">
<Grid ClipToBounds="True">
<mahapps:ProgressRing x:Name="LoadingProgress" />
<Viewbox
Height="340"
HorizontalAlignment="Center"
@ -119,7 +120,7 @@
<Ellipse.Style>
<Style TargetType="Ellipse">
<Style.Triggers>
<DataTrigger Binding="{Binding IsMouseOver, RelativeSource={RelativeSource AncestorType={x:Type Button}}}" Value="True">
<DataTrigger Binding="{Binding IsMouseOver, ElementName=ModListTile}" Value="True">
<DataTrigger.EnterActions>
<BeginStoryboard>
<Storyboard>

View File

@ -54,6 +54,10 @@ namespace Wabbajack
this.WhenAny(x => x.ViewModel.Image)
.BindToStrict(this, x => x.ModListImage.Source)
.DisposeWith(dispose);
this.WhenAny(x => x.ViewModel.LoadingImage)
.Select(x => x ? Visibility.Visible : Visibility.Collapsed)
.BindToStrict(this, x => x.LoadingProgress.Visibility)
.DisposeWith(dispose);
});
}
}

View File

@ -7,40 +7,6 @@ using System.Windows.Controls;
namespace Wabbajack
{
public class UserControlRx : UserControl, IDisposable, IReactiveObject
{
public event PropertyChangedEventHandler PropertyChanged;
public event PropertyChangingEventHandler PropertyChanging;
public void RaisePropertyChanging(PropertyChangingEventArgs args)
{
PropertyChanging?.Invoke(this, args);
}
public void RaisePropertyChanged(PropertyChangedEventArgs args)
{
PropertyChanged?.Invoke(this, args);
}
private readonly Lazy<CompositeDisposable> _compositeDisposable = new Lazy<CompositeDisposable>();
public CompositeDisposable CompositeDisposable => _compositeDisposable.Value;
public virtual void Dispose()
{
if (_compositeDisposable.IsValueCreated)
{
_compositeDisposable.Value.Dispose();
}
}
protected static void WireNotifyPropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
if (!(d is UserControlRx control)) return;
if (Equals(e.OldValue, e.NewValue)) return;
control.RaisePropertyChanged(e.Property.Name);
}
}
public class UserControlRx<TViewModel> : ReactiveUserControl<TViewModel>, IReactiveObject
where TViewModel : class
{
@ -59,7 +25,7 @@ namespace Wabbajack
protected static void WireNotifyPropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
if (!(d is UserControlRx control)) return;
if (!(d is UserControlRx<TViewModel> control)) return;
if (Equals(e.OldValue, e.NewValue)) return;
control.RaisePropertyChanged(e.Property.Name);
}

View File

@ -614,6 +614,8 @@
<ItemGroup>
<SplashScreen Include="Resources\Wabba_Mouth_Small.png" />
</ItemGroup>
<ItemGroup />
<ItemGroup>
<Folder Include="Interfaces\" />
</ItemGroup>
<Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />
</Project>