diff --git a/src/Artemis.Core/Models/Profile/LayerProperties/LayerProperty.cs b/src/Artemis.Core/Models/Profile/LayerProperties/LayerProperty.cs index c89abca04..4ebd5bb61 100644 --- a/src/Artemis.Core/Models/Profile/LayerProperties/LayerProperty.cs +++ b/src/Artemis.Core/Models/Profile/LayerProperties/LayerProperty.cs @@ -249,6 +249,7 @@ namespace Artemis.Core if (_keyframesEnabled == value) return; _keyframesEnabled = value; OnKeyframesToggled(); + OnPropertyChanged(nameof(KeyframesEnabled)); } } diff --git a/src/Artemis.UI/Screens/ProfileEditor/LayerProperties/Timeline/TimelinePropertyViewModel.cs b/src/Artemis.UI/Screens/ProfileEditor/LayerProperties/Timeline/TimelinePropertyViewModel.cs index 3294fec4b..d9ed531d4 100644 --- a/src/Artemis.UI/Screens/ProfileEditor/LayerProperties/Timeline/TimelinePropertyViewModel.cs +++ b/src/Artemis.UI/Screens/ProfileEditor/LayerProperties/Timeline/TimelinePropertyViewModel.cs @@ -115,8 +115,8 @@ namespace Artemis.UI.Screens.ProfileEditor.LayerProperties.Timeline public interface ITimelinePropertyViewModel : IScreen { - List GetAllKeyframeViewModels(); - void WipeKeyframes(TimeSpan? start, TimeSpan? end); - void ShiftKeyframes(TimeSpan? start, TimeSpan? end, TimeSpan amount); + List GetAllKeyframeViewModels(); + void WipeKeyframes(TimeSpan? start, TimeSpan? end); + void ShiftKeyframes(TimeSpan? start, TimeSpan? end, TimeSpan amount); } } \ No newline at end of file diff --git a/src/Avalonia/Artemis.UI.Shared/Services/ProfileEditor/IProfileEditorService.cs b/src/Avalonia/Artemis.UI.Shared/Services/ProfileEditor/IProfileEditorService.cs index f1f5e2260..b759fe8cc 100644 --- a/src/Avalonia/Artemis.UI.Shared/Services/ProfileEditor/IProfileEditorService.cs +++ b/src/Avalonia/Artemis.UI.Shared/Services/ProfileEditor/IProfileEditorService.cs @@ -11,6 +11,7 @@ namespace Artemis.UI.Shared.Services.ProfileEditor IObservable ProfileElement { get; } IObservable History { get; } IObservable Time { get; } + IObservable PixelsPerSecond { get; } void ChangeCurrentProfileConfiguration(ProfileConfiguration? profileConfiguration); void ChangeCurrentProfileElement(RenderProfileElement? renderProfileElement); diff --git a/src/Avalonia/Artemis.UI.Shared/Services/ProfileEditor/ProfileEditorService.cs b/src/Avalonia/Artemis.UI.Shared/Services/ProfileEditor/ProfileEditorService.cs index b78f1b9de..0441f068f 100644 --- a/src/Avalonia/Artemis.UI.Shared/Services/ProfileEditor/ProfileEditorService.cs +++ b/src/Avalonia/Artemis.UI.Shared/Services/ProfileEditor/ProfileEditorService.cs @@ -15,6 +15,7 @@ internal class ProfileEditorService : IProfileEditorService private readonly Dictionary _profileEditorHistories = new(); private readonly BehaviorSubject _profileElementSubject = new(null); private readonly BehaviorSubject _timeSubject = new(TimeSpan.Zero); + private readonly BehaviorSubject _pixelsPerSecondSubject = new(300); private readonly IProfileService _profileService; private readonly IWindowService _windowService; @@ -22,9 +23,12 @@ internal class ProfileEditorService : IProfileEditorService { _profileService = profileService; _windowService = windowService; + ProfileConfiguration = _profileConfigurationSubject.AsObservable(); ProfileElement = _profileElementSubject.AsObservable(); History = Observable.Defer(() => Observable.Return(GetHistory(_profileConfigurationSubject.Value))).Concat(ProfileConfiguration.Select(GetHistory)); + Time = _timeSubject.AsObservable(); + PixelsPerSecond = _pixelsPerSecondSubject.AsObservable(); } private ProfileEditorHistory? GetHistory(ProfileConfiguration? profileConfiguration) @@ -43,6 +47,7 @@ internal class ProfileEditorService : IProfileEditorService public IObservable ProfileElement { get; } public IObservable History { get; } public IObservable Time { get; } + public IObservable PixelsPerSecond { get; } public void ChangeCurrentProfileConfiguration(ProfileConfiguration? profileConfiguration) { @@ -59,6 +64,11 @@ internal class ProfileEditorService : IProfileEditorService _timeSubject.OnNext(time); } + public void ChangePixelsPerSecond(double pixelsPerSecond) + { + _pixelsPerSecondSubject.OnNext(pixelsPerSecond); + } + public void ExecuteCommand(IProfileEditorCommand command) { try diff --git a/src/Avalonia/Artemis.UI.Shared/Services/PropertyInput/IPropertyInputService.cs b/src/Avalonia/Artemis.UI.Shared/Services/PropertyInput/IPropertyInputService.cs new file mode 100644 index 000000000..74d6ae76a --- /dev/null +++ b/src/Avalonia/Artemis.UI.Shared/Services/PropertyInput/IPropertyInputService.cs @@ -0,0 +1,51 @@ +using System; +using System.Collections.ObjectModel; +using Artemis.Core; +using Artemis.UI.Shared.Services.Interfaces; + +namespace Artemis.UI.Shared.Services.PropertyInput; + +public interface IPropertyInputService : IArtemisSharedUIService +{ + /// + /// Gets a read-only collection of all registered property editors + /// + ReadOnlyCollection RegisteredPropertyEditors { get; } + + /// + /// Registers a new property input view model used in the profile editor for the generic type defined in + /// + /// Note: DataBindingProperty will remove itself on plugin disable so you don't have to + /// + /// + /// + PropertyInputRegistration RegisterPropertyInput(Plugin plugin) where T : PropertyInputViewModel; + + /// + /// Registers a new property input view model used in the profile editor for the generic type defined in + /// + /// Note: DataBindingProperty will remove itself on plugin disable so you don't have to + /// + /// + /// + /// + PropertyInputRegistration RegisterPropertyInput(Type viewModelType, Plugin plugin); + + /// + /// Removes the property input view model + /// + /// + void RemovePropertyInput(PropertyInputRegistration registration); + + /// + /// Determines if there is a matching registration for the provided layer property + /// + /// The layer property to try to find a view model for + bool CanCreatePropertyInputViewModel(ILayerProperty layerProperty); + + /// + /// If a matching registration is found, creates a new supporting + /// + /// + PropertyInputViewModel? CreatePropertyInputViewModel(LayerProperty layerProperty); +} \ No newline at end of file diff --git a/src/Avalonia/Artemis.UI.Shared/Services/PropertyInput/PropertyInputService.cs b/src/Avalonia/Artemis.UI.Shared/Services/PropertyInput/PropertyInputService.cs index 528d7e531..ec23d0850 100644 --- a/src/Avalonia/Artemis.UI.Shared/Services/PropertyInput/PropertyInputService.cs +++ b/src/Avalonia/Artemis.UI.Shared/Services/PropertyInput/PropertyInputService.cs @@ -2,99 +2,118 @@ using System.Collections.Generic; using System.Collections.ObjectModel; using System.Linq; -using System.Text; -using System.Threading.Tasks; using Artemis.Core; -using Artemis.UI.Shared.Services.Interfaces; +using Ninject; +using Ninject.Parameters; -namespace Artemis.UI.Shared.Services.PropertyInput +namespace Artemis.UI.Shared.Services.PropertyInput; + +internal class PropertyInputService : IPropertyInputService { - internal class PropertyInputService : IPropertyInputService + private readonly IKernel _kernel; + private readonly List _registeredPropertyEditors; + + public PropertyInputService(IKernel kernel) { - private readonly List _registeredPropertyEditors; + _kernel = kernel; + _registeredPropertyEditors = new List(); + RegisteredPropertyEditors = new ReadOnlyCollection(_registeredPropertyEditors); + } - public PropertyInputService() + /// + public ReadOnlyCollection RegisteredPropertyEditors { get; } + + public PropertyInputRegistration RegisterPropertyInput(Plugin plugin) where T : PropertyInputViewModel + { + return RegisterPropertyInput(typeof(T), plugin); + } + + public PropertyInputRegistration RegisterPropertyInput(Type viewModelType, Plugin plugin) + { + if (!typeof(PropertyInputViewModel).IsAssignableFrom(viewModelType)) + throw new ArtemisSharedUIException($"Property input VM type must implement {nameof(PropertyInputViewModel)}"); + + lock (_registeredPropertyEditors) { - _registeredPropertyEditors = new List(); - RegisteredPropertyEditors = new ReadOnlyCollection(_registeredPropertyEditors); - } + // Indirectly checked if there's a BaseType above + Type supportedType = viewModelType.BaseType!.GetGenericArguments()[0]; + // If the supported type is a generic, assume there is a base type + if (supportedType.IsGenericParameter) + { + if (supportedType.BaseType == null) + throw new ArtemisSharedUIException("Generic property input VM type must have a type constraint"); + supportedType = supportedType.BaseType; + } - /// - public ReadOnlyCollection RegisteredPropertyEditors { get; } + PropertyInputRegistration? existing = _registeredPropertyEditors.FirstOrDefault(r => r.SupportedType == supportedType); + if (existing != null) + { + if (existing.Plugin != plugin) + throw new ArtemisSharedUIException($"Cannot register property editor for type {supportedType.Name} because an editor was already " + + $"registered by {existing.Plugin}"); - /// - public PropertyInputRegistration RegisterPropertyInput(Plugin plugin) where T : PropertyInputViewModel - { - throw new NotImplementedException(); - } + return existing; + } - /// - public PropertyInputRegistration RegisterPropertyInput(Type viewModelType, Plugin plugin) - { - throw new NotImplementedException(); - } - - /// - public void RemovePropertyInput(PropertyInputRegistration registration) - { - throw new NotImplementedException(); - } - - /// - public bool CanCreatePropertyInputViewModel(ILayerProperty layerProperty) - { - throw new NotImplementedException(); - } - - /// - public PropertyInputViewModel? CreatePropertyInputViewModel(LayerProperty layerProperty) - { - throw new NotImplementedException(); + _kernel.Bind(viewModelType).ToSelf(); + PropertyInputRegistration registration = new(this, plugin, supportedType, viewModelType); + _registeredPropertyEditors.Add(registration); + return registration; } } - public interface IPropertyInputService : IArtemisSharedUIService + public void RemovePropertyInput(PropertyInputRegistration registration) { - /// - /// Gets a read-only collection of all registered property editors - /// - ReadOnlyCollection RegisteredPropertyEditors { get; } + lock (_registeredPropertyEditors) + { + if (_registeredPropertyEditors.Contains(registration)) + { + registration.Unsubscribe(); + _registeredPropertyEditors.Remove(registration); - /// - /// Registers a new property input view model used in the profile editor for the generic type defined in - /// - /// Note: DataBindingProperty will remove itself on plugin disable so you don't have to - /// - /// - /// - PropertyInputRegistration RegisterPropertyInput(Plugin plugin) where T : PropertyInputViewModel; + _kernel.Unbind(registration.ViewModelType); + } + } + } - /// - /// Registers a new property input view model used in the profile editor for the generic type defined in - /// - /// Note: DataBindingProperty will remove itself on plugin disable so you don't have to - /// - /// - /// - /// - PropertyInputRegistration RegisterPropertyInput(Type viewModelType, Plugin plugin); + public bool CanCreatePropertyInputViewModel(ILayerProperty layerProperty) + { + PropertyInputRegistration? registration = RegisteredPropertyEditors.FirstOrDefault(r => r.SupportedType == layerProperty.PropertyType); + if (registration == null && layerProperty.PropertyType.IsEnum) + registration = RegisteredPropertyEditors.FirstOrDefault(r => r.SupportedType == typeof(Enum)); - /// - /// Removes the property input view model - /// - /// - void RemovePropertyInput(PropertyInputRegistration registration); + return registration != null; + } - /// - /// Determines if there is a matching registration for the provided layer property - /// - /// The layer property to try to find a view model for - bool CanCreatePropertyInputViewModel(ILayerProperty layerProperty); + public PropertyInputViewModel? CreatePropertyInputViewModel(LayerProperty layerProperty) + { + Type? viewModelType = null; + PropertyInputRegistration? registration = RegisteredPropertyEditors.FirstOrDefault(r => r.SupportedType == typeof(T)); - /// - /// If a matching registration is found, creates a new supporting - /// - /// - PropertyInputViewModel? CreatePropertyInputViewModel(LayerProperty layerProperty); + // Check for enums if no supported type was found + if (registration == null && typeof(T).IsEnum) + { + // The enum VM will likely be a generic, that requires creating a generic type matching the layer property + registration = RegisteredPropertyEditors.FirstOrDefault(r => r.SupportedType == typeof(Enum)); + if (registration != null && registration.ViewModelType.IsGenericType) + viewModelType = registration.ViewModelType.MakeGenericType(layerProperty.GetType().GenericTypeArguments); + } + else if (registration != null) + { + viewModelType = registration.ViewModelType; + } + else + { + return null; + } + + if (viewModelType == null) + return null; + + ConstructorArgument parameter = new("layerProperty", layerProperty); + // ReSharper disable once InconsistentlySynchronizedField + // When you've just spent the last 2 hours trying to figure out a deadlock and reach this line, I'm so, so sorry. I thought this would be fine. + IKernel kernel = registration?.Plugin.Kernel ?? _kernel; + return (PropertyInputViewModel) kernel.Get(viewModelType, parameter); } } \ No newline at end of file diff --git a/src/Avalonia/Artemis.UI/Ninject/Factories/IVMFactory.cs b/src/Avalonia/Artemis.UI/Ninject/Factories/IVMFactory.cs index 0e699c8f6..ffbda5e3a 100644 --- a/src/Avalonia/Artemis.UI/Ninject/Factories/IVMFactory.cs +++ b/src/Avalonia/Artemis.UI/Ninject/Factories/IVMFactory.cs @@ -4,6 +4,7 @@ using Artemis.UI.Screens.Device; using Artemis.UI.Screens.Plugins; using Artemis.UI.Screens.ProfileEditor; using Artemis.UI.Screens.ProfileEditor.ProfileElementProperties; +using Artemis.UI.Screens.ProfileEditor.ProfileElementProperties.Timeline; using Artemis.UI.Screens.ProfileEditor.ProfileElementProperties.Tree; using Artemis.UI.Screens.ProfileEditor.ProfileTree; using Artemis.UI.Screens.Settings; @@ -75,4 +76,10 @@ namespace Artemis.UI.Ninject.Factories // TimelineViewModel TimelineViewModel(ProfileElementPropertiesViewModel profileElementPropertiesViewModel, IObservableCollection profileElementPropertyGroups); // TimelineSegmentViewModel TimelineSegmentViewModel(SegmentViewModelType segment, IObservableCollection profileElementPropertyGroups); } + + public interface IPropertyVmFactory + { + ITreePropertyViewModel TreePropertyViewModel(ILayerProperty layerProperty, ProfileElementPropertyViewModel profileElementPropertyViewModel); + ITimelinePropertyViewModel TimelinePropertyViewModel(ILayerProperty layerProperty, ProfileElementPropertyViewModel profileElementPropertyViewModel); + } } \ No newline at end of file diff --git a/src/Avalonia/Artemis.UI/Ninject/InstanceProviders/LayerPropertyViewModelInstanceProvider.cs b/src/Avalonia/Artemis.UI/Ninject/InstanceProviders/LayerPropertyViewModelInstanceProvider.cs new file mode 100644 index 000000000..ab31a993e --- /dev/null +++ b/src/Avalonia/Artemis.UI/Ninject/InstanceProviders/LayerPropertyViewModelInstanceProvider.cs @@ -0,0 +1,32 @@ +using System; +using System.Reflection; +using Artemis.Core; +using Artemis.UI.Screens.ProfileEditor.ProfileElementProperties.Timeline; +using Artemis.UI.Screens.ProfileEditor.ProfileElementProperties.Tree; +using Ninject.Extensions.Factory; + +namespace Artemis.UI.Ninject.InstanceProviders +{ + public class LayerPropertyViewModelInstanceProvider : StandardInstanceProvider + { + protected override Type GetType(MethodInfo methodInfo, object[] arguments) + { + if (methodInfo.ReturnType != typeof(ITreePropertyViewModel) && methodInfo.ReturnType != typeof(ITimelinePropertyViewModel)) + return base.GetType(methodInfo, arguments); + + // Find LayerProperty type + Type? layerPropertyType = arguments[0].GetType(); + while (layerPropertyType != null && (!layerPropertyType.IsGenericType || layerPropertyType.GetGenericTypeDefinition() != typeof(LayerProperty<>))) + layerPropertyType = layerPropertyType.BaseType; + if (layerPropertyType == null) + return base.GetType(methodInfo, arguments); + + if (methodInfo.ReturnType == typeof(ITreePropertyViewModel)) + return typeof(TreePropertyViewModel<>).MakeGenericType(layerPropertyType.GetGenericArguments()); + if (methodInfo.ReturnType == typeof(ITimelinePropertyViewModel)) + return typeof(TimelinePropertyViewModel<>).MakeGenericType(layerPropertyType.GetGenericArguments()); + + return base.GetType(methodInfo, arguments); + } + } +} \ No newline at end of file diff --git a/src/Avalonia/Artemis.UI/Ninject/UIModule.cs b/src/Avalonia/Artemis.UI/Ninject/UIModule.cs index 4461c6a66..af1cfa130 100644 --- a/src/Avalonia/Artemis.UI/Ninject/UIModule.cs +++ b/src/Avalonia/Artemis.UI/Ninject/UIModule.cs @@ -1,11 +1,13 @@ using System; using Artemis.UI.Ninject.Factories; +using Artemis.UI.Ninject.InstanceProviders; using Artemis.UI.Screens; using Artemis.UI.Services.Interfaces; using Artemis.UI.Shared; using Avalonia.Platform; using Avalonia.Shared.PlatformSupport; using Ninject.Extensions.Conventions; +using Ninject.Extensions.Factory; using Ninject.Modules; using Ninject.Planning.Bindings.Resolvers; @@ -46,6 +48,8 @@ namespace Artemis.UI.Ninject .BindToFactory(); }); + Kernel.Bind().ToFactory(() => new LayerPropertyViewModelInstanceProvider()); + // Bind all UI services as singletons Kernel.Bind(x => { diff --git a/src/Avalonia/Artemis.UI/Screens/ProfileEditor/Panels/ProfileElementProperties/ProfileElementPropertyGroupViewModel.cs b/src/Avalonia/Artemis.UI/Screens/ProfileEditor/Panels/ProfileElementProperties/ProfileElementPropertyGroupViewModel.cs index bd7e5ea2c..4340c49e3 100644 --- a/src/Avalonia/Artemis.UI/Screens/ProfileEditor/Panels/ProfileElementProperties/ProfileElementPropertyGroupViewModel.cs +++ b/src/Avalonia/Artemis.UI/Screens/ProfileEditor/Panels/ProfileElementProperties/ProfileElementPropertyGroupViewModel.cs @@ -1,28 +1,63 @@ -using System.Collections.ObjectModel; +using System; +using System.Collections.ObjectModel; +using System.Linq; +using System.Reflection; using Artemis.Core; using Artemis.UI.Ninject.Factories; using Artemis.UI.Screens.ProfileEditor.ProfileElementProperties.Tree; using Artemis.UI.Shared; +using Artemis.UI.Shared.Services.PropertyInput; using ReactiveUI; namespace Artemis.UI.Screens.ProfileEditor.ProfileElementProperties; public class ProfileElementPropertyGroupViewModel : ViewModelBase { + private readonly ILayerPropertyVmFactory _layerPropertyVmFactory; + private readonly IPropertyInputService _propertyInputService; private bool _isVisible; private bool _isExpanded; + private bool _hasChildren; - public ProfileElementPropertyGroupViewModel(LayerPropertyGroup layerPropertyGroup, ILayerPropertyVmFactory layerPropertyVmFactory) + public ProfileElementPropertyGroupViewModel(LayerPropertyGroup layerPropertyGroup, ILayerPropertyVmFactory layerPropertyVmFactory, IPropertyInputService propertyInputService) { - Children = new ObservableCollection(); + _layerPropertyVmFactory = layerPropertyVmFactory; + _propertyInputService = propertyInputService; + Children = new ObservableCollection(); LayerPropertyGroup = layerPropertyGroup; TreeGroupViewModel = layerPropertyVmFactory.TreeGroupViewModel(this); - IsVisible = !LayerPropertyGroup.IsHidden; - // TODO: Update visiblity on change, can't do it atm because not sure how to unsubscribe from the event + PopulateChildren(); } - public ObservableCollection Children { get; } + private void PopulateChildren() + { + // Get all properties and property groups and create VMs for them + // The group has methods for getting this without reflection but then we lose the order of the properties as they are defined on the group + foreach (PropertyInfo propertyInfo in LayerPropertyGroup.GetType().GetProperties()) + { + if (Attribute.IsDefined(propertyInfo, typeof(LayerPropertyIgnoreAttribute))) + continue; + + if (typeof(ILayerProperty).IsAssignableFrom(propertyInfo.PropertyType)) + { + ILayerProperty? value = (ILayerProperty?) propertyInfo.GetValue(LayerPropertyGroup); + // Ensure a supported input VM was found, otherwise don't add it + if (value != null && _propertyInputService.CanCreatePropertyInputViewModel(value)) + Children.Add(_layerPropertyVmFactory.ProfileElementPropertyViewModel(value)); + } + else if (typeof(LayerPropertyGroup).IsAssignableFrom(propertyInfo.PropertyType)) + { + LayerPropertyGroup? value = (LayerPropertyGroup?) propertyInfo.GetValue(LayerPropertyGroup); + if (value != null) + Children.Add(_layerPropertyVmFactory.ProfileElementPropertyGroupViewModel(value)); + } + } + + HasChildren = Children.Any(i => i is ProfileElementPropertyViewModel {IsVisible: true} || i is ProfileElementPropertyGroupViewModel {IsVisible: true}); + } + + public ObservableCollection Children { get; } public LayerPropertyGroup LayerPropertyGroup { get; } public TreeGroupViewModel TreeGroupViewModel { get; } @@ -37,4 +72,10 @@ public class ProfileElementPropertyGroupViewModel : ViewModelBase get => _isExpanded; set => this.RaiseAndSetIfChanged(ref _isExpanded, value); } + + public bool HasChildren + { + get => _hasChildren; + set => this.RaiseAndSetIfChanged(ref _hasChildren, value); + } } \ No newline at end of file diff --git a/src/Avalonia/Artemis.UI/Screens/ProfileEditor/Panels/ProfileElementProperties/ProfileElementPropertyViewModel.cs b/src/Avalonia/Artemis.UI/Screens/ProfileEditor/Panels/ProfileElementProperties/ProfileElementPropertyViewModel.cs index da3519ae9..5fa58821a 100644 --- a/src/Avalonia/Artemis.UI/Screens/ProfileEditor/Panels/ProfileElementProperties/ProfileElementPropertyViewModel.cs +++ b/src/Avalonia/Artemis.UI/Screens/ProfileEditor/Panels/ProfileElementProperties/ProfileElementPropertyViewModel.cs @@ -1,20 +1,44 @@ using Artemis.Core; using Artemis.UI.Ninject.Factories; -using Artemis.UI.Shared.Services.ProfileEditor; +using Artemis.UI.Screens.ProfileEditor.ProfileElementProperties.Timeline; +using Artemis.UI.Screens.ProfileEditor.ProfileElementProperties.Tree; +using Artemis.UI.Shared; +using ReactiveUI; namespace Artemis.UI.Screens.ProfileEditor.ProfileElementProperties; -public class ProfileElementPropertyViewModel +public class ProfileElementPropertyViewModel : ViewModelBase { - private readonly ILayerPropertyVmFactory _layerPropertyVmFactory; - private readonly IProfileEditorService _profileEditorService; + private bool _isExpanded; + private bool _isHighlighted; + private bool _isVisible; - public ProfileElementPropertyViewModel(ILayerProperty layerProperty, IProfileEditorService profileEditorService, ILayerPropertyVmFactory layerPropertyVmFactory) + public ProfileElementPropertyViewModel(ILayerProperty layerProperty, IPropertyVmFactory propertyVmFactory) { LayerProperty = layerProperty; - _profileEditorService = profileEditorService; - _layerPropertyVmFactory = layerPropertyVmFactory; + TreePropertyViewModel = propertyVmFactory.TreePropertyViewModel(LayerProperty, this); + TimelinePropertyViewModel = propertyVmFactory.TimelinePropertyViewModel(LayerProperty, this); } public ILayerProperty LayerProperty { get; } + public ITreePropertyViewModel TreePropertyViewModel { get; } + public ITimelinePropertyViewModel TimelinePropertyViewModel { get; } + + public bool IsVisible + { + get => _isVisible; + set => this.RaiseAndSetIfChanged(ref _isVisible, value); + } + + public bool IsHighlighted + { + get => _isHighlighted; + set => this.RaiseAndSetIfChanged(ref _isHighlighted, value); + } + + public bool IsExpanded + { + get => _isExpanded; + set => this.RaiseAndSetIfChanged(ref _isExpanded, value); + } } \ No newline at end of file diff --git a/src/Avalonia/Artemis.UI/Screens/ProfileEditor/Panels/ProfileElementProperties/Timeline/ITimelineKeyframeViewModel.cs b/src/Avalonia/Artemis.UI/Screens/ProfileEditor/Panels/ProfileElementProperties/Timeline/ITimelineKeyframeViewModel.cs new file mode 100644 index 000000000..8cc49f77b --- /dev/null +++ b/src/Avalonia/Artemis.UI/Screens/ProfileEditor/Panels/ProfileElementProperties/Timeline/ITimelineKeyframeViewModel.cs @@ -0,0 +1,28 @@ +using System; +using Artemis.Core; + +namespace Artemis.UI.Screens.ProfileEditor.ProfileElementProperties.Timeline; + +public interface ITimelineKeyframeViewModel +{ + bool IsSelected { get; set; } + TimeSpan Position { get; } + ILayerPropertyKeyframe Keyframe { get; } + + #region Movement + + void SaveOffsetToKeyframe(ITimelineKeyframeViewModel source); + void ApplyOffsetToKeyframe(ITimelineKeyframeViewModel source); + void UpdatePosition(TimeSpan position); + void ReleaseMovement(); + + #endregion + + #region Context menu actions + + void PopulateEasingViewModels(); + void ClearEasingViewModels(); + void Delete(bool save = true); + + #endregion +} \ No newline at end of file diff --git a/src/Avalonia/Artemis.UI/Screens/ProfileEditor/Panels/ProfileElementProperties/Timeline/ITimelinePropertyViewModel.cs b/src/Avalonia/Artemis.UI/Screens/ProfileEditor/Panels/ProfileElementProperties/Timeline/ITimelinePropertyViewModel.cs new file mode 100644 index 000000000..21cc94893 --- /dev/null +++ b/src/Avalonia/Artemis.UI/Screens/ProfileEditor/Panels/ProfileElementProperties/Timeline/ITimelinePropertyViewModel.cs @@ -0,0 +1,12 @@ +using System; +using System.Collections.Generic; +using ReactiveUI; + +namespace Artemis.UI.Screens.ProfileEditor.ProfileElementProperties.Timeline; + +public interface ITimelinePropertyViewModel : IReactiveObject +{ + List GetAllKeyframeViewModels(); + void WipeKeyframes(TimeSpan? start, TimeSpan? end); + void ShiftKeyframes(TimeSpan? start, TimeSpan? end, TimeSpan amount); +} \ No newline at end of file diff --git a/src/Avalonia/Artemis.UI/Screens/ProfileEditor/Panels/ProfileElementProperties/Timeline/TimelineEasingViewModel.cs b/src/Avalonia/Artemis.UI/Screens/ProfileEditor/Panels/ProfileElementProperties/Timeline/TimelineEasingViewModel.cs new file mode 100644 index 000000000..23be99e6a --- /dev/null +++ b/src/Avalonia/Artemis.UI/Screens/ProfileEditor/Panels/ProfileElementProperties/Timeline/TimelineEasingViewModel.cs @@ -0,0 +1,50 @@ +using System; +using System.Collections.Generic; +using Artemis.Core; +using Artemis.UI.Shared; +using Avalonia; +using Humanizer; + +namespace Artemis.UI.Screens.ProfileEditor.ProfileElementProperties.Timeline; + +public class TimelineEasingViewModel : ViewModelBase +{ + private bool _isEasingModeSelected; + + public TimelineEasingViewModel(Easings.Functions easingFunction, bool isSelected) + { + _isEasingModeSelected = isSelected; + + EasingFunction = easingFunction; + Description = easingFunction.Humanize(); + + EasingPoints = new List(); + for (int i = 1; i <= 10; i++) + { + int x = i; + double y = Easings.Interpolate(i / 10.0, EasingFunction) * 10; + EasingPoints.Add(new Point(x, y)); + } + } + + public Easings.Functions EasingFunction { get; } + public List EasingPoints { get; } + public string Description { get; } + + public bool IsEasingModeSelected + { + get => _isEasingModeSelected; + set + { + _isEasingModeSelected = value; + if (value) OnEasingModeSelected(); + } + } + + public event EventHandler EasingModeSelected; + + protected virtual void OnEasingModeSelected() + { + EasingModeSelected?.Invoke(this, EventArgs.Empty); + } +} \ No newline at end of file diff --git a/src/Avalonia/Artemis.UI/Screens/ProfileEditor/Panels/ProfileElementProperties/Timeline/TimelineKeyframeViewModel.cs b/src/Avalonia/Artemis.UI/Screens/ProfileEditor/Panels/ProfileElementProperties/Timeline/TimelineKeyframeViewModel.cs new file mode 100644 index 000000000..1bc1eaa7c --- /dev/null +++ b/src/Avalonia/Artemis.UI/Screens/ProfileEditor/Panels/ProfileElementProperties/Timeline/TimelineKeyframeViewModel.cs @@ -0,0 +1,169 @@ +using System; +using System.Collections.ObjectModel; +using System.Linq; +using Artemis.Core; +using Artemis.UI.Shared; +using Artemis.UI.Shared.Services.ProfileEditor; +using Avalonia.Controls.Mixins; +using DynamicData; +using ReactiveUI; +using Disposable = System.Reactive.Disposables.Disposable; + +namespace Artemis.UI.Screens.ProfileEditor.ProfileElementProperties.Timeline +{ + public class TimelineKeyframeViewModel : ActivatableViewModelBase, ITimelineKeyframeViewModel + { + + private bool _isSelected; + private string _timestamp; + private double _x; + private readonly IProfileEditorService _profileEditorService; + + public TimelineKeyframeViewModel(LayerPropertyKeyframe layerPropertyKeyframe, IProfileEditorService profileEditorService) + { + _profileEditorService = profileEditorService; + LayerPropertyKeyframe = layerPropertyKeyframe; + EasingViewModels = new ObservableCollection(); + + this.WhenActivated(d => + { + _profileEditorService.PixelsPerSecond.Subscribe(p => + { + _pixelsPerSecond = p; + _profileEditorService.PixelsPerSecond.Subscribe(_ => Update()).DisposeWith(d); + Disposable.Create(() => + { + foreach (TimelineEasingViewModel timelineEasingViewModel in EasingViewModels) + timelineEasingViewModel.EasingModeSelected -= TimelineEasingViewModelOnEasingModeSelected; + }).DisposeWith(d); + }).DisposeWith(d); + }); + } + + public LayerPropertyKeyframe LayerPropertyKeyframe { get; } + public ObservableCollection EasingViewModels { get; } + + public double X + { + get => _x; + set => this.RaiseAndSetIfChanged(ref _x, value); + } + + public string Timestamp + { + get => _timestamp; + set => this.RaiseAndSetIfChanged(ref _timestamp, value); + } + + public bool IsSelected + { + get => _isSelected; + set => this.RaiseAndSetIfChanged(ref _isSelected, value); + } + + public TimeSpan Position => LayerPropertyKeyframe.Position; + public ILayerPropertyKeyframe Keyframe => LayerPropertyKeyframe; + + public void Update() + { + X = _pixelsPerSecond * LayerPropertyKeyframe.Position.TotalSeconds; + Timestamp = $"{Math.Floor(LayerPropertyKeyframe.Position.TotalSeconds):00}.{LayerPropertyKeyframe.Position.Milliseconds:000}"; + } + + + #region Movement + + private TimeSpan? _offset; + private double _pixelsPerSecond; + + public void ReleaseMovement() + { + _offset = null; + } + + public void SaveOffsetToKeyframe(ITimelineKeyframeViewModel source) + { + if (source == this) + { + _offset = null; + return; + } + + if (_offset != null) + return; + + _offset = LayerPropertyKeyframe.Position - source.Position; + } + + public void ApplyOffsetToKeyframe(ITimelineKeyframeViewModel source) + { + if (source == this || _offset == null) + return; + + UpdatePosition(source.Position + _offset.Value); + } + + public void UpdatePosition(TimeSpan position) + { + throw new NotImplementedException(); + + // if (position < TimeSpan.Zero) + // LayerPropertyKeyframe.Position = TimeSpan.Zero; + // else if (position > _profileEditorService.SelectedProfileElement.Timeline.Length) + // LayerPropertyKeyframe.Position = _profileEditorService.SelectedProfileElement.Timeline.Length; + // else + // LayerPropertyKeyframe.Position = position; + + Update(); + } + + #endregion + + #region Easing + + public void PopulateEasingViewModels() + { + if (EasingViewModels.Any()) + return; + + EasingViewModels.AddRange(Enum.GetValues(typeof(Easings.Functions)) + .Cast() + .Select(e => new TimelineEasingViewModel(e, e == LayerPropertyKeyframe.EasingFunction))); + + foreach (TimelineEasingViewModel timelineEasingViewModel in EasingViewModels) + timelineEasingViewModel.EasingModeSelected += TimelineEasingViewModelOnEasingModeSelected; + } + + public void ClearEasingViewModels() + { + EasingViewModels.Clear(); + } + + private void TimelineEasingViewModelOnEasingModeSelected(object? sender, EventArgs e) + { + if (sender is TimelineEasingViewModel timelineEasingViewModel) + SelectEasingMode(timelineEasingViewModel); + } + + public void SelectEasingMode(TimelineEasingViewModel easingViewModel) + { + throw new NotImplementedException(); + + LayerPropertyKeyframe.EasingFunction = easingViewModel.EasingFunction; + // Set every selection to false except on the VM that made the change + foreach (TimelineEasingViewModel propertyTrackEasingViewModel in EasingViewModels.Where(vm => vm != easingViewModel)) + propertyTrackEasingViewModel.IsEasingModeSelected = false; + } + + #endregion + + #region Context menu actions + + public void Delete(bool save = true) + { + throw new NotImplementedException(); + } + + #endregion + } +} diff --git a/src/Avalonia/Artemis.UI/Screens/ProfileEditor/Panels/ProfileElementProperties/Timeline/TimelinePropertyViewModel.cs b/src/Avalonia/Artemis.UI/Screens/ProfileEditor/Panels/ProfileElementProperties/Timeline/TimelinePropertyViewModel.cs new file mode 100644 index 000000000..63bb39e76 --- /dev/null +++ b/src/Avalonia/Artemis.UI/Screens/ProfileEditor/Panels/ProfileElementProperties/Timeline/TimelinePropertyViewModel.cs @@ -0,0 +1,81 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using Artemis.Core; +using Artemis.UI.Shared; +using Artemis.UI.Shared.Services.ProfileEditor; + +namespace Artemis.UI.Screens.ProfileEditor.ProfileElementProperties.Timeline +{ + public class TimelinePropertyViewModel : ActivatableViewModelBase, ITimelinePropertyViewModel + { + private readonly IProfileEditorService _profileEditorService; + public LayerProperty LayerProperty { get; } + public ProfileElementPropertyViewModel ProfileElementPropertyViewModel { get; } + public ObservableCollection> KeyframeViewModels { get; } + + public TimelinePropertyViewModel(LayerProperty layerProperty, ProfileElementPropertyViewModel profileElementPropertyViewModel, IProfileEditorService profileEditorService) + { + _profileEditorService = profileEditorService; + LayerProperty = layerProperty; + ProfileElementPropertyViewModel = profileElementPropertyViewModel; + KeyframeViewModels = new ObservableCollection>(); + } + + #region Implementation of ITimelinePropertyViewModel + + public List GetAllKeyframeViewModels() + { + return KeyframeViewModels.Cast().ToList(); + } + + public void WipeKeyframes(TimeSpan? start, TimeSpan? end) + { + start ??= TimeSpan.Zero; + end ??= TimeSpan.MaxValue; + + + List> toShift = LayerProperty.Keyframes.Where(k => k.Position >= start && k.Position < end).ToList(); + foreach (LayerPropertyKeyframe keyframe in toShift) + LayerProperty.RemoveKeyframe(keyframe); + + UpdateKeyframes(); + } + + public void ShiftKeyframes(TimeSpan? start, TimeSpan? end, TimeSpan amount) + { + start ??= TimeSpan.Zero; + end ??= TimeSpan.MaxValue; + + List> toShift = LayerProperty.Keyframes.Where(k => k.Position > start && k.Position < end).ToList(); + foreach (LayerPropertyKeyframe keyframe in toShift) + keyframe.Position += amount; + + UpdateKeyframes(); + } + + #endregion + + private void UpdateKeyframes() + { + // Only show keyframes if they are enabled + if (LayerProperty.KeyframesEnabled) + { + List> keyframes = LayerProperty.Keyframes.ToList(); + + List> toRemove = KeyframeViewModels.Where(t => !keyframes.Contains(t.LayerPropertyKeyframe)).ToList(); + foreach (TimelineKeyframeViewModel timelineKeyframeViewModel in toRemove) + KeyframeViewModels.Remove(timelineKeyframeViewModel); + List> toAdd = keyframes.Where(k => KeyframeViewModels.All(t => t.LayerPropertyKeyframe != k)).Select(k => new TimelineKeyframeViewModel(k, _profileEditorService)).ToList(); + foreach (TimelineKeyframeViewModel timelineKeyframeViewModel in toAdd) + KeyframeViewModels.Add(timelineKeyframeViewModel); + } + else + KeyframeViewModels.Clear(); + + foreach (TimelineKeyframeViewModel timelineKeyframeViewModel in KeyframeViewModels) + timelineKeyframeViewModel.Update(); + } + } +} \ No newline at end of file diff --git a/src/Avalonia/Artemis.UI/Screens/ProfileEditor/Panels/ProfileElementProperties/Tree/ITreePropertyViewModel.cs b/src/Avalonia/Artemis.UI/Screens/ProfileEditor/Panels/ProfileElementProperties/Tree/ITreePropertyViewModel.cs new file mode 100644 index 000000000..16fcc9eea --- /dev/null +++ b/src/Avalonia/Artemis.UI/Screens/ProfileEditor/Panels/ProfileElementProperties/Tree/ITreePropertyViewModel.cs @@ -0,0 +1,9 @@ +using ReactiveUI; + +namespace Artemis.UI.Screens.ProfileEditor.ProfileElementProperties.Tree; + +public interface ITreePropertyViewModel : IReactiveObject +{ + bool HasDataBinding { get; } + double GetDepth(); +} \ No newline at end of file diff --git a/src/Avalonia/Artemis.UI/Screens/ProfileEditor/Panels/ProfileElementProperties/Tree/TreeGroupViewModel.cs b/src/Avalonia/Artemis.UI/Screens/ProfileEditor/Panels/ProfileElementProperties/Tree/TreeGroupViewModel.cs index dbdd25b59..08286059b 100644 --- a/src/Avalonia/Artemis.UI/Screens/ProfileEditor/Panels/ProfileElementProperties/Tree/TreeGroupViewModel.cs +++ b/src/Avalonia/Artemis.UI/Screens/ProfileEditor/Panels/ProfileElementProperties/Tree/TreeGroupViewModel.cs @@ -1,9 +1,7 @@ using System; using System.Collections.ObjectModel; -using System.ComponentModel; using System.Linq; using System.Reactive.Disposables; -using System.Reactive.Linq; using System.Reflection; using System.Threading.Tasks; using Artemis.Core; @@ -24,8 +22,8 @@ namespace Artemis.UI.Screens.ProfileEditor.ProfileElementProperties.Tree; public class TreeGroupViewModel : ActivatableViewModelBase { - private readonly IWindowService _windowService; private readonly IProfileEditorService _profileEditorService; + private readonly IWindowService _windowService; private BrushConfigurationWindowViewModel? _brushConfigurationWindowViewModel; private EffectConfigurationWindowViewModel? _effectConfigurationWindowViewModel; @@ -41,12 +39,14 @@ public class TreeGroupViewModel : ActivatableViewModelBase ProfileElementPropertyGroupViewModel.WhenAnyValue(vm => vm.IsExpanded).Subscribe(_ => this.RaisePropertyChanged(nameof(Children))).DisposeWith(d); Disposable.Create(CloseViewModels).DisposeWith(d); }); + + // TODO: Update ProfileElementPropertyGroupViewModel visibility on change (can remove the sub on line 41 as well then) } public ProfileElementPropertyGroupViewModel ProfileElementPropertyGroupViewModel { get; } public LayerPropertyGroup LayerPropertyGroup => ProfileElementPropertyGroupViewModel.LayerPropertyGroup; - public ObservableCollection? Children => ProfileElementPropertyGroupViewModel.IsExpanded ? ProfileElementPropertyGroupViewModel.Children : null; + public ObservableCollection? Children => ProfileElementPropertyGroupViewModel.IsExpanded ? ProfileElementPropertyGroupViewModel.Children : null; public LayerPropertyGroupType GroupType { get; private set; } @@ -96,7 +96,7 @@ public class TreeGroupViewModel : ActivatableViewModelBase // Find the BaseLayerEffect parameter, it is required by the base constructor so its there for sure ParameterInfo effectParameter = constructors.First().GetParameters().First(p => typeof(BaseLayerEffect).IsAssignableFrom(p.ParameterType)); ConstructorArgument argument = new(effectParameter.Name!, layerEffect); - EffectConfigurationViewModel viewModel = (EffectConfigurationViewModel)layerEffect.Descriptor.Provider.Plugin.Kernel!.Get(configurationViewModel.Type, argument); + EffectConfigurationViewModel viewModel = (EffectConfigurationViewModel) layerEffect.Descriptor.Provider.Plugin.Kernel!.Get(configurationViewModel.Type, argument); _effectConfigurationWindowViewModel = new EffectConfigurationWindowViewModel(viewModel, configurationViewModel); await _windowService.ShowDialogAsync(_effectConfigurationWindowViewModel); @@ -138,7 +138,7 @@ public class TreeGroupViewModel : ActivatableViewModelBase _effectConfigurationWindowViewModel?.Close(null); _brushConfigurationWindowViewModel?.Close(null); } - + private void DetermineGroupType() { if (LayerPropertyGroup is LayerGeneralProperties) diff --git a/src/Avalonia/Artemis.UI/Screens/ProfileEditor/Panels/ProfileElementProperties/Tree/TreePropertyViewModel.cs b/src/Avalonia/Artemis.UI/Screens/ProfileEditor/Panels/ProfileElementProperties/Tree/TreePropertyViewModel.cs index 0ca6424eb..63a0c1e8e 100644 --- a/src/Avalonia/Artemis.UI/Screens/ProfileEditor/Panels/ProfileElementProperties/Tree/TreePropertyViewModel.cs +++ b/src/Avalonia/Artemis.UI/Screens/ProfileEditor/Panels/ProfileElementProperties/Tree/TreePropertyViewModel.cs @@ -4,16 +4,32 @@ using Artemis.UI.Shared.Services.PropertyInput; namespace Artemis.UI.Screens.ProfileEditor.ProfileElementProperties.Tree; -internal class TreePropertyViewModel : ActivatableViewModelBase +internal class TreePropertyViewModel : ActivatableViewModelBase, ITreePropertyViewModel { - public TreePropertyViewModel(LayerProperty layerProperty, ProfileElementPropertyViewModel layerPropertyViewModel, IPropertyInputService propertyInputService) + public TreePropertyViewModel(LayerProperty layerProperty, ProfileElementPropertyViewModel profileElementPropertyViewModel, IPropertyInputService propertyInputService) { LayerProperty = layerProperty; - LayerPropertyViewModel = layerPropertyViewModel; + ProfileElementPropertyViewModel = profileElementPropertyViewModel; PropertyInputViewModel = propertyInputService.CreatePropertyInputViewModel(LayerProperty); + + // TODO: Update ProfileElementPropertyViewModel visibility on change } public LayerProperty LayerProperty { get; } - public ProfileElementPropertyViewModel LayerPropertyViewModel { get; } + public ProfileElementPropertyViewModel ProfileElementPropertyViewModel { get; } public PropertyInputViewModel? PropertyInputViewModel { get; } + public bool HasDataBinding => LayerProperty.HasDataBinding; + + public double GetDepth() + { + int depth = 0; + LayerPropertyGroup? current = LayerProperty.LayerPropertyGroup; + while (current != null) + { + depth++; + current = current.Parent; + } + + return depth; + } } \ No newline at end of file