diff --git a/src/Artemis.Core/Models/Profile/Colors/ColorGradient.cs b/src/Artemis.Core/Models/Profile/Colors/ColorGradient.cs index 8550a9b10..a4ea63a11 100644 --- a/src/Artemis.Core/Models/Profile/Colors/ColorGradient.cs +++ b/src/Artemis.Core/Models/Profile/Colors/ColorGradient.cs @@ -241,7 +241,7 @@ public class ColorGradient : IList, IList, INotifyCollectionC return _stops[^1].Color; //find the first stop after the position - int stop2Index = 0; + int stop2Index = -1; for (int i = 0; i < _stops.Count; i++) { diff --git a/src/Artemis.UI.Linux/App.axaml b/src/Artemis.UI.Linux/App.axaml index 61b3b65c3..56b423b9a 100644 --- a/src/Artemis.UI.Linux/App.axaml +++ b/src/Artemis.UI.Linux/App.axaml @@ -17,10 +17,10 @@ - - - - + + + + diff --git a/src/Artemis.UI.MacOS/App.axaml b/src/Artemis.UI.MacOS/App.axaml index a1b602647..f17dcf30d 100644 --- a/src/Artemis.UI.MacOS/App.axaml +++ b/src/Artemis.UI.MacOS/App.axaml @@ -17,10 +17,10 @@ - - - - + + + + diff --git a/src/Artemis.UI.Shared/Artemis.UI.Shared.csproj.DotSettings b/src/Artemis.UI.Shared/Artemis.UI.Shared.csproj.DotSettings index 849012cda..2cc53be94 100644 --- a/src/Artemis.UI.Shared/Artemis.UI.Shared.csproj.DotSettings +++ b/src/Artemis.UI.Shared/Artemis.UI.Shared.csproj.DotSettings @@ -2,6 +2,9 @@ True True True + True + True + True True True True \ No newline at end of file diff --git a/src/Artemis.UI.Shared/Controls/DeviceVisualizer.cs b/src/Artemis.UI.Shared/Controls/DeviceVisualizer.cs index 80c72b0a5..2b16bb769 100644 --- a/src/Artemis.UI.Shared/Controls/DeviceVisualizer.cs +++ b/src/Artemis.UI.Shared/Controls/DeviceVisualizer.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.ComponentModel; using System.IO; using System.Linq; +using System.Threading.Tasks; using Artemis.Core; using Artemis.Core.Services; using Artemis.UI.Shared.Events; @@ -26,6 +27,7 @@ namespace Artemis.UI.Shared; /// public class DeviceVisualizer : Control { + internal static readonly Dictionary BitmapCache = new(); private readonly ICoreService _coreService; private readonly List _deviceVisualizerLeds; @@ -160,7 +162,7 @@ public class DeviceVisualizer : Control return geometry.Bounds; } - + private void OnFrameRendered(object? sender, FrameRenderedEventArgs e) { Dispatcher.UIThread.Post(() => @@ -195,12 +197,14 @@ public class DeviceVisualizer : Control private void DevicePropertyChanged(object? sender, PropertyChangedEventArgs e) { - Dispatcher.UIThread.Post(SetupForDevice, DispatcherPriority.Background); + if (Device != null) + BitmapCache.Remove(Device); + Dispatcher.UIThread.Invoke(SetupForDevice, DispatcherPriority.Background); } private void DeviceUpdated(object? sender, EventArgs e) { - Dispatcher.UIThread.Post(SetupForDevice, DispatcherPriority.Background); + Dispatcher.UIThread.Invoke(SetupForDevice, DispatcherPriority.Background); } #region Properties @@ -242,9 +246,6 @@ public class DeviceVisualizer : Control /// protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) { - _deviceImage?.Dispose(); - _deviceImage = null; - if (Device != null) { Device.RgbDevice.PropertyChanged -= DevicePropertyChanged; @@ -270,7 +271,7 @@ public class DeviceVisualizer : Control base.OnDetachedFromLogicalTree(e); } - private void SetupForDevice() + private async Task SetupForDevice() { lock (_deviceVisualizerLeds) { @@ -302,46 +303,47 @@ public class DeviceVisualizer : Control // Load the device main image on a background thread ArtemisDevice? device = Device; - Dispatcher.UIThread.Post(() => + try { - try - { - if (device.Layout?.Image == null || !File.Exists(device.Layout.Image.LocalPath)) - { - _deviceImage?.Dispose(); - _deviceImage = null; - return; - } + _deviceImage = await Task.Run(() => GetDeviceImage(device)); + } + catch (Exception e) + { + // ignored + } - // Create a bitmap that'll be used to render the device and LED images just once - // Render 4 times the actual size of the device to make sure things look sharp when zoomed in - RenderTargetBitmap renderTargetBitmap = new(new PixelSize((int) device.RgbDevice.ActualSize.Width * 2, (int) device.RgbDevice.ActualSize.Height * 2)); + InvalidateMeasure(); + _loading = false; + } - using DrawingContext context = renderTargetBitmap.CreateDrawingContext(); - using Bitmap bitmap = new(device.Layout.Image.LocalPath); - using Bitmap scaledBitmap = bitmap.CreateScaledBitmap(renderTargetBitmap.PixelSize); + private RenderTargetBitmap? GetDeviceImage(ArtemisDevice device) + { + if (BitmapCache.TryGetValue(device, out RenderTargetBitmap? existingBitmap)) + return existingBitmap; - context.DrawImage(scaledBitmap, new Rect(scaledBitmap.Size)); - lock (_deviceVisualizerLeds) - { - foreach (DeviceVisualizerLed deviceVisualizerLed in _deviceVisualizerLeds) - deviceVisualizerLed.DrawBitmap(context, 2 * Device.Scale); - } + if (device.Layout?.Image == null || !File.Exists(device.Layout.Image.LocalPath)) + { + BitmapCache[device] = null; + return null; + } - _deviceImage?.Dispose(); - _deviceImage = renderTargetBitmap; + // Create a bitmap that'll be used to render the device and LED images just once + // Render 4 times the actual size of the device to make sure things look sharp when zoomed in + RenderTargetBitmap renderTargetBitmap = new(new PixelSize((int) device.RgbDevice.ActualSize.Width * 2, (int) device.RgbDevice.ActualSize.Height * 2)); - InvalidateMeasure(); - } - catch (Exception) - { - // ignored - } - finally - { - _loading = false; - } - }); + using DrawingContext context = renderTargetBitmap.CreateDrawingContext(); + using Bitmap bitmap = new(device.Layout.Image.LocalPath); + using Bitmap scaledBitmap = bitmap.CreateScaledBitmap(renderTargetBitmap.PixelSize); + + context.DrawImage(scaledBitmap, new Rect(scaledBitmap.Size)); + lock (_deviceVisualizerLeds) + { + foreach (DeviceVisualizerLed deviceVisualizerLed in _deviceVisualizerLeds) + deviceVisualizerLed.DrawBitmap(context, 2 * device.Scale); + } + + BitmapCache[device] = renderTargetBitmap; + return renderTargetBitmap; } /// diff --git a/src/Artemis.UI.Shared/Controls/ProfileConfigurationIcon.axaml.cs b/src/Artemis.UI.Shared/Controls/ProfileConfigurationIcon.axaml.cs index 0e8b9adcb..9ad64cbf8 100644 --- a/src/Artemis.UI.Shared/Controls/ProfileConfigurationIcon.axaml.cs +++ b/src/Artemis.UI.Shared/Controls/ProfileConfigurationIcon.axaml.cs @@ -48,11 +48,14 @@ public partial class ProfileConfigurationIcon : UserControl, IDisposable } else { - Stream? stream = ConfigurationIcon.GetIconStream(); - if (stream == null) - Content = new MaterialIcon {Kind = MaterialIconKind.QuestionMark}; - else - LoadFromBitmap(ConfigurationIcon, stream); + Dispatcher.UIThread.Post(() => + { + Stream? stream = ConfigurationIcon.GetIconStream(); + if (stream == null) + Content = new MaterialIcon {Kind = MaterialIconKind.QuestionMark}; + else + LoadFromBitmap(ConfigurationIcon, stream); + }, DispatcherPriority.ApplicationIdle); } } catch (Exception) @@ -97,12 +100,12 @@ public partial class ProfileConfigurationIcon : UserControl, IDisposable if (e.NewValue is Core.ProfileConfigurationIcon newIcon) newIcon.IconUpdated += ConfigurationIconOnIconUpdated; - Dispatcher.UIThread.Post(Update, DispatcherPriority.ApplicationIdle); + Update(); } private void ConfigurationIconOnIconUpdated(object? sender, EventArgs e) { - Dispatcher.UIThread.Post(Update, DispatcherPriority.ApplicationIdle); + Update(); } /// diff --git a/src/Artemis.UI.Shared/DryIoc/ContainerExtensions.cs b/src/Artemis.UI.Shared/DryIoc/ContainerExtensions.cs index c1e7006a7..94190e2d4 100644 --- a/src/Artemis.UI.Shared/DryIoc/ContainerExtensions.cs +++ b/src/Artemis.UI.Shared/DryIoc/ContainerExtensions.cs @@ -1,4 +1,5 @@ using System.Reflection; +using Artemis.UI.Shared.Routing; using Artemis.UI.Shared.Services; using DryIoc; @@ -15,6 +16,7 @@ public static class ContainerExtensions /// The builder building the current container public static void RegisterSharedUI(this IContainer container) { + container.Register(Reuse.Singleton); Assembly artemisShared = typeof(IArtemisSharedUIService).GetAssembly(); container.RegisterMany(new[] {artemisShared}, type => type.IsAssignableTo(), Reuse.Singleton); diff --git a/src/Artemis.UI.Shared/Exceptions/ArtemisRoutingException.cs b/src/Artemis.UI.Shared/Exceptions/ArtemisRoutingException.cs new file mode 100644 index 000000000..92756bc56 --- /dev/null +++ b/src/Artemis.UI.Shared/Exceptions/ArtemisRoutingException.cs @@ -0,0 +1,21 @@ +using System; + +namespace Artemis.UI.Shared; + +public class ArtemisRoutingException : Exception +{ + /// + public ArtemisRoutingException() + { + } + + /// + public ArtemisRoutingException(string? message) : base(message) + { + } + + /// + public ArtemisRoutingException(string? message, Exception? innerException) : base(message, innerException) + { + } +} \ No newline at end of file diff --git a/src/Artemis.UI.Shared/Routing/Routable/RoutableScreen.cs b/src/Artemis.UI.Shared/Routing/Routable/RoutableScreen.cs new file mode 100644 index 000000000..53973de46 --- /dev/null +++ b/src/Artemis.UI.Shared/Routing/Routable/RoutableScreen.cs @@ -0,0 +1,24 @@ +using System.Threading; +using System.Threading.Tasks; +using ReactiveUI; + +namespace Artemis.UI.Shared.Routing; + +/// +/// For internal use. +/// +/// +/// +internal interface IRoutableScreen : IActivatableViewModel +{ + /// + /// Gets or sets a value indicating whether or not to reuse the child screen instance if the type has not changed. + /// + /// Defaults to . + bool RecycleScreen { get; } + + object? InternalScreen { get; } + void InternalChangeScreen(object? screen); + Task InternalOnNavigating(NavigationArguments args, CancellationToken cancellationToken); + Task InternalOnClosing(NavigationArguments args); +} \ No newline at end of file diff --git a/src/Artemis.UI.Shared/Routing/Routable/RoutableScreenOfTScreen.cs b/src/Artemis.UI.Shared/Routing/Routable/RoutableScreenOfTScreen.cs new file mode 100644 index 000000000..00c48b111 --- /dev/null +++ b/src/Artemis.UI.Shared/Routing/Routable/RoutableScreenOfTScreen.cs @@ -0,0 +1,79 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace Artemis.UI.Shared.Routing; + +/// +/// Represents a view model to which routing can take place and which in turn can host another view model. +/// +/// The type of view model the screen can host. +public abstract class RoutableScreen : ActivatableViewModelBase, IRoutableScreen where TScreen : class +{ + private TScreen? _screen; + private bool _recycleScreen = true; + + /// + /// Gets the currently active child screen. + /// + public TScreen? Screen + { + get => _screen; + private set => RaiseAndSetIfChanged(ref _screen, value); + } + + /// + public bool RecycleScreen + { + get => _recycleScreen; + protected set => RaiseAndSetIfChanged(ref _recycleScreen, value); + } + + /// + /// Called before navigating to this screen. + /// + /// Navigation arguments containing information about the navigation action. + public virtual Task BeforeNavigating(NavigationArguments args) + { + return Task.CompletedTask; + } + + /// + /// Called while navigating to this screen. + /// + /// Navigation arguments containing information about the navigation action. + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + public virtual Task OnNavigating(NavigationArguments args, CancellationToken cancellationToken) + { + return Task.CompletedTask; + } + + /// + /// Called before navigating away from this screen. + /// + /// Navigation arguments containing information about the navigation action. + public virtual Task OnClosing(NavigationArguments args) + { + return Task.CompletedTask; + } + + #region Overrides of RoutableScreen + + object? IRoutableScreen.InternalScreen => Screen; + + void IRoutableScreen.InternalChangeScreen(object? screen) + { + Screen = screen as TScreen; + } + + async Task IRoutableScreen.InternalOnNavigating(NavigationArguments args, CancellationToken cancellationToken) + { + await OnNavigating(args, cancellationToken); + } + + async Task IRoutableScreen.InternalOnClosing(NavigationArguments args) + { + await OnClosing(args); + } + + #endregion +} \ No newline at end of file diff --git a/src/Artemis.UI.Shared/Routing/Routable/RoutableScreenOfTScreenTParam.cs b/src/Artemis.UI.Shared/Routing/Routable/RoutableScreenOfTScreenTParam.cs new file mode 100644 index 000000000..38afcd3d7 --- /dev/null +++ b/src/Artemis.UI.Shared/Routing/Routable/RoutableScreenOfTScreenTParam.cs @@ -0,0 +1,149 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using System.Threading; +using System.Threading.Tasks; + +namespace Artemis.UI.Shared.Routing; + +/// +/// Represents a view model to which routing with parameters can take place and which in turn can host another view +/// model. +/// +/// The type of view model the screen can host. +/// The type of parameters the screen expects. It must have a parameterless constructor. +public abstract class RoutableScreen : ActivatableViewModelBase, IRoutableScreen where TScreen : class where TParam : new() +{ + private bool _recycleScreen = true; + private TScreen? _screen; + + /// + /// Gets the currently active child screen. + /// + public TScreen? Screen + { + get => _screen; + private set => RaiseAndSetIfChanged(ref _screen, value); + } + + /// + /// Called before navigating to this screen. + /// + /// Navigation arguments containing information about the navigation action. + public virtual Task BeforeNavigating(NavigationArguments args) + { + return Task.CompletedTask; + } + + /// + /// Called while navigating to this screen. + /// + /// An object containing the parameters of the navigation action. + /// Navigation arguments containing information about the navigation action. + /// + /// A cancellation token that can be used by other objects or threads to receive notice of + /// cancellation. + /// + public virtual Task OnNavigating(TParam parameters, NavigationArguments args, CancellationToken cancellationToken) + { + return Task.CompletedTask; + } + + /// + /// Called before navigating away from this screen. + /// + /// Navigation arguments containing information about the navigation action. + public virtual Task OnClosing(NavigationArguments args) + { + return Task.CompletedTask; + } + + /// + public bool RecycleScreen + { + get => _recycleScreen; + protected set => RaiseAndSetIfChanged(ref _recycleScreen, value); + } + + #region Overrides of RoutableScreen + + object? IRoutableScreen.InternalScreen => Screen; + + void IRoutableScreen.InternalChangeScreen(object? screen) + { + Screen = screen as TScreen; + } + + async Task IRoutableScreen.InternalOnNavigating(NavigationArguments args, CancellationToken cancellationToken) + { + Func activator = GetParameterActivator(); + + if (args.SegmentParameters.Length != _parameterPropertyCount) + throw new ArtemisRoutingException($"Did not retrieve the required amount of parameters, expects {_parameterPropertyCount}, got {args.SegmentParameters.Length}."); + + TParam parameters = activator(args.SegmentParameters); + await OnNavigating(parameters, args, cancellationToken); + } + + async Task IRoutableScreen.InternalOnClosing(NavigationArguments args) + { + await OnClosing(args); + } + + #endregion + + #region Parameter generation + + // ReSharper disable once StaticMemberInGenericType - That's intentional, each kind of TParam should have its own property count + private static int _parameterPropertyCount; + private static Func? _parameterActivator; + + private static Func GetParameterActivator() + { + if (_parameterActivator != null) + return _parameterActivator; + + // Generates a lambda that creates a new instance of TParam + // - Each property of TParam with a public setter must be set using the source object[] + // - Use the index of each property as the index on the source array + // - Cast the object of the source array to the correct type for that property + Type parameterType = typeof(TParam); + ParameterExpression sourceExpression = Expression.Parameter(typeof(object[]), "source"); + ParameterExpression parameterExpression = Expression.Parameter(parameterType, "parameters"); + + List propertyAssignments = parameterType.GetProperties() + .Where(p => p.CanWrite) + .Select((property, index) => + { + UnaryExpression sourceValueExpression = Expression.Convert( + Expression.ArrayIndex(sourceExpression, Expression.Constant(index)), + property.PropertyType + ); + BinaryExpression propertyAssignment = Expression.Assign( + Expression.Property(parameterExpression, property), + sourceValueExpression + ); + + return propertyAssignment; + }) + .ToList(); + + Expression> lambda = Expression.Lambda>( + Expression.Block( + new[] {parameterExpression}, + Expression.Assign(parameterExpression, Expression.New(parameterType)), + Expression.Block(propertyAssignments), + parameterExpression + ), + sourceExpression + ); + + _parameterActivator = lambda.Compile(); + _parameterPropertyCount = propertyAssignments.Count; + + return _parameterActivator; + } + + #endregion +} \ No newline at end of file diff --git a/src/Artemis.UI.Shared/Routing/Route/ParameterParsers/GuidParameterParser.cs b/src/Artemis.UI.Shared/Routing/Route/ParameterParsers/GuidParameterParser.cs new file mode 100644 index 000000000..e816b2d75 --- /dev/null +++ b/src/Artemis.UI.Shared/Routing/Route/ParameterParsers/GuidParameterParser.cs @@ -0,0 +1,18 @@ +using System; + +namespace Artemis.UI.Shared.Routing.ParameterParsers; + +internal class GuidParameterParser : IRouteParameterParser +{ + /// + public bool IsMatch(RouteSegment segment, string source) + { + return Guid.TryParse(source, out _); + } + + /// + public object GetValue(RouteSegment segment, string source) + { + return Guid.Parse(source); + } +} \ No newline at end of file diff --git a/src/Artemis.UI.Shared/Routing/Route/ParameterParsers/IRouteParameterParser.cs b/src/Artemis.UI.Shared/Routing/Route/ParameterParsers/IRouteParameterParser.cs new file mode 100644 index 000000000..5851418e8 --- /dev/null +++ b/src/Artemis.UI.Shared/Routing/Route/ParameterParsers/IRouteParameterParser.cs @@ -0,0 +1,7 @@ +namespace Artemis.UI.Shared.Routing.ParameterParsers; + +public interface IRouteParameterParser +{ + bool IsMatch(RouteSegment segment, string source); + object GetValue(RouteSegment segment, string source); +} \ No newline at end of file diff --git a/src/Artemis.UI.Shared/Routing/Route/ParameterParsers/StringParameterParser.cs b/src/Artemis.UI.Shared/Routing/Route/ParameterParsers/StringParameterParser.cs new file mode 100644 index 000000000..cfd1a7c8f --- /dev/null +++ b/src/Artemis.UI.Shared/Routing/Route/ParameterParsers/StringParameterParser.cs @@ -0,0 +1,16 @@ +namespace Artemis.UI.Shared.Routing.ParameterParsers; + +internal class StringParameterParser : IRouteParameterParser +{ + /// + public bool IsMatch(RouteSegment segment, string source) + { + return true; + } + + /// + public object GetValue(RouteSegment segment, string source) + { + return source; + } +} \ No newline at end of file diff --git a/src/Artemis.UI.Shared/Routing/Route/Route.cs b/src/Artemis.UI.Shared/Routing/Route/Route.cs new file mode 100644 index 000000000..ad95bddd3 --- /dev/null +++ b/src/Artemis.UI.Shared/Routing/Route/Route.cs @@ -0,0 +1,22 @@ +using System.Collections.Generic; +using System.Linq; + +namespace Artemis.UI.Shared.Routing; + +public class Route +{ + public Route(string path) + { + Path = path; + Segments = path.Split('/').Select(s => new RouteSegment(s)).ToList(); + } + + public string Path { get; } + public List Segments { get; } + + /// + public override string ToString() + { + return Path; + } +} \ No newline at end of file diff --git a/src/Artemis.UI.Shared/Routing/Route/RouteRegistration.cs b/src/Artemis.UI.Shared/Routing/Route/RouteRegistration.cs new file mode 100644 index 000000000..dabb5dadb --- /dev/null +++ b/src/Artemis.UI.Shared/Routing/Route/RouteRegistration.cs @@ -0,0 +1,36 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace Artemis.UI.Shared.Routing; + +public class RouteRegistration : IRouterRegistration where TViewModel : ViewModelBase +{ + public RouteRegistration(string path) + { + Route = new Route(path); + } + + /// + public override string ToString() + { + return $"{nameof(Route)}: {Route}, {nameof(ViewModel)}: {ViewModel}"; + } + + public Route Route { get; } + + /// + public Type ViewModel => typeof(TViewModel); + + /// + public List Children { get; set; } = new(); +} + +public interface IRouterRegistration +{ + Route Route { get; } + Type ViewModel { get; } + List Children { get; set; } + +} \ No newline at end of file diff --git a/src/Artemis.UI.Shared/Routing/Route/RouteResolution.cs b/src/Artemis.UI.Shared/Routing/Route/RouteResolution.cs new file mode 100644 index 000000000..545d85fa6 --- /dev/null +++ b/src/Artemis.UI.Shared/Routing/Route/RouteResolution.cs @@ -0,0 +1,115 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using DryIoc; + +namespace Artemis.UI.Shared.Routing; + +internal class RouteResolution +{ + private RouteResolution(string path) + { + Path = path; + } + + public string Path { get; } + public bool Success { get; private set; } + public Type? ViewModel { get; private set; } + public object[]? Parameters { get; private set; } + public RouteResolution? Child { get; private set; } + + public static RouteResolution Resolve(IRouterRegistration registration, string path) + { + List segments = path.Split('/').ToList(); + if (registration.Route.Segments.Count > segments.Count) + return AsFailure(path); + + // Ensure self is a match + List parameters = new(); + int currentSegment = 0; + foreach (RouteSegment routeSegment in registration.Route.Segments) + { + string segment = segments[currentSegment]; + if (!routeSegment.IsMatch(segment)) + return AsFailure(path); + + object? parameter = routeSegment.GetParameter(segment); + if (parameter != null) + parameters.Add(parameter); + + currentSegment++; + } + + if (currentSegment == segments.Count) + return AsSuccess(registration.ViewModel, path, parameters.ToArray()); + + // If segments remain, a child should match it + string childPath = string.Join('/', segments.Skip(currentSegment)); + foreach (IRouterRegistration routerRegistration in registration.Children) + { + RouteResolution result = Resolve(routerRegistration, childPath); + if (result.Success) + return AsSuccess(registration.ViewModel, path, parameters.ToArray(), result); + } + + return AsFailure(path); + } + + public static RouteResolution AsFailure(string path) + { + return new RouteResolution(path); + } + + public static RouteResolution AsSuccess(Type viewModel, string path, object[] parameters, RouteResolution? child = null) + { + if (child != null && !child.Success) + throw new ArtemisRoutingException("Cannot create a success route resolution with a failed child"); + + return new RouteResolution(path) + { + Success = true, + ViewModel = viewModel, + Parameters = parameters, + Child = child + }; + } + + public object GetViewModel(IContainer container) + { + if (ViewModel == null) + throw new ArtemisRoutingException("Cannot get a view model of a non-success route resolution"); + + object? viewModel = container.Resolve(ViewModel); + if (viewModel == null) + throw new ArtemisRoutingException($"Could not resolve view model of type {ViewModel}"); + + return viewModel; + } + + public T GetViewModel(IContainer container) + { + if (ViewModel == null) + throw new ArtemisRoutingException("Cannot get a view model of a non-success route resolution"); + + object? viewModel = container.Resolve(ViewModel); + if (viewModel == null) + throw new ArtemisRoutingException($"Could not resolve view model of type {ViewModel}"); + if (viewModel is not T typedViewModel) + throw new ArtemisRoutingException($"View model of type {ViewModel} is does mot implement {typeof(T)}."); + + return typedViewModel; + } + + public object[] GetAllParameters() + { + List result = new(); + if (Parameters != null) + result.AddRange(Parameters); + object[]? childParameters = Child?.GetAllParameters(); + if (childParameters != null) + result.AddRange(childParameters); + + return result.ToArray(); + } +} \ No newline at end of file diff --git a/src/Artemis.UI.Shared/Routing/Route/RouteSegment.cs b/src/Artemis.UI.Shared/Routing/Route/RouteSegment.cs new file mode 100644 index 000000000..5668c6fb4 --- /dev/null +++ b/src/Artemis.UI.Shared/Routing/Route/RouteSegment.cs @@ -0,0 +1,60 @@ +using System; +using System.Text.RegularExpressions; +using Artemis.UI.Shared.Routing.ParameterParsers; + +namespace Artemis.UI.Shared.Routing; + +public partial class RouteSegment +{ + private readonly IRouteParameterParser? _parameterParser; + + public RouteSegment(string segment) + { + Segment = segment; + + Match match = ParameterRegex().Match(segment); + if (match.Success) + { + Parameter = match.Groups[1].Value; + ParameterType = match.Groups[2].Value; + _parameterParser = GetParameterParser(ParameterType); + } + } + + public string Segment { get; } + public string? Parameter { get; } + public string? ParameterType { get; } + + public bool IsMatch(string value) + { + if (_parameterParser == null) + return Segment.Equals(value, StringComparison.InvariantCultureIgnoreCase); + return _parameterParser.IsMatch(this, value); + } + + public object? GetParameter(string value) + { + if (_parameterParser == null) + return null; + + return _parameterParser.GetValue(this, value); + } + + /// + public override string ToString() + { + return $"{Segment} (param: {Parameter ?? "none"}, type: {ParameterType ?? "N/A"})"; + } + + private IRouteParameterParser GetParameterParser(string parameterType) + { + if (parameterType == "guid") + return new GuidParameterParser(); + + // Default to a string parser which just returns the segment as is + return new StringParameterParser(); + } + + [GeneratedRegex(@"\{(\w+):(\w+)\}")] + private static partial Regex ParameterRegex(); +} \ No newline at end of file diff --git a/src/Artemis.UI.Shared/Routing/Router/IRouter.cs b/src/Artemis.UI.Shared/Routing/Router/IRouter.cs new file mode 100644 index 000000000..1778d9f20 --- /dev/null +++ b/src/Artemis.UI.Shared/Routing/Router/IRouter.cs @@ -0,0 +1,62 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Artemis.UI.Shared.Routing; + +/// +/// Represents a router that can be used to navigate to different screens. +/// +public interface IRouter +{ + /// + /// Gets an observable containing the current path. + /// + IObservable CurrentPath { get; } + + /// + /// Gets a list of router registrations, you can use this to register new routes. + /// + List Routes { get; } + + /// + /// Asynchronously navigates to the provided path. + /// + /// Navigating cancels any currently processing navigations. + /// The path to navigate to. + /// Optional navigation options used to control navigation behaviour. + /// A task representing the operation + Task Navigate(string path, RouterNavigationOptions? options = null); + + /// + /// Asynchronously navigates back to the previous active route. + /// + /// A task containing a boolean value which indicates whether there was a previous path to go back to. + Task GoBack(); + + /// + /// Asynchronously navigates forward to the previous active route. + /// + /// A task containing a boolean value which indicates whether there was a forward path to go back to. + Task GoForward(); + + /// + /// Clears the navigation history. + /// + void ClearHistory(); + + /// + /// Sets the root screen from which navigation takes place. + /// + /// The root screen to set. + /// The type of the root screen. It must be a class. + void SetRoot(RoutableScreen root) where TScreen : class; + + /// + /// Sets the root screen from which navigation takes place. + /// + /// The root screen to set. + /// The type of the root screen. It must be a class. + /// The type of the parameters for the root screen. It must have a parameterless constructor. + void SetRoot(RoutableScreen root) where TScreen : class where TParam : new(); +} \ No newline at end of file diff --git a/src/Artemis.UI.Shared/Routing/Router/Navigation.cs b/src/Artemis.UI.Shared/Routing/Router/Navigation.cs new file mode 100644 index 000000000..69d3ed15b --- /dev/null +++ b/src/Artemis.UI.Shared/Routing/Router/Navigation.cs @@ -0,0 +1,121 @@ +using System; +using System.Reactive.Threading.Tasks; +using System.Threading; +using System.Threading.Tasks; +using Avalonia.Threading; +using DryIoc; +using ReactiveUI; +using Serilog; + +namespace Artemis.UI.Shared.Routing; + +internal class Navigation +{ + private readonly IContainer _container; + private readonly ILogger _logger; + + private readonly IRoutableScreen _root; + private readonly RouteResolution _resolution; + private readonly RouterNavigationOptions _options; + private CancellationTokenSource _cts; + + public Navigation(IContainer container, ILogger logger, IRoutableScreen root, RouteResolution resolution, RouterNavigationOptions options) + { + _container = container; + _logger = logger; + + _root = root; + _resolution = resolution; + _options = options; + _cts = new CancellationTokenSource(); + } + + public bool Cancelled => _cts.IsCancellationRequested; + public bool Completed { get; private set; } + + public async Task Navigate(NavigationArguments args) + { + _logger.Information("Navigating to {Path}", _resolution.Path); + _cts = new CancellationTokenSource(); + await NavigateResolution(_resolution, args, _root); + + if (!Cancelled) + _logger.Information("Navigated to {Path}", _resolution.Path); + } + + public void Cancel() + { + if (Cancelled || Completed) + return; + + _logger.Information("Cancelled navigation to {Path}", _resolution.Path); + _cts.Cancel(); + } + + private async Task NavigateResolution(RouteResolution resolution, NavigationArguments args, IRoutableScreen host) + { + if (Cancelled) + return; + + // Reuse the screen if its type has not changed, if a new one must be created, don't do so on the UI thread + object screen; + if (_options.RecycleScreens && host.RecycleScreen && host.InternalScreen != null && host.InternalScreen.GetType() == resolution.ViewModel) + screen = host.InternalScreen; + else + screen = await Task.Run(() => resolution.GetViewModel(_container)); + + // If resolution has a child, ensure the screen can host it + if (resolution.Child != null && screen is not IRoutableScreen) + throw new ArtemisRoutingException($"Route resolved with a child but view model of type {resolution.ViewModel} is does mot implement {nameof(IRoutableScreen)}."); + + // Only change the screen if it wasn't reused + if (!ReferenceEquals(host.InternalScreen, screen)) + host.InternalChangeScreen(screen); + + if (CancelIfRequested(args, "ChangeScreen", screen)) + return; + + // If the screen implements some form of Navigable, activate it + args.SegmentParameters = resolution.Parameters ?? Array.Empty(); + if (screen is IRoutableScreen routableScreen) + { + try + { + await routableScreen.InternalOnNavigating(args, _cts.Token); + } + catch (Exception e) + { + Cancel(); + _logger.Error(e, "Failed to navigate to {Path}", resolution.Path); + } + + if (CancelIfRequested(args, "OnNavigating", screen)) + return; + } + + if (resolution.Child != null && screen is IRoutableScreen childScreen) + await NavigateResolution(resolution.Child, args, childScreen); + + Completed = true; + } + + public bool PathEquals(string path, bool allowPartialMatch) + { + if (allowPartialMatch) + return _resolution.Path.StartsWith(path, StringComparison.InvariantCultureIgnoreCase); + return string.Equals(_resolution.Path, path, StringComparison.InvariantCultureIgnoreCase); + } + + private bool CancelIfRequested(NavigationArguments args, string stage, object screen) + { + if (Cancelled) + return true; + + if (!args.Cancelled) + return false; + + _logger.Debug("Navigation to {Path} during {Stage} by {Screen}", args.Path, stage, screen.GetType().Name); + Cancel(); + return true; + } +} \ No newline at end of file diff --git a/src/Artemis.UI.Shared/Routing/Router/NavigationArguments.cs b/src/Artemis.UI.Shared/Routing/Router/NavigationArguments.cs new file mode 100644 index 000000000..5c26142e9 --- /dev/null +++ b/src/Artemis.UI.Shared/Routing/Router/NavigationArguments.cs @@ -0,0 +1,49 @@ +using System; +using System.Threading.Tasks; + +namespace Artemis.UI.Shared.Routing; + +/// +/// Represents an object that contains information about the current navigation action. +/// +public class NavigationArguments +{ + internal NavigationArguments(IRouter router, string path, object[] routeParameters) + { + Router = router; + Path = path; + RouteParameters = routeParameters; + SegmentParameters = Array.Empty(); + } + + /// + /// Gets the router in which the navigation is taking place. + /// + public IRouter Router { get; } + + /// + /// Gets the path of the route that is being navigated to. + /// + public string Path { get; } + + /// + /// GEts an array of all parameters provided to this route. + /// + public object[] RouteParameters { get; } + + /// + /// Gets an array of parameters provided to this screen's segment of the route. + /// + public object[] SegmentParameters { get; internal set; } + + internal bool Cancelled { get; private set; } + + /// + /// Cancels further processing of the current navigation. + /// + /// It not necessary to cancel the navigation in order to navigate to another route, the current navigation will be cancelled by the router. + public void Cancel() + { + Cancelled = true; + } +} \ No newline at end of file diff --git a/src/Artemis.UI.Shared/Routing/Router/Router.cs b/src/Artemis.UI.Shared/Routing/Router/Router.cs new file mode 100644 index 000000000..b3b8a88c1 --- /dev/null +++ b/src/Artemis.UI.Shared/Routing/Router/Router.cs @@ -0,0 +1,167 @@ +using System; +using System.Collections.Generic; +using System.Reactive.Subjects; +using System.Threading.Tasks; +using Artemis.Core; +using Avalonia.Threading; +using Serilog; + +namespace Artemis.UI.Shared.Routing; + +internal class Router : CorePropertyChanged, IRouter +{ + private readonly Stack _backStack = new(); + private readonly BehaviorSubject _currentRouteSubject; + private readonly Stack _forwardStack = new(); + private readonly Func _getNavigation; + private readonly ILogger _logger; + private Navigation? _currentNavigation; + + private IRoutableScreen? _root; + + public Router(ILogger logger, Func getNavigation) + { + _logger = logger; + _getNavigation = getNavigation; + _currentRouteSubject = new BehaviorSubject(null); + } + + private RouteResolution Resolve(string path) + { + foreach (IRouterRegistration routerRegistration in Routes) + { + RouteResolution result = RouteResolution.Resolve(routerRegistration, path); + if (result.Success) + return result; + } + + return RouteResolution.AsFailure(path); + } + + private async Task RequestClose(object screen, NavigationArguments args) + { + if (screen is not IRoutableScreen routableScreen) + return true; + + await routableScreen.InternalOnClosing(args); + if (args.Cancelled) + { + _logger.Debug("Navigation to {Path} cancelled during RequestClose by {Screen}", args.Path, screen.GetType().Name); + return false; + } + + if (routableScreen.InternalScreen == null) + return true; + return await RequestClose(routableScreen.InternalScreen, args); + } + + private bool PathEquals(string path, bool allowPartialMatch) + { + if (allowPartialMatch) + return _currentRouteSubject.Value != null && _currentRouteSubject.Value.StartsWith(path, StringComparison.InvariantCultureIgnoreCase); + return string.Equals(_currentRouteSubject.Value, path, StringComparison.InvariantCultureIgnoreCase); + } + + /// + public IObservable CurrentPath => _currentRouteSubject; + + /// + public List Routes { get; } = new(); + + /// + public async Task Navigate(string path, RouterNavigationOptions? options = null) + { + options ??= new RouterNavigationOptions(); + + // Routing takes place on the UI thread with processing heavy tasks offloaded by the router itself + await Dispatcher.UIThread.InvokeAsync(() => InternalNavigate(path, options)); + } + + private async Task InternalNavigate(string path, RouterNavigationOptions options) + { + if (_root == null) + throw new ArtemisRoutingException("Cannot navigate without a root having been set"); + if (PathEquals(path, options.IgnoreOnPartialMatch) || (_currentNavigation != null && _currentNavigation.PathEquals(path, options.IgnoreOnPartialMatch))) + return; + + string? previousPath = _currentRouteSubject.Value; + RouteResolution resolution = Resolve(path); + if (!resolution.Success) + { + _logger.Warning("Failed to resolve path {Path}", path); + return; + } + + NavigationArguments args = new(this, resolution.Path, resolution.GetAllParameters()); + + if (!await RequestClose(_root, args)) + return; + + Navigation navigation = _getNavigation(_root, resolution, options); + + _currentNavigation?.Cancel(); + _currentNavigation = navigation; + + // Execute the navigation + await navigation.Navigate(args); + + // If it was cancelled before completion, don't add it to history or update the current path + if (navigation.Cancelled) + return; + + if (options.AddToHistory && previousPath != null) + { + _backStack.Push(previousPath); + _forwardStack.Clear(); + } + + _currentRouteSubject.OnNext(path); + } + + /// + public async Task GoBack() + { + if (!_backStack.TryPop(out string? path)) + return false; + + string? previousPath = _currentRouteSubject.Value; + await Navigate(path, new RouterNavigationOptions {AddToHistory = false}); + if (previousPath != null) + _forwardStack.Push(previousPath); + + return true; + } + + /// + public async Task GoForward() + { + if (!_forwardStack.TryPop(out string? path)) + return false; + + string? previousPath = _currentRouteSubject.Value; + await Navigate(path, new RouterNavigationOptions {AddToHistory = false}); + if (previousPath != null) + _backStack.Push(previousPath); + + return true; + } + + /// + public void ClearHistory() + { + _backStack.Clear(); + _forwardStack.Clear(); + } + + /// + public void SetRoot(RoutableScreen root) where TScreen : class + { + _root = root; + } + + /// + public void SetRoot(RoutableScreen root) where TScreen : class where TParam : new() + { + _root = root; + } +} \ No newline at end of file diff --git a/src/Artemis.UI.Shared/Routing/Router/RouterNavigationOptions.cs b/src/Artemis.UI.Shared/Routing/Router/RouterNavigationOptions.cs new file mode 100644 index 000000000..de4f25b10 --- /dev/null +++ b/src/Artemis.UI.Shared/Routing/Router/RouterNavigationOptions.cs @@ -0,0 +1,23 @@ +namespace Artemis.UI.Shared.Routing; + +/// +/// Represents navigation options used to control navigation behaviour. +/// +public class RouterNavigationOptions +{ + /// + /// Gets or sets a boolean indicating whether or not to add the navigation to the history. + /// + public bool AddToHistory { get; set; } = true; + + /// + /// Gets or sets a boolean indicating whether or not to recycle already active screens. + /// + public bool RecycleScreens { get; set; } = true; + + /// + /// Gets or sets a boolean indicating whether route changes should be ignored if they are a partial match. + /// + /// If set to true, a route change from page/subpage1/subpage2 to page/subpage1 will be ignored. + public bool IgnoreOnPartialMatch { get; set; } = false; +} \ No newline at end of file diff --git a/src/Artemis.UI.Shared/Services/Builders/NotificationBuilder.cs b/src/Artemis.UI.Shared/Services/Builders/NotificationBuilder.cs index c506ee1d9..5dfe0ecb9 100644 --- a/src/Artemis.UI.Shared/Services/Builders/NotificationBuilder.cs +++ b/src/Artemis.UI.Shared/Services/Builders/NotificationBuilder.cs @@ -153,6 +153,7 @@ public class NotificationBuilder public class NotificationButtonBuilder { private Action? _action; + private Func? _asyncAction; private ICommand? _command; private object? _commandParameter; private string _text = "Text"; @@ -179,6 +180,18 @@ public class NotificationButtonBuilder _action = action; return this; } + + /// + /// Changes action that is called when the button is clicked. + /// + /// The action to call when the button is clicked. + /// The builder that can be used to further build the button. + public NotificationButtonBuilder WithAction(Func action) + { + _command = null; + _asyncAction = action; + return this; + } /// /// Changes command that is called when the button is clicked. @@ -210,6 +223,8 @@ public class NotificationButtonBuilder if (_action != null) button.Command = ReactiveCommand.Create(() => _action()); + else if (_asyncAction != null) + button.Command = ReactiveCommand.CreateFromTask(() => _asyncAction()); else if (_command != null) { button.Command = _command; diff --git a/src/Artemis.UI.Shared/Services/MainWindow/IMainWindowService.cs b/src/Artemis.UI.Shared/Services/MainWindow/IMainWindowService.cs index e42d644dc..d85066451 100644 --- a/src/Artemis.UI.Shared/Services/MainWindow/IMainWindowService.cs +++ b/src/Artemis.UI.Shared/Services/MainWindow/IMainWindowService.cs @@ -1,5 +1,4 @@ using System; -using ReactiveUI; namespace Artemis.UI.Shared.Services.MainWindow; @@ -12,12 +11,7 @@ public interface IMainWindowService : IArtemisSharedUIService /// Gets a boolean indicating whether the main window is currently open /// bool IsMainWindowOpen { get; } - - /// - /// Gets or sets the host screen contained in the main window - /// - IScreen? HostScreen { get; set; } - + /// /// Sets up the main window provider that controls the state of the main window /// diff --git a/src/Artemis.UI.Shared/Services/MainWindow/MainWindowService.cs b/src/Artemis.UI.Shared/Services/MainWindow/MainWindowService.cs index 98a6cba19..a95126c66 100644 --- a/src/Artemis.UI.Shared/Services/MainWindow/MainWindowService.cs +++ b/src/Artemis.UI.Shared/Services/MainWindow/MainWindowService.cs @@ -9,10 +9,7 @@ internal class MainWindowService : IMainWindowService /// public bool IsMainWindowOpen { get; private set; } - - /// - public IScreen? HostScreen { get; set; } - + protected virtual void OnMainWindowOpened() { MainWindowOpened?.Invoke(this, EventArgs.Empty); @@ -20,6 +17,7 @@ internal class MainWindowService : IMainWindowService protected virtual void OnMainWindowClosed() { + UI.ClearCache(); MainWindowClosed?.Invoke(this, EventArgs.Empty); } diff --git a/src/Artemis.UI.Shared/Services/ProfileEditor/IProfileEditorService.cs b/src/Artemis.UI.Shared/Services/ProfileEditor/IProfileEditorService.cs index e766659c6..a0c9cea17 100644 --- a/src/Artemis.UI.Shared/Services/ProfileEditor/IProfileEditorService.cs +++ b/src/Artemis.UI.Shared/Services/ProfileEditor/IProfileEditorService.cs @@ -65,7 +65,7 @@ public interface IProfileEditorService : IArtemisSharedUIService /// Changes the selected profile by its . /// /// The profile configuration of the profile to select. - void ChangeCurrentProfileConfiguration(ProfileConfiguration? profileConfiguration); + Task ChangeCurrentProfileConfiguration(ProfileConfiguration? profileConfiguration); /// /// Changes the selected profile element. diff --git a/src/Artemis.UI.Shared/Services/ProfileEditor/ProfileEditorService.cs b/src/Artemis.UI.Shared/Services/ProfileEditor/ProfileEditorService.cs index 270798b89..42390dd3e 100644 --- a/src/Artemis.UI.Shared/Services/ProfileEditor/ProfileEditorService.cs +++ b/src/Artemis.UI.Shared/Services/ProfileEditor/ProfileEditorService.cs @@ -10,6 +10,7 @@ using Artemis.Core; using Artemis.Core.Services; using Artemis.UI.Shared.Services.MainWindow; using Artemis.UI.Shared.Services.ProfileEditor.Commands; +using Avalonia.Threading; using DynamicData; using Serilog; @@ -155,14 +156,13 @@ internal class ProfileEditorService : IProfileEditorService public IObservable LayerProperty { get; } public IObservable History { get; } public IObservable SuspendedEditing { get; } - public IObservable SuspendedKeybindings { get; } public IObservable Time { get; } public IObservable Playing { get; } public IObservable PixelsPerSecond { get; } public IObservable FocusMode { get; } public ReadOnlyObservableCollection SelectedKeyframes { get; } - public void ChangeCurrentProfileConfiguration(ProfileConfiguration? profileConfiguration) + public async Task ChangeCurrentProfileConfiguration(ProfileConfiguration? profileConfiguration) { if (ReferenceEquals(_profileConfigurationSubject.Value, profileConfiguration)) return; @@ -177,7 +177,7 @@ internal class ProfileEditorService : IProfileEditorService _profileConfigurationSubject.Value.Profile.LastSelectedProfileElement = _profileElementSubject.Value; } - SaveProfile(); + await SaveProfileAsync(); // No need to deactivate the profile, if needed it will be deactivated next update if (_profileConfigurationSubject.Value != null) @@ -192,11 +192,13 @@ internal class ProfileEditorService : IProfileEditorService // The new profile may need activation if (profileConfiguration != null) { - profileConfiguration.IsBeingEdited = true; - _moduleService.SetActivationOverride(profileConfiguration.Module); - _profileService.ActivateProfile(profileConfiguration); - _profileService.RenderForEditor = true; - + await Task.Run(() => + { + profileConfiguration.IsBeingEdited = true; + _moduleService.SetActivationOverride(profileConfiguration.Module); + _profileService.ActivateProfile(profileConfiguration); + _profileService.RenderForEditor = true; + }); if (profileConfiguration.Profile?.LastSelectedProfileElement is RenderProfileElement renderProfileElement) ChangeCurrentProfileElement(renderProfileElement); } diff --git a/src/Artemis.UI.Shared/Utilities.cs b/src/Artemis.UI.Shared/Utilities.cs index 832e07b75..344aa4834 100644 --- a/src/Artemis.UI.Shared/Utilities.cs +++ b/src/Artemis.UI.Shared/Utilities.cs @@ -1,5 +1,7 @@ using System; +using System.Reactive.Concurrency; using System.Reactive.Linq; +using System.Threading; using Avalonia.Controls; using Avalonia.Input; using Avalonia.Input.Platform; @@ -16,6 +18,13 @@ public static class UI { KeyBindingsEnabled = InputElement.GotFocusEvent.Raised.Select(e => e.Item2.Source is not TextBox).StartWith(true); } + + public static EventLoopScheduler BackgroundScheduler = new EventLoopScheduler(ts => new Thread(ts)); + + internal static void ClearCache() + { + DeviceVisualizer.BitmapCache.Clear(); + } /// /// Gets the current IoC locator. diff --git a/src/Artemis.UI.Windows/App.axaml b/src/Artemis.UI.Windows/App.axaml index b9e069434..da6064c9c 100644 --- a/src/Artemis.UI.Windows/App.axaml +++ b/src/Artemis.UI.Windows/App.axaml @@ -17,10 +17,10 @@ - - - - + + + + diff --git a/src/Artemis.UI.Windows/Providers/WindowsUpdateNotificationProvider.cs b/src/Artemis.UI.Windows/Providers/WindowsUpdateNotificationProvider.cs index f77ee9226..8ca86dd87 100644 --- a/src/Artemis.UI.Windows/Providers/WindowsUpdateNotificationProvider.cs +++ b/src/Artemis.UI.Windows/Providers/WindowsUpdateNotificationProvider.cs @@ -7,6 +7,7 @@ using System.Threading.Tasks; using Windows.UI.Notifications; using Artemis.UI.Screens.Settings; using Artemis.UI.Services.Updating; +using Artemis.UI.Shared.Routing; using Artemis.UI.Shared.Services.MainWindow; using Avalonia.Threading; using Microsoft.Toolkit.Uwp.Notifications; @@ -17,19 +18,16 @@ namespace Artemis.UI.Windows.Providers; public class WindowsUpdateNotificationProvider : IUpdateNotificationProvider { private readonly Func _getReleaseInstaller; - private readonly Func _getSettingsViewModel; private readonly IMainWindowService _mainWindowService; private readonly IUpdateService _updateService; + private readonly IRouter _router; private CancellationTokenSource? _cancellationTokenSource; - public WindowsUpdateNotificationProvider(IMainWindowService mainWindowService, - IUpdateService updateService, - Func getSettingsViewModel, - Func getReleaseInstaller) + public WindowsUpdateNotificationProvider(IMainWindowService mainWindowService, IUpdateService updateService, IRouter router, Func getReleaseInstaller) { _mainWindowService = mainWindowService; _updateService = updateService; - _getSettingsViewModel = getSettingsViewModel; + _router = router; _getReleaseInstaller = getReleaseInstaller; ToastNotificationManagerCompat.OnActivated += ToastNotificationManagerCompatOnOnActivated; } @@ -57,25 +55,15 @@ public class WindowsUpdateNotificationProvider : IUpdateNotificationProvider .Show(); } - private void ViewRelease(string releaseVersion) + private void ViewRelease(Guid? releaseId) { - Dispatcher.UIThread.Post(() => + Dispatcher.UIThread.Invoke(async () => { _mainWindowService.OpenMainWindow(); - if (_mainWindowService.HostScreen == null) - return; - - // TODO: When proper routing has been implemented, use that here - // Create a settings VM to navigate to - SettingsViewModel settingsViewModel = _getSettingsViewModel(_mainWindowService.HostScreen); - // Get the release tab - ReleasesTabViewModel releaseTabViewModel = (ReleasesTabViewModel) settingsViewModel.SettingTabs.First(t => t is ReleasesTabViewModel); - - // Navigate to the settings VM - _mainWindowService.HostScreen.Router.Navigate.Execute(settingsViewModel); - // Navigate to the release tab - releaseTabViewModel.PreselectVersion = releaseVersion; - settingsViewModel.SelectedTab = releaseTabViewModel; + if (releaseId != null) + await _router.Navigate($"settings/releases/{releaseId}"); + else + await _router.Navigate($"settings/releases"); }); } @@ -173,7 +161,7 @@ public class WindowsUpdateNotificationProvider : IUpdateNotificationProvider if (action == "install") await InstallRelease(releaseId, releaseVersion); else if (action == "view-changes") - ViewRelease(releaseVersion); + ViewRelease(releaseId); else if (action == "cancel") _cancellationTokenSource?.Cancel(); else if (action == "restart-for-update") diff --git a/src/Artemis.UI/Artemis.UI.csproj b/src/Artemis.UI/Artemis.UI.csproj index 54e584d67..0dd2f9d09 100644 --- a/src/Artemis.UI/Artemis.UI.csproj +++ b/src/Artemis.UI/Artemis.UI.csproj @@ -37,6 +37,7 @@ + diff --git a/src/Artemis.UI/Converters/SubstringConverter.cs b/src/Artemis.UI/Converters/SubstringConverter.cs new file mode 100644 index 000000000..8ed7dc8fb --- /dev/null +++ b/src/Artemis.UI/Converters/SubstringConverter.cs @@ -0,0 +1,24 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using Avalonia.Data.Converters; + +namespace Artemis.UI.Converters; + +public class SubstringConverter : IValueConverter +{ + /// + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (parameter == null || !int.TryParse((string) parameter, out int intParameter)) + return value; + return value?.ToString()?.Substring(0, intParameter); + } + + /// + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + { + return value; + } +} \ No newline at end of file diff --git a/src/Artemis.UI/DryIoc/Factories/IVMFactory.cs b/src/Artemis.UI/DryIoc/Factories/IVMFactory.cs index 81dca6eff..e9c315f05 100644 --- a/src/Artemis.UI/DryIoc/Factories/IVMFactory.cs +++ b/src/Artemis.UI/DryIoc/Factories/IVMFactory.cs @@ -5,6 +5,7 @@ using Artemis.Core; using Artemis.Core.LayerBrushes; using Artemis.Core.LayerEffects; using Artemis.Core.ScriptingProviders; +using Artemis.UI.Routing; using Artemis.UI.Screens.Device; using Artemis.UI.Screens.Plugins; using Artemis.UI.Screens.Plugins.Features; @@ -26,6 +27,8 @@ using Artemis.UI.Screens.SurfaceEditor; using Artemis.UI.Screens.VisualScripting; using Artemis.UI.Screens.VisualScripting.Pins; using Artemis.UI.Shared; +using Artemis.UI.Shared.Routing; +using Artemis.WebClient.Updating; using DryIoc; using ReactiveUI; @@ -123,7 +126,6 @@ public class SettingsVmFactory : ISettingsVmFactory public interface ISidebarVmFactory : IVmFactory { - SidebarViewModel? SidebarViewModel(IScreen hostScreen); SidebarCategoryViewModel SidebarCategoryViewModel(ProfileCategory profileCategory); SidebarProfileConfigurationViewModel SidebarProfileConfigurationViewModel(ProfileConfiguration profileConfiguration); } @@ -135,12 +137,7 @@ public class SidebarVmFactory : ISidebarVmFactory { _container = container; } - - public SidebarViewModel? SidebarViewModel(IScreen hostScreen) - { - return _container.Resolve(new object[] { hostScreen }); - } - + public SidebarCategoryViewModel SidebarCategoryViewModel(ProfileCategory profileCategory) { return _container.Resolve(new object[] { profileCategory }); @@ -483,7 +480,7 @@ public class ScriptVmFactory : IScriptVmFactory public interface IReleaseVmFactory : IVmFactory { - ReleaseViewModel ReleaseListViewModel(Guid releaseId, string version, DateTimeOffset createdAt); + ReleaseViewModel ReleaseListViewModel(IGetReleases_PublishedReleases_Nodes release); } public class ReleaseVmFactory : IReleaseVmFactory { @@ -494,8 +491,8 @@ public class ReleaseVmFactory : IReleaseVmFactory _container = container; } - public ReleaseViewModel ReleaseListViewModel(Guid releaseId, string version, DateTimeOffset createdAt) + public ReleaseViewModel ReleaseListViewModel(IGetReleases_PublishedReleases_Nodes release) { - return _container.Resolve(new object[] { releaseId, version, createdAt }); + return _container.Resolve(new object[] { release }); } } \ No newline at end of file diff --git a/src/Artemis.UI/MainWindow.axaml b/src/Artemis.UI/MainWindow.axaml index 97f3994e0..d735ae109 100644 --- a/src/Artemis.UI/MainWindow.axaml +++ b/src/Artemis.UI/MainWindow.axaml @@ -10,7 +10,8 @@ Icon="/Assets/Images/Logo/application.ico" Title="Artemis 2.0" MinWidth="600" - MinHeight="400"> + MinHeight="400" + PointerReleased="InputElement_OnPointerReleased"> - @@ -30,6 +22,12 @@ + + + + + + + + + + + + + Release info + + + + + + + + + + + + + + + + + + Ready, restart to install + + + + + + + + + + + + Release date + + + + + + Source + + + + + + File size + + + + + + + + + + Release notes + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Settings/Updating/ReleaseDetailsView.axaml.cs b/src/Artemis.UI/Screens/Settings/Updating/ReleaseDetailsView.axaml.cs new file mode 100644 index 000000000..56174c663 --- /dev/null +++ b/src/Artemis.UI/Screens/Settings/Updating/ReleaseDetailsView.axaml.cs @@ -0,0 +1,19 @@ +using Avalonia.Input; +using Avalonia.Markup.Xaml; +using Avalonia.ReactiveUI; + +namespace Artemis.UI.Screens.Settings.Updating; + +public partial class ReleaseDetailsView : ReactiveUserControl +{ + public ReleaseDetailsView() + { + InitializeComponent(); + } + + + private void InputElement_OnPointerReleased(object? sender, PointerReleasedEventArgs e) + { + ViewModel?.NavigateToSource(); + } +} \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Settings/Updating/ReleaseDetailsViewModel.cs b/src/Artemis.UI/Screens/Settings/Updating/ReleaseDetailsViewModel.cs new file mode 100644 index 000000000..2ea6be462 --- /dev/null +++ b/src/Artemis.UI/Screens/Settings/Updating/ReleaseDetailsViewModel.cs @@ -0,0 +1,195 @@ +using System; +using System.Linq; +using System.Reactive; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using System.Threading; +using System.Threading.Tasks; +using Artemis.Core; +using Artemis.UI.Services.Updating; +using Artemis.UI.Shared; +using Artemis.UI.Shared.Routing; +using Artemis.UI.Shared.Services; +using Artemis.UI.Shared.Services.Builders; +using Artemis.WebClient.Updating; +using ReactiveUI; +using Serilog; +using StrawberryShake; + +namespace Artemis.UI.Screens.Settings.Updating; + +public class ReleaseDetailsViewModel : RoutableScreen +{ + private readonly ObservableAsPropertyHelper _fileSize; + private readonly ILogger _logger; + private readonly INotificationService _notificationService; + private readonly IUpdateService _updateService; + private readonly IUpdatingClient _updatingClient; + private bool _installationAvailable; + private bool _installationFinished; + private bool _installationInProgress; + + private CancellationTokenSource? _installerCts; + private bool _loading = true; + private IGetReleaseById_PublishedRelease? _release; + private ReleaseInstaller? _releaseInstaller; + + public ReleaseDetailsViewModel(ILogger logger, IUpdatingClient updatingClient, INotificationService notificationService, IUpdateService updateService) + { + _logger = logger; + _updatingClient = updatingClient; + _notificationService = notificationService; + _updateService = updateService; + + Platform updatePlatform; + if (OperatingSystem.IsWindows()) + updatePlatform = Platform.Windows; + else if (OperatingSystem.IsLinux()) + updatePlatform = Platform.Linux; + else if (OperatingSystem.IsMacOS()) + updatePlatform = Platform.Osx; + else + throw new PlatformNotSupportedException("Cannot auto update on the current platform"); + + Install = ReactiveCommand.CreateFromTask(ExecuteInstall); + Restart = ReactiveCommand.Create(ExecuteRestart); + CancelInstall = ReactiveCommand.Create(() => _installerCts?.Cancel()); + + _fileSize = this.WhenAnyValue(vm => vm.Release) + .Select(release => release?.Artifacts.FirstOrDefault(a => a.Platform == updatePlatform)?.FileInfo.DownloadSize ?? 0) + .ToProperty(this, vm => vm.FileSize); + + this.WhenActivated(d => Disposable.Create(_installerCts, cts => cts?.Cancel()).DisposeWith(d)); + } + + public ReactiveCommand Restart { get; } + public ReactiveCommand Install { get; } + public ReactiveCommand CancelInstall { get; } + + public long FileSize => _fileSize.Value; + + public IGetReleaseById_PublishedRelease? Release + { + get => _release; + set => RaiseAndSetIfChanged(ref _release, value); + } + + public ReleaseInstaller? ReleaseInstaller + { + get => _releaseInstaller; + set => RaiseAndSetIfChanged(ref _releaseInstaller, value); + } + + public bool Loading + { + get => _loading; + private set => RaiseAndSetIfChanged(ref _loading, value); + } + + public bool InstallationAvailable + { + get => _installationAvailable; + set => RaiseAndSetIfChanged(ref _installationAvailable, value); + } + + public bool InstallationInProgress + { + get => _installationInProgress; + set => RaiseAndSetIfChanged(ref _installationInProgress, value); + } + + public bool InstallationFinished + { + get => _installationFinished; + set => RaiseAndSetIfChanged(ref _installationFinished, value); + } + + public void NavigateToSource() + { + if (Release != null) + Utilities.OpenUrl($"https://github.com/Artemis-RGB/Artemis/commit/{Release.Commit}"); + } + + /// + public override async Task OnNavigating(ReleaseDetailsViewModelParameters parameters, NavigationArguments args, CancellationToken cancellationToken) + { + // There's no point in running anything but the latest version of the current channel. + // Perhaps later that won't be true anymore, then we could consider allowing to install + // older versions with compatible database versions. + InstallationAvailable = _updateService.CachedLatestRelease?.Id == parameters.ReleaseId; + await RetrieveDetails(parameters.ReleaseId, cancellationToken); + ReleaseInstaller = _updateService.GetReleaseInstaller(parameters.ReleaseId); + } + + private void ExecuteRestart() + { + _updateService.RestartForUpdate(false); + } + + private async Task ExecuteInstall(CancellationToken cancellationToken) + { + if (ReleaseInstaller == null) + return; + + _installerCts = new CancellationTokenSource(); + try + { + InstallationInProgress = true; + await ReleaseInstaller.InstallAsync(_installerCts.Token); + InstallationFinished = true; + } + catch (Exception e) + { + if (_installerCts.IsCancellationRequested) + return; + + _logger.Warning(e, "Failed to install update through UI"); + _notificationService.CreateNotification() + .WithTitle("Failed to install update") + .WithMessage(e.Message) + .WithSeverity(NotificationSeverity.Warning) + .Show(); + } + finally + { + InstallationInProgress = false; + } + } + + private async Task RetrieveDetails(Guid releaseId, CancellationToken cancellationToken) + { + try + { + Loading = true; + + IOperationResult result = await _updatingClient.GetReleaseById.ExecuteAsync(releaseId, cancellationToken); + IGetReleaseById_PublishedRelease? release = result.Data?.PublishedRelease; + if (release == null) + return; + + Release = release; + } + catch (TaskCanceledException) + { + // ignored + } + catch (Exception e) + { + _logger.Warning(e, "Failed to retrieve release details"); + _notificationService.CreateNotification() + .WithTitle("Failed to retrieve details") + .WithMessage(e.Message) + .WithSeverity(NotificationSeverity.Warning) + .Show(); + } + finally + { + Loading = false; + } + } +} + +public class ReleaseDetailsViewModelParameters +{ + public Guid ReleaseId { get; set; } +} \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Settings/Updating/ReleaseView.axaml b/src/Artemis.UI/Screens/Settings/Updating/ReleaseView.axaml index 5e820c4ce..97e58bd65 100644 --- a/src/Artemis.UI/Screens/Settings/Updating/ReleaseView.axaml +++ b/src/Artemis.UI/Screens/Settings/Updating/ReleaseView.axaml @@ -3,163 +3,22 @@ xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:updating="clr-namespace:Artemis.UI.Screens.Settings.Updating" - xmlns:avalonia="clr-namespace:Markdown.Avalonia;assembly=Markdown.Avalonia" - xmlns:avalonia1="clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia" - xmlns:converters="clr-namespace:Artemis.UI.Shared.Converters;assembly=Artemis.UI.Shared" - mc:Ignorable="d" d:DesignWidth="1000" d:DesignHeight="1400" + xmlns:avalonia="clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia" + mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" x:Class="Artemis.UI.Screens.Settings.Updating.ReleaseView" x:DataType="updating:ReleaseViewModel"> - - - - - - - - - - - - - - - - - - - Release info - - - - - - - - - - - - - - - - - - Ready, restart to install - - - - - - - - - - - - Release date - - - - - - Source - - - - - - File size - - - - - - - - - - Release notes - - - - - - - - - - - - \ No newline at end of file + + + + + + + + + + + + + diff --git a/src/Artemis.UI/Screens/Settings/Updating/ReleaseView.axaml.cs b/src/Artemis.UI/Screens/Settings/Updating/ReleaseView.axaml.cs index 64c2af738..29e385e8a 100644 --- a/src/Artemis.UI/Screens/Settings/Updating/ReleaseView.axaml.cs +++ b/src/Artemis.UI/Screens/Settings/Updating/ReleaseView.axaml.cs @@ -1,19 +1,18 @@ -using Avalonia.Input; +using Avalonia; +using Avalonia.Controls; using Avalonia.Markup.Xaml; -using Avalonia.ReactiveUI; namespace Artemis.UI.Screens.Settings.Updating; -public partial class ReleaseView : ReactiveUserControl +public partial class ReleaseView : UserControl { public ReleaseView() { InitializeComponent(); } - - private void InputElement_OnPointerReleased(object? sender, PointerReleasedEventArgs e) + private void InitializeComponent() { - ViewModel?.NavigateToSource(); + AvaloniaXamlLoader.Load(this); } } \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Settings/Updating/ReleaseViewModel.cs b/src/Artemis.UI/Screens/Settings/Updating/ReleaseViewModel.cs index bb640c1d3..0ff84129b 100644 --- a/src/Artemis.UI/Screens/Settings/Updating/ReleaseViewModel.cs +++ b/src/Artemis.UI/Screens/Settings/Updating/ReleaseViewModel.cs @@ -1,220 +1,22 @@ -using System; -using System.Linq; -using System.Reactive; -using System.Reactive.Disposables; -using System.Reactive.Threading.Tasks; -using System.Threading; -using System.Threading.Tasks; using Artemis.Core; -using Artemis.UI.Extensions; using Artemis.UI.Services.Updating; using Artemis.UI.Shared; -using Artemis.UI.Shared.Services; -using Artemis.UI.Shared.Services.Builders; using Artemis.WebClient.Updating; -using ReactiveUI; -using Serilog; -using StrawberryShake; namespace Artemis.UI.Screens.Settings.Updating; -public class ReleaseViewModel : ActivatableViewModelBase +public class ReleaseViewModel : ViewModelBase { - private readonly ILogger _logger; - private readonly INotificationService _notificationService; private readonly IUpdateService _updateService; - private readonly Platform _updatePlatform; - private readonly IUpdatingClient _updatingClient; - private CancellationTokenSource? _installerCts; - private string? _changelog; - private string? _commit; - private string? _shortCommit; - private long _fileSize; - private bool _installationAvailable; - private bool _installationFinished; - private bool _installationInProgress; - private bool _loading = true; - private bool _retrievedDetails; + public IGetReleases_PublishedReleases_Nodes Release { get; } - public ReleaseViewModel(Guid releaseId, - string version, - DateTimeOffset createdAt, - ILogger logger, - IUpdatingClient updatingClient, - INotificationService notificationService, - IUpdateService updateService) + public ReleaseViewModel(IUpdateService updateService, IGetReleases_PublishedReleases_Nodes release) { - _logger = logger; - _updatingClient = updatingClient; - _notificationService = notificationService; _updateService = updateService; - - if (OperatingSystem.IsWindows()) - _updatePlatform = Platform.Windows; - else if (OperatingSystem.IsLinux()) - _updatePlatform = Platform.Linux; - else if (OperatingSystem.IsMacOS()) - _updatePlatform = Platform.Osx; - else - throw new PlatformNotSupportedException("Cannot auto update on the current platform"); - - - ReleaseId = releaseId; - Version = version; - CreatedAt = createdAt; - ReleaseInstaller = updateService.GetReleaseInstaller(ReleaseId); - - Install = ReactiveCommand.CreateFromTask(ExecuteInstall); - Restart = ReactiveCommand.Create(ExecuteRestart); - CancelInstall = ReactiveCommand.Create(() => _installerCts?.Cancel()); - - this.WhenActivated(d => - { - // There's no point in running anything but the latest version of the current channel. - // Perhaps later that won't be true anymore, then we could consider allowing to install - // older versions with compatible database versions. - InstallationAvailable = _updateService.CachedLatestRelease?.Id == ReleaseId; - RetrieveDetails(d.AsCancellationToken()).ToObservable(); - Disposable.Create(_installerCts, cts => cts?.Cancel()).DisposeWith(d); - }); + Release = release; } - - public Guid ReleaseId { get; } - - private void ExecuteRestart() - { - _updateService.RestartForUpdate(false); - } - - public ReactiveCommand Restart { get; set; } - public ReactiveCommand Install { get; } - public ReactiveCommand CancelInstall { get; } - - public string Version { get; } - public DateTimeOffset CreatedAt { get; } - public ReleaseInstaller ReleaseInstaller { get; } - - public string? Changelog - { - get => _changelog; - set => RaiseAndSetIfChanged(ref _changelog, value); - } - - public string? Commit - { - get => _commit; - set => RaiseAndSetIfChanged(ref _commit, value); - } - - public string? ShortCommit - { - get => _shortCommit; - set => RaiseAndSetIfChanged(ref _shortCommit, value); - } - - public long FileSize - { - get => _fileSize; - set => RaiseAndSetIfChanged(ref _fileSize, value); - } - - public bool Loading - { - get => _loading; - private set => RaiseAndSetIfChanged(ref _loading, value); - } - - public bool InstallationAvailable - { - get => _installationAvailable; - set => RaiseAndSetIfChanged(ref _installationAvailable, value); - } - - public bool InstallationInProgress - { - get => _installationInProgress; - set => RaiseAndSetIfChanged(ref _installationInProgress, value); - } - - public bool InstallationFinished - { - get => _installationFinished; - set => RaiseAndSetIfChanged(ref _installationFinished, value); - } - - public bool IsCurrentVersion => Version == Constants.CurrentVersion; - public bool IsPreviousVersion => Version == _updateService.PreviousVersion; - public bool ShowStatusIndicator => IsCurrentVersion || IsPreviousVersion; - public void NavigateToSource() - { - Utilities.OpenUrl($"https://github.com/Artemis-RGB/Artemis/commit/{Commit}"); - } - - private async Task ExecuteInstall(CancellationToken cancellationToken) - { - _installerCts = new CancellationTokenSource(); - try - { - InstallationInProgress = true; - await ReleaseInstaller.InstallAsync(_installerCts.Token); - InstallationFinished = true; - } - catch (Exception e) - { - if (_installerCts.IsCancellationRequested) - return; - - _logger.Warning(e, "Failed to install update through UI"); - _notificationService.CreateNotification() - .WithTitle("Failed to install update") - .WithMessage(e.Message) - .WithSeverity(NotificationSeverity.Warning) - .Show(); - } - finally - { - InstallationInProgress = false; - } - } - - private async Task RetrieveDetails(CancellationToken cancellationToken) - { - if (_retrievedDetails) - return; - - try - { - Loading = true; - - IOperationResult result = await _updatingClient.GetReleaseById.ExecuteAsync(ReleaseId, cancellationToken); - IGetReleaseById_PublishedRelease? release = result.Data?.PublishedRelease; - if (release == null) - return; - - Changelog = release.Changelog; - Commit = release.Commit; - ShortCommit = release.Commit.Substring(0, 7); - FileSize = release.Artifacts.FirstOrDefault(a => a.Platform == _updatePlatform)?.FileInfo.DownloadSize ?? 0; - - _retrievedDetails = true; - } - catch (TaskCanceledException) - { - // ignored - } - catch (Exception e) - { - _logger.Warning(e, "Failed to retrieve release details"); - _notificationService.CreateNotification() - .WithTitle("Failed to retrieve details") - .WithMessage(e.Message) - .WithSeverity(NotificationSeverity.Warning) - .Show(); - } - finally - { - Loading = false; - } - } + public bool IsCurrentVersion => Release.Version == Constants.CurrentVersion; + public bool IsPreviousVersion => Release.Version == _updateService.PreviousVersion; + public bool ShowStatusIndicator => IsCurrentVersion || IsPreviousVersion; } \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Sidebar/Dialogs/ProfileConfigurationEditViewModel.cs b/src/Artemis.UI/Screens/Sidebar/Dialogs/ProfileConfigurationEditViewModel.cs index c3f3a092b..c0d130ce6 100644 --- a/src/Artemis.UI/Screens/Sidebar/Dialogs/ProfileConfigurationEditViewModel.cs +++ b/src/Artemis.UI/Screens/Sidebar/Dialogs/ProfileConfigurationEditViewModel.cs @@ -149,7 +149,7 @@ public class ProfileConfigurationEditViewModel : DialogViewModelBase? _isCollapsed; private ObservableAsPropertyHelper? _isSuspended; private SidebarProfileConfigurationViewModel? _selectedProfileConfiguration; - public SidebarCategoryViewModel(ProfileCategory profileCategory, - IProfileService profileService, - IWindowService windowService, - IProfileEditorService profileEditorService, - ISidebarVmFactory vmFactory) + public SidebarCategoryViewModel(ProfileCategory profileCategory, IProfileService profileService, IWindowService windowService, ISidebarVmFactory vmFactory, IRouter router) { _profileService = profileService; _windowService = windowService; - _profileEditorService = profileEditorService; _vmFactory = vmFactory; + _router = router; ProfileCategory = profileCategory; SourceList profileConfigurations = new(); @@ -66,6 +62,14 @@ public class SidebarCategoryViewModel : ActivatableViewModelBase this.WhenActivated(d => { + // Navigate on selection change + this.WhenAnyValue(vm => vm.SelectedProfileConfiguration) + .WhereNotNull() + .Subscribe(s => _router.Navigate($"profile-editor/{s.ProfileConfiguration.ProfileId}", new RouterNavigationOptions {IgnoreOnPartialMatch = true, RecycleScreens = false})) + .DisposeWith(d); + + _router.CurrentPath.WhereNotNull().Subscribe(r => SelectedProfileConfiguration = ProfileConfigurations.FirstOrDefault(c => c.Matches(r))).DisposeWith(d); + // Update the list of profiles whenever the category fires events Observable.FromEventPattern(x => profileCategory.ProfileConfigurationAdded += x, x => profileCategory.ProfileConfigurationAdded -= x) .Subscribe(e => profileConfigurations.Add(e.EventArgs.ProfileConfiguration)) @@ -73,34 +77,9 @@ public class SidebarCategoryViewModel : ActivatableViewModelBase Observable.FromEventPattern(x => profileCategory.ProfileConfigurationRemoved += x, x => profileCategory.ProfileConfigurationRemoved -= x) .Subscribe(e => profileConfigurations.RemoveMany(profileConfigurations.Items.Where(c => c == e.EventArgs.ProfileConfiguration))) .DisposeWith(d); - - profileEditorService.ProfileConfiguration - .Subscribe(p => SelectedProfileConfiguration = ProfileConfigurations.FirstOrDefault(c => ReferenceEquals(c.ProfileConfiguration, p))) - .DisposeWith(d); - + _isCollapsed = ProfileCategory.WhenAnyValue(vm => vm.IsCollapsed).ToProperty(this, vm => vm.IsCollapsed).DisposeWith(d); _isSuspended = ProfileCategory.WhenAnyValue(vm => vm.IsSuspended).ToProperty(this, vm => vm.IsSuspended).DisposeWith(d); - - // Change the current profile configuration when a new one is selected - this.WhenAnyValue(vm => vm.SelectedProfileConfiguration) - .WhereNotNull() - .Subscribe(s => - { - try - { - profileEditorService.ChangeCurrentProfileConfiguration(s.ProfileConfiguration); - } - catch (Exception e) - { - if (s.ProfileConfiguration.BrokenState != null && s.ProfileConfiguration.BrokenStateException != null) - _windowService.ShowExceptionDialog(s.ProfileConfiguration.BrokenState, s.ProfileConfiguration.BrokenStateException); - else - _windowService.ShowExceptionDialog(e.Message, e); - - profileEditorService.ChangeCurrentProfileConfiguration(null); - SelectedProfileConfiguration = null; - } - }); }); profileConfigurations.Edit(updater => @@ -158,7 +137,7 @@ public class SidebarCategoryViewModel : ActivatableViewModelBase if (await _windowService.ShowConfirmContentDialog($"Delete {ProfileCategory.Name}", "Do you want to delete this category and all its profiles?")) { if (ProfileCategory.ProfileConfigurations.Any(c => c.IsBeingEdited)) - _profileEditorService.ChangeCurrentProfileConfiguration(null); + await _router.Navigate("home"); _profileService.DeleteProfileCategory(ProfileCategory); } } diff --git a/src/Artemis.UI/Screens/Sidebar/SidebarProfileConfigurationViewModel.cs b/src/Artemis.UI/Screens/Sidebar/SidebarProfileConfigurationViewModel.cs index 91824fde6..98749cb51 100644 --- a/src/Artemis.UI/Screens/Sidebar/SidebarProfileConfigurationViewModel.cs +++ b/src/Artemis.UI/Screens/Sidebar/SidebarProfileConfigurationViewModel.cs @@ -8,6 +8,7 @@ using System.Threading.Tasks; using Artemis.Core; using Artemis.Core.Services; using Artemis.UI.Shared; +using Artemis.UI.Shared.Routing; using Artemis.UI.Shared.Services; using Artemis.UI.Shared.Services.ProfileEditor; using Newtonsoft.Json; @@ -17,15 +18,15 @@ namespace Artemis.UI.Screens.Sidebar; public class SidebarProfileConfigurationViewModel : ActivatableViewModelBase { - private readonly IProfileEditorService _profileEditorService; + private readonly IRouter _router; private readonly IProfileService _profileService; private readonly IWindowService _windowService; private ObservableAsPropertyHelper? _isDisabled; - public SidebarProfileConfigurationViewModel(ProfileConfiguration profileConfiguration, IProfileService profileService, IProfileEditorService profileEditorService, IWindowService windowService) + public SidebarProfileConfigurationViewModel(IRouter router, ProfileConfiguration profileConfiguration, IProfileService profileService, IWindowService windowService) { + _router = router; _profileService = profileService; - _profileEditorService = profileEditorService; _windowService = windowService; ProfileConfiguration = profileConfiguration; @@ -98,7 +99,7 @@ public class SidebarProfileConfigurationViewModel : ActivatableViewModelBase return; if (ProfileConfiguration.IsBeingEdited) - _profileEditorService.ChangeCurrentProfileConfiguration(null); + await _router.Navigate("home"); _profileService.RemoveProfileConfiguration(ProfileConfiguration); } @@ -131,4 +132,9 @@ public class SidebarProfileConfigurationViewModel : ActivatableViewModelBase ProfileConfigurationExportModel export = _profileService.ExportProfile(ProfileConfiguration); _profileService.ImportProfile(ProfileConfiguration.Category, export, true, false, "copy"); } + + public bool Matches(string s) + { + return s.StartsWith("profile-editor") && s.EndsWith(ProfileConfiguration.ProfileId.ToString()); + } } \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Sidebar/SidebarScreenViewModel.cs b/src/Artemis.UI/Screens/Sidebar/SidebarScreenViewModel.cs index 51986d0cb..eea80c5dd 100644 --- a/src/Artemis.UI/Screens/Sidebar/SidebarScreenViewModel.cs +++ b/src/Artemis.UI/Screens/Sidebar/SidebarScreenViewModel.cs @@ -1,35 +1,25 @@ using System; using Artemis.UI.Shared; -using DryIoc; using Material.Icons; -using ReactiveUI; namespace Artemis.UI.Screens.Sidebar; -public class SidebarScreenViewModel : SidebarScreenViewModel where T : MainScreenViewModel +public class SidebarScreenViewModel : ViewModelBase { - public SidebarScreenViewModel(MaterialIconKind icon, string displayName) : base(icon, displayName) - { - } - - public override Type ScreenType => typeof(T); - - public override MainScreenViewModel CreateInstance(IContainer container, IScreen screen) - { - return container.Resolve(new object[] { screen }); - } -} - -public abstract class SidebarScreenViewModel : ViewModelBase -{ - protected SidebarScreenViewModel(MaterialIconKind icon, string displayName) + public SidebarScreenViewModel(MaterialIconKind icon, string displayName, string path) { Icon = icon; + Path = path; DisplayName = displayName; } public MaterialIconKind Icon { get; } + public string Path { get; } - public abstract Type ScreenType { get; } - public abstract MainScreenViewModel CreateInstance(IContainer container, IScreen screen); + public bool Matches(string? path) + { + if (path == null) + return false; + return path.StartsWith(Path, StringComparison.InvariantCultureIgnoreCase); + } } \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Sidebar/SidebarViewModel.cs b/src/Artemis.UI/Screens/Sidebar/SidebarViewModel.cs index ca13abff0..17c0e0590 100644 --- a/src/Artemis.UI/Screens/Sidebar/SidebarViewModel.cs +++ b/src/Artemis.UI/Screens/Sidebar/SidebarViewModel.cs @@ -8,17 +8,12 @@ using System.Threading.Tasks; using Artemis.Core; using Artemis.Core.Services; using Artemis.UI.DryIoc.Factories; -using Artemis.UI.Screens.Home; -using Artemis.UI.Screens.ProfileEditor; -using Artemis.UI.Screens.Settings; -using Artemis.UI.Screens.SurfaceEditor; -using Artemis.UI.Screens.Workshop; using Artemis.UI.Shared; +using Artemis.UI.Shared.Routing; using Artemis.UI.Shared.Services; using Artemis.UI.Shared.Services.Builders; -using Artemis.UI.Shared.Services.ProfileEditor; using Avalonia.ReactiveUI; -using DryIoc; +using Avalonia.Threading; using DynamicData; using DynamicData.Binding; using Material.Icons; @@ -28,50 +23,34 @@ namespace Artemis.UI.Screens.Sidebar; public class SidebarViewModel : ActivatableViewModelBase { - private readonly IScreen _hostScreen; - private readonly IContainer _container; - private readonly IProfileEditorService _profileEditorService; - private readonly IProfileEditorVmFactory _profileEditorVmFactory; + private readonly IRouter _router; private readonly IWindowService _windowService; private SidebarScreenViewModel? _selectedSidebarScreen; private ReadOnlyObservableCollection _sidebarCategories = new(new ObservableCollection()); - public SidebarViewModel(IScreen hostScreen, - IContainer container, - IProfileService profileService, - IWindowService windowService, - IProfileEditorService profileEditorService, - ISidebarVmFactory sidebarVmFactory, - IProfileEditorVmFactory profileEditorVmFactory) + public SidebarViewModel(IRouter router, IProfileService profileService, IWindowService windowService, ISidebarVmFactory sidebarVmFactory) { - _hostScreen = hostScreen; - _container = container; + _router = router; _windowService = windowService; - _profileEditorService = profileEditorService; - _profileEditorVmFactory = profileEditorVmFactory; SidebarScreens = new ObservableCollection { - new SidebarScreenViewModel(MaterialIconKind.Home, "Home"), + new(MaterialIconKind.Home, "Home", "home"), #if DEBUG - new SidebarScreenViewModel(MaterialIconKind.TestTube, "Workshop"), + new(MaterialIconKind.TestTube, "Workshop", "workshop"), #endif - new SidebarScreenViewModel(MaterialIconKind.Devices, "Surface Editor"), - new SidebarScreenViewModel(MaterialIconKind.Cog, "Settings") + new(MaterialIconKind.Devices, "Surface Editor", "surface-editor"), + new(MaterialIconKind.Cog, "Settings", "settings") }; AddCategory = ReactiveCommand.CreateFromTask(ExecuteAddCategory); SourceList profileCategories = new(); + this.WhenAnyValue(vm => vm.SelectedSidebarScreen).WhereNotNull().Subscribe(NavigateToScreen); this.WhenActivated(d => { - this.WhenAnyObservable(vm => vm._hostScreen.Router.CurrentViewModel).WhereNotNull() - .Subscribe(c => SelectedSidebarScreen = SidebarScreens.FirstOrDefault(s => s.ScreenType == c.GetType())) - .DisposeWith(d); - - this.WhenAnyValue(vm => vm.SelectedSidebarScreen).WhereNotNull().Subscribe(NavigateToScreen); - this.WhenAnyObservable(vm => vm._profileEditorService.ProfileConfiguration).Subscribe(NavigateToProfile).DisposeWith(d); + _router.CurrentPath.WhereNotNull().Subscribe(r => SelectedSidebarScreen = SidebarScreens.FirstOrDefault(s => s.Matches(r))).DisposeWith(d); Observable.FromEventPattern(x => profileService.ProfileCategoryAdded += x, x => profileService.ProfileCategoryAdded -= x) .Subscribe(e => profileCategories.Add(e.EventArgs.ProfileCategory)) @@ -127,21 +106,18 @@ public class SidebarViewModel : ActivatableViewModelBase .ShowAsync(); } - private void NavigateToProfile(ProfileConfiguration? profile) - { - if (profile == null && _hostScreen.Router.GetCurrentViewModel() is ProfileEditorViewModel) - SelectedSidebarScreen = SidebarScreens.FirstOrDefault(); - else if (profile != null && _hostScreen.Router.GetCurrentViewModel() is not ProfileEditorViewModel) - _hostScreen.Router.Navigate.Execute(_profileEditorVmFactory.ProfileEditorViewModel(_hostScreen)); - } - private void NavigateToScreen(SidebarScreenViewModel sidebarScreenViewModel) { - // If the current screen changed through external means and already matches, don't navigate again - if (_hostScreen.Router.GetCurrentViewModel()?.GetType() == sidebarScreenViewModel.ScreenType) - return; - - _hostScreen.Router.Navigate.Execute(sidebarScreenViewModel.CreateInstance(_container, _hostScreen)); - _profileEditorService.ChangeCurrentProfileConfiguration(null); + Dispatcher.UIThread.Invoke(async () => + { + try + { + await _router.Navigate(sidebarScreenViewModel.Path, new RouterNavigationOptions {IgnoreOnPartialMatch = true}); + } + catch (Exception e) + { + _windowService.ShowExceptionDialog("Navigation failed", e); + } + }); } } \ No newline at end of file diff --git a/src/Artemis.UI/Screens/SurfaceEditor/SurfaceEditorViewModel.cs b/src/Artemis.UI/Screens/SurfaceEditor/SurfaceEditorViewModel.cs index 0df1ca26c..e68d8e13a 100644 --- a/src/Artemis.UI/Screens/SurfaceEditor/SurfaceEditorViewModel.cs +++ b/src/Artemis.UI/Screens/SurfaceEditor/SurfaceEditorViewModel.cs @@ -9,6 +9,7 @@ using Artemis.Core; using Artemis.Core.Services; using Artemis.UI.DryIoc.Factories; using Artemis.UI.Extensions; +using Artemis.UI.Shared; using Artemis.UI.Shared.Services; using Avalonia; using ReactiveUI; @@ -16,7 +17,7 @@ using SkiaSharp; namespace Artemis.UI.Screens.SurfaceEditor; -public class SurfaceEditorViewModel : MainScreenViewModel +public class SurfaceEditorViewModel : ActivatableViewModelBase, IMainScreenViewModel { private readonly IDeviceService _deviceService; private readonly IDeviceVmFactory _deviceVmFactory; @@ -30,14 +31,13 @@ public class SurfaceEditorViewModel : MainScreenViewModel private double _overlayOpacity; private bool _saving; - public SurfaceEditorViewModel(IScreen hostScreen, - ICoreService coreService, + public SurfaceEditorViewModel(ICoreService coreService, IRgbService rgbService, ISurfaceVmFactory surfaceVmFactory, ISettingsService settingsService, IDeviceVmFactory deviceVmFactory, IWindowService windowService, - IDeviceService deviceService) : base(hostScreen, "surface-editor") + IDeviceService deviceService) { _rgbService = rgbService; _surfaceVmFactory = surfaceVmFactory; @@ -71,6 +71,8 @@ public class SurfaceEditorViewModel : MainScreenViewModel }).DisposeWith(d); }); } + + public ViewModelBase? TitleBarViewModel => null; public bool ColorDevices { diff --git a/src/Artemis.UI/Screens/Workshop/WorkshopViewModel.cs b/src/Artemis.UI/Screens/Workshop/WorkshopViewModel.cs index 06be34781..951b7c8e0 100644 --- a/src/Artemis.UI/Screens/Workshop/WorkshopViewModel.cs +++ b/src/Artemis.UI/Screens/Workshop/WorkshopViewModel.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.ObjectModel; using System.Reactive; using System.Reactive.Linq; @@ -15,7 +15,7 @@ using StrawberryShake; namespace Artemis.UI.Screens.Workshop; -public class WorkshopViewModel : MainScreenViewModel +public class WorkshopViewModel : ActivatableViewModelBase, IMainScreenViewModel { private readonly IWorkshopClient _workshopClient; diff --git a/src/Artemis.UI/Services/RegistrationService.cs b/src/Artemis.UI/Services/RegistrationService.cs index e4d50ec9e..32288e8c3 100644 --- a/src/Artemis.UI/Services/RegistrationService.cs +++ b/src/Artemis.UI/Services/RegistrationService.cs @@ -9,6 +9,7 @@ using Artemis.UI.DefaultTypes.PropertyInput; using Artemis.UI.Services.Interfaces; using Artemis.UI.Shared.DefaultTypes.DataModel.Display; using Artemis.UI.Shared.Providers; +using Artemis.UI.Shared.Routing; using Artemis.UI.Shared.Services; using Artemis.UI.Shared.Services.ProfileEditor; using Artemis.UI.Shared.Services.PropertyInput; @@ -24,12 +25,14 @@ public class RegistrationService : IRegistrationService private readonly IDataModelUIService _dataModelUIService; private readonly IInputService _inputService; private readonly IContainer _container; + private readonly IRouter _router; private readonly INodeService _nodeService; private readonly IPropertyInputService _propertyInputService; private readonly IWebServerService _webServerService; private bool _registeredBuiltInPropertyEditors; public RegistrationService(IContainer container, + IRouter router, IInputService inputService, IPropertyInputService propertyInputService, IProfileEditorService profileEditorService, @@ -40,6 +43,7 @@ public class RegistrationService : IRegistrationService ) { _container = container; + _router = router; _inputService = inputService; _propertyInputService = propertyInputService; _nodeService = nodeService; @@ -47,10 +51,16 @@ public class RegistrationService : IRegistrationService _webServerService = webServerService; CreateCursorResources(); + RegisterRoutes(); RegisterBuiltInNodeTypes(); RegisterControllers(); } + private void RegisterRoutes() + { + _router.Routes.AddRange(Routing.Routes.ArtemisRoutes); + } + private void CreateCursorResources() { ICursorProvider? cursorProvider = _container.Resolve(IfUnresolved.ReturnDefault); diff --git a/src/Artemis.UI/Services/Updating/BasicUpdateNotificationProvider.cs b/src/Artemis.UI/Services/Updating/BasicUpdateNotificationProvider.cs index 6d0335ad7..b5e84fa9d 100644 --- a/src/Artemis.UI/Services/Updating/BasicUpdateNotificationProvider.cs +++ b/src/Artemis.UI/Services/Updating/BasicUpdateNotificationProvider.cs @@ -1,47 +1,28 @@ using System; -using System.Linq; -using Artemis.UI.Screens.Settings; +using System.Threading.Tasks; +using Artemis.UI.Shared.Routing; using Artemis.UI.Shared.Services; using Artemis.UI.Shared.Services.Builders; using Artemis.UI.Shared.Services.MainWindow; -using ReactiveUI; namespace Artemis.UI.Services.Updating; public class BasicUpdateNotificationProvider : IUpdateNotificationProvider { - private readonly Func _getSettingsViewModel; private readonly IMainWindowService _mainWindowService; private readonly INotificationService _notificationService; + private readonly IRouter _router; private Action? _available; private Action? _installed; - public BasicUpdateNotificationProvider(INotificationService notificationService, IMainWindowService mainWindowService, Func getSettingsViewModel) + public BasicUpdateNotificationProvider(INotificationService notificationService, IMainWindowService mainWindowService, IRouter router) { _notificationService = notificationService; _mainWindowService = mainWindowService; - _getSettingsViewModel = getSettingsViewModel; + _router = router; } - /// - public void ShowNotification(Guid releaseId, string releaseVersion) - { - if (_mainWindowService.IsMainWindowOpen) - ShowAvailable(releaseVersion); - else - _mainWindowService.MainWindowOpened += (_, _) => ShowAvailable(releaseVersion); - } - - /// - public void ShowInstalledNotification(string installedVersion) - { - if (_mainWindowService.IsMainWindowOpen) - ShowInstalled(installedVersion); - else - _mainWindowService.MainWindowOpened += (_, _) => ShowInstalled(installedVersion); - } - - private void ShowAvailable(string releaseVersion) + private void ShowAvailable(Guid releaseId, string releaseVersion) { _available?.Invoke(); _available = _notificationService.CreateNotification() @@ -49,7 +30,7 @@ public class BasicUpdateNotificationProvider : IUpdateNotificationProvider .WithMessage($"Artemis {releaseVersion} has been released") .WithSeverity(NotificationSeverity.Success) .WithTimeout(TimeSpan.FromSeconds(15)) - .HavingButton(b => b.WithText("View release").WithAction(() => ViewRelease(releaseVersion))) + .HavingButton(b => b.WithText("View release").WithAction(() => ViewRelease(releaseId))) .Show(); } @@ -61,28 +42,36 @@ public class BasicUpdateNotificationProvider : IUpdateNotificationProvider .WithMessage($"Artemis {installedVersion} has been installed.") .WithSeverity(NotificationSeverity.Success) .WithTimeout(TimeSpan.FromSeconds(15)) - .HavingButton(b => b.WithText("View release").WithAction(() => ViewRelease(installedVersion))) + .HavingButton(b => b.WithText("View release").WithAction(async () => await ViewRelease(null))) .Show(); } - private void ViewRelease(string version) + private async Task ViewRelease(Guid? releaseId) { _installed?.Invoke(); _available?.Invoke(); - if (_mainWindowService.HostScreen == null) - return; + if (releaseId != null) + await _router.Navigate($"settings/releases/{releaseId}"); + else + await _router.Navigate("settings/releases"); + } - // TODO: When proper routing has been implemented, use that here - // Create a settings VM to navigate to - SettingsViewModel settingsViewModel = _getSettingsViewModel(_mainWindowService.HostScreen); - // Get the release tab - ReleasesTabViewModel releaseTabViewModel = (ReleasesTabViewModel) settingsViewModel.SettingTabs.First(t => t is ReleasesTabViewModel); + /// + public void ShowNotification(Guid releaseId, string releaseVersion) + { + if (_mainWindowService.IsMainWindowOpen) + ShowAvailable(releaseId, releaseVersion); + else + _mainWindowService.MainWindowOpened += (_, _) => ShowAvailable(releaseId, releaseVersion); + } - // Navigate to the settings VM - _mainWindowService.HostScreen.Router.Navigate.Execute(settingsViewModel); - // Navigate to the release tab - releaseTabViewModel.PreselectVersion = version; - settingsViewModel.SelectedTab = releaseTabViewModel; + /// + public void ShowInstalledNotification(string installedVersion) + { + if (_mainWindowService.IsMainWindowOpen) + ShowInstalled(installedVersion); + else + _mainWindowService.MainWindowOpened += (_, _) => ShowInstalled(installedVersion); } } \ No newline at end of file diff --git a/src/Artemis.WebClient.Updating/Queries/GetReleaseById.graphql b/src/Artemis.WebClient.Updating/Queries/GetReleaseById.graphql index 584445b52..1027f81f2 100644 --- a/src/Artemis.WebClient.Updating/Queries/GetReleaseById.graphql +++ b/src/Artemis.WebClient.Updating/Queries/GetReleaseById.graphql @@ -3,6 +3,7 @@ query GetReleaseById($id: UUID!) { branch commit version + createdAt previousRelease { version }