From dd40bdd54431c51964fa5b13451fb5a42691feef Mon Sep 17 00:00:00 2001 From: Robert Date: Sun, 5 Jun 2022 18:57:42 +0200 Subject: [PATCH] Profiles - Added module activation requirements Editor - Refactored tools and selected keyframes management Timeline - Added keyframe duplicating, copying and pasting Windows UI - Added logging of fatal exceptions --- src/.idea/.idea.Artemis/.idea/avalonia.xml | 3 + .../Profile/LayerProperties/ILayerProperty.cs | 7 +- .../LayerProperties/ILayerPropertyKeyframe.cs | 7 + .../Profile/LayerProperties/LayerProperty.cs | 29 ++-- .../LayerProperties/LayerPropertyKeyFrame.cs | 6 + .../ProfileConfiguration.cs | 4 +- .../Extensions/ClipboardExtensions.cs | 29 ++++ .../Commands/DuplicateKeyframe.cs | 55 +++++++ .../ProfileEditor/IProfileEditorService.cs | 23 ++- .../Services/ProfileEditor/IToolViewModel.cs | 3 +- .../ProfileEditor/ProfileEditorService.cs | 142 ++++++++++-------- src/Artemis.UI.Windows/App.axaml.cs | 3 +- src/Artemis.UI.Windows/Program.cs | 19 ++- .../Models/KeyframeClipboardModel.cs | 25 +++ .../Keyframes/ITimelineKeyframeViewModel.cs | 11 +- .../Keyframes/TimelineKeyframeView.axaml | 8 +- .../Keyframes/TimelineKeyframeViewModel.cs | 137 +++++++++++++++-- .../Properties/Timeline/TimelineView.axaml | 5 +- .../Properties/Timeline/TimelineViewModel.cs | 86 +++++------ .../VisualEditor/VisualEditorViewModel.cs | 6 +- .../ProfileEditor/ProfileEditorViewModel.cs | 3 +- .../ModuleActivationRequirementView.axaml | 42 ++++++ .../ModuleActivationRequirementView.axaml.cs | 17 +++ .../ModuleActivationRequirementViewModel.cs | 56 +++++++ .../ModuleActivationRequirementsView.axaml | 28 ++++ .../ModuleActivationRequirementsView.axaml.cs | 17 +++ .../ModuleActivationRequirementsViewModel.cs | 20 +++ .../ProfileConfigurationEditView.axaml | 11 +- .../ProfileConfigurationEditViewModel.cs | 21 ++- .../ProfileEditor/Commands/PasteKeyframes.cs | 69 +++++++++ .../Services/RegistrationService.cs | 5 +- 31 files changed, 719 insertions(+), 178 deletions(-) create mode 100644 src/Artemis.UI.Shared/Extensions/ClipboardExtensions.cs create mode 100644 src/Artemis.UI.Shared/Services/ProfileEditor/Commands/DuplicateKeyframe.cs create mode 100644 src/Artemis.UI/Models/KeyframeClipboardModel.cs create mode 100644 src/Artemis.UI/Screens/Sidebar/Dialogs/ModuleActivationRequirementView.axaml create mode 100644 src/Artemis.UI/Screens/Sidebar/Dialogs/ModuleActivationRequirementView.axaml.cs create mode 100644 src/Artemis.UI/Screens/Sidebar/Dialogs/ModuleActivationRequirementViewModel.cs create mode 100644 src/Artemis.UI/Screens/Sidebar/Dialogs/ModuleActivationRequirementsView.axaml create mode 100644 src/Artemis.UI/Screens/Sidebar/Dialogs/ModuleActivationRequirementsView.axaml.cs create mode 100644 src/Artemis.UI/Screens/Sidebar/Dialogs/ModuleActivationRequirementsViewModel.cs create mode 100644 src/Artemis.UI/Services/ProfileEditor/Commands/PasteKeyframes.cs diff --git a/src/.idea/.idea.Artemis/.idea/avalonia.xml b/src/.idea/.idea.Artemis/.idea/avalonia.xml index 8ad483249..03fd0fca9 100644 --- a/src/.idea/.idea.Artemis/.idea/avalonia.xml +++ b/src/.idea/.idea.Artemis/.idea/avalonia.xml @@ -16,6 +16,7 @@ + @@ -55,6 +56,8 @@ + + diff --git a/src/Artemis.Core/Models/Profile/LayerProperties/ILayerProperty.cs b/src/Artemis.Core/Models/Profile/LayerProperties/ILayerProperty.cs index 009852cec..2f36515f8 100644 --- a/src/Artemis.Core/Models/Profile/LayerProperties/ILayerProperty.cs +++ b/src/Artemis.Core/Models/Profile/LayerProperties/ILayerProperty.cs @@ -1,5 +1,4 @@ using System; -using System.Collections.Generic; using System.Collections.ObjectModel; using Artemis.Storage.Entities.Profile; @@ -79,11 +78,11 @@ namespace Artemis.Core void Initialize(RenderProfileElement profileElement, LayerPropertyGroup group, PropertyEntity entity, bool fromStorage, PropertyDescriptionAttribute description); /// - /// Attempts to load and add the provided keyframe entity to the layer property + /// Attempts to create a keyframe for this property from the provided entity /// - /// The entity representing the keyframe to add + /// The entity representing the keyframe to create /// If succeeded the resulting keyframe, otherwise - ILayerPropertyKeyframe? AddKeyframeEntity(KeyframeEntity keyframeEntity); + ILayerPropertyKeyframe? CreateKeyframeFromEntity(KeyframeEntity keyframeEntity); /// /// Overrides the property value with the default value diff --git a/src/Artemis.Core/Models/Profile/LayerProperties/ILayerPropertyKeyframe.cs b/src/Artemis.Core/Models/Profile/LayerProperties/ILayerPropertyKeyframe.cs index be7528402..ab3446359 100644 --- a/src/Artemis.Core/Models/Profile/LayerProperties/ILayerPropertyKeyframe.cs +++ b/src/Artemis.Core/Models/Profile/LayerProperties/ILayerPropertyKeyframe.cs @@ -32,5 +32,12 @@ namespace Artemis.Core /// Removes the keyframe from the layer property /// void Remove(); + + /// + /// Creates a copy of this keyframe. + /// Note: The copied keyframe is not added to the layer property. + /// + /// The resulting copy + ILayerPropertyKeyframe CreateCopy(); } } \ No newline at end of file diff --git a/src/Artemis.Core/Models/Profile/LayerProperties/LayerProperty.cs b/src/Artemis.Core/Models/Profile/LayerProperties/LayerProperty.cs index 28e2d5db4..8c16c3b93 100644 --- a/src/Artemis.Core/Models/Profile/LayerProperties/LayerProperty.cs +++ b/src/Artemis.Core/Models/Profile/LayerProperties/LayerProperty.cs @@ -1,9 +1,7 @@ using System; using System.Collections.Generic; using System.Collections.ObjectModel; -using System.Diagnostics.CodeAnalysis; using System.Linq; -using System.Text; using Artemis.Storage.Entities.Profile; using Newtonsoft.Json; @@ -326,19 +324,24 @@ namespace Artemis.Core } /// - public ILayerPropertyKeyframe? AddKeyframeEntity(KeyframeEntity keyframeEntity) + public ILayerPropertyKeyframe? CreateKeyframeFromEntity(KeyframeEntity keyframeEntity) { if (keyframeEntity.Position > ProfileElement.Timeline.Length) return null; - T? value = CoreJson.DeserializeObject(keyframeEntity.Value); - if (value == null) - return null; - LayerPropertyKeyframe keyframe = new( - CoreJson.DeserializeObject(keyframeEntity.Value)!, keyframeEntity.Position, (Easings.Functions) keyframeEntity.EasingFunction, this - ); - AddKeyframe(keyframe); - return keyframe; + try + { + T? value = CoreJson.DeserializeObject(keyframeEntity.Value); + if (value == null) + return null; + + LayerPropertyKeyframe keyframe = new(value, keyframeEntity.Position, (Easings.Functions) keyframeEntity.EasingFunction, this); + return keyframe; + } + catch (JsonException) + { + return null; + } } /// @@ -514,7 +517,7 @@ namespace Artemis.Core // Create the path to this property by walking up the tree Path = LayerPropertyGroup.Path + "." + description.Identifier; - + OnInitialize(); } @@ -547,7 +550,7 @@ namespace Artemis.Core try { foreach (KeyframeEntity keyframeEntity in Entity.KeyframeEntities.Where(k => k.Position <= ProfileElement.Timeline.Length)) - AddKeyframeEntity(keyframeEntity); + CreateKeyframeFromEntity(keyframeEntity); } catch (JsonException) { diff --git a/src/Artemis.Core/Models/Profile/LayerProperties/LayerPropertyKeyFrame.cs b/src/Artemis.Core/Models/Profile/LayerProperties/LayerPropertyKeyFrame.cs index 1f26a1876..4a889f613 100644 --- a/src/Artemis.Core/Models/Profile/LayerProperties/LayerPropertyKeyFrame.cs +++ b/src/Artemis.Core/Models/Profile/LayerProperties/LayerPropertyKeyFrame.cs @@ -80,5 +80,11 @@ namespace Artemis.Core { LayerProperty.RemoveKeyframe(this); } + + /// + public ILayerPropertyKeyframe CreateCopy() + { + return new LayerPropertyKeyframe(Value, Position, EasingFunction, LayerProperty); + } } } \ No newline at end of file diff --git a/src/Artemis.Core/Models/ProfileConfiguration/ProfileConfiguration.cs b/src/Artemis.Core/Models/ProfileConfiguration/ProfileConfiguration.cs index d07241c1b..2ed2cc21e 100644 --- a/src/Artemis.Core/Models/ProfileConfiguration/ProfileConfiguration.cs +++ b/src/Artemis.Core/Models/ProfileConfiguration/ProfileConfiguration.cs @@ -241,7 +241,9 @@ namespace Artemis.Core Icon.Load(); - ActivationCondition.LoadFromEntity(Entity.ActivationCondition); + if (Entity.ActivationCondition != null) + ActivationCondition.LoadFromEntity(Entity.ActivationCondition); + EnableHotkey = Entity.EnableHotkey != null ? new Hotkey(Entity.EnableHotkey) : null; DisableHotkey = Entity.DisableHotkey != null ? new Hotkey(Entity.DisableHotkey) : null; } diff --git a/src/Artemis.UI.Shared/Extensions/ClipboardExtensions.cs b/src/Artemis.UI.Shared/Extensions/ClipboardExtensions.cs new file mode 100644 index 000000000..b63cb4faf --- /dev/null +++ b/src/Artemis.UI.Shared/Extensions/ClipboardExtensions.cs @@ -0,0 +1,29 @@ +using System.Text; +using System.Threading.Tasks; +using Artemis.Core; +using Avalonia.Input.Platform; + +namespace Artemis.UI.Shared.Extensions; + +/// +/// Provides extension methods for Avalonia's type. +/// +public static class ClipboardExtensions +{ + /// + /// Retrieves clipboard JSON data representing and deserializes it into an instance of + /// . + /// + /// The clipboard to retrieve the data off. + /// The data format to retrieve data for. + /// The type of data to retrieve + /// + /// The resulting value or if the clipboard did not contain data for the provided ; + /// . + /// + public static async Task GetJsonAsync(this IClipboard clipboard, string format) + { + byte[]? bytes = (byte[]?) await clipboard.GetDataAsync(format); + return bytes == null ? default : CoreJson.DeserializeObject(Encoding.Unicode.GetString(bytes), true); + } +} \ No newline at end of file diff --git a/src/Artemis.UI.Shared/Services/ProfileEditor/Commands/DuplicateKeyframe.cs b/src/Artemis.UI.Shared/Services/ProfileEditor/Commands/DuplicateKeyframe.cs new file mode 100644 index 000000000..a9c82af39 --- /dev/null +++ b/src/Artemis.UI.Shared/Services/ProfileEditor/Commands/DuplicateKeyframe.cs @@ -0,0 +1,55 @@ +using System; +using Artemis.Core; + +namespace Artemis.UI.Shared.Services.ProfileEditor.Commands; + +/// +/// Represents a profile editor command that can be used to duplicate a keyframe at a new position. +/// +public class DuplicateKeyframe : IProfileEditorCommand +{ + private readonly ILayerPropertyKeyframe _keyframe; + private readonly TimeSpan _position; + + /// + /// Gets the duplicated keyframe, only available after the command has been executed. + /// + public ILayerPropertyKeyframe? Duplication { get; private set; } + + /// + /// Creates a new instance of the class. + /// + /// The keyframe to duplicate. + /// The position of the duplicated keyframe. + public DuplicateKeyframe(ILayerPropertyKeyframe keyframe, TimeSpan position) + { + _keyframe = keyframe; + _position = position; + } + + #region Implementation of IProfileEditorCommand + + /// + public string DisplayName => "Duplicate keyframe"; + + /// + public void Execute() + { + if (Duplication == null) + { + Duplication = _keyframe.CreateCopy(); + Duplication.Position = _position; + } + + _keyframe.UntypedLayerProperty.AddUntypedKeyframe(Duplication); + } + + /// + public void Undo() + { + if (Duplication != null) + _keyframe.UntypedLayerProperty.RemoveUntypedKeyframe(Duplication); + } + + #endregion +} \ No newline at end of file diff --git a/src/Artemis.UI.Shared/Services/ProfileEditor/IProfileEditorService.cs b/src/Artemis.UI.Shared/Services/ProfileEditor/IProfileEditorService.cs index 7d1314755..de5c56670 100644 --- a/src/Artemis.UI.Shared/Services/ProfileEditor/IProfileEditorService.cs +++ b/src/Artemis.UI.Shared/Services/ProfileEditor/IProfileEditorService.cs @@ -1,8 +1,8 @@ using System; using System.Collections.Generic; +using System.Collections.ObjectModel; using System.Threading.Tasks; using Artemis.Core; -using DynamicData; namespace Artemis.UI.Shared.Services.ProfileEditor; @@ -52,15 +52,14 @@ public interface IProfileEditorService : IArtemisSharedUIService IObservable SuspendedEditing { get; } /// - /// Gets a source list of all available editor tools. + /// Gets an observable read only collection of all available editor tools. /// - SourceList Tools { get; } + ReadOnlyObservableCollection Tools { get; } /// - /// Connect to the observable list of keyframes and observe any changes starting with the list's initial items. + /// Gets an observable read only collection of selected keyframes. /// - /// An observable which emits the change set. - IObservable> ConnectToKeyframes(); + ReadOnlyObservableCollection SelectedKeyframes { get; } /// /// Changes the selected profile by its . @@ -188,4 +187,16 @@ public interface IProfileEditorService : IArtemisSharedUIService /// Pauses profile preview playback. /// void Pause(); + + /// + /// Adds a profile editor tool by it's view model. + /// + /// The view model of the tool to add. + void AddTool(IToolViewModel toolViewModel); + + /// + /// Removes a profile editor tool by it's view model. + /// + /// The view model of the tool to remove. + void RemoveTool(IToolViewModel toolViewModel); } \ No newline at end of file diff --git a/src/Artemis.UI.Shared/Services/ProfileEditor/IToolViewModel.cs b/src/Artemis.UI.Shared/Services/ProfileEditor/IToolViewModel.cs index bb841721c..e34febdfe 100644 --- a/src/Artemis.UI.Shared/Services/ProfileEditor/IToolViewModel.cs +++ b/src/Artemis.UI.Shared/Services/ProfileEditor/IToolViewModel.cs @@ -1,7 +1,5 @@ using System; -using System.Windows.Input; using Material.Icons; -using ReactiveUI; namespace Artemis.UI.Shared.Services.ProfileEditor; @@ -47,6 +45,7 @@ public interface IToolViewModel : IDisposable public string ToolTip { get; } } +/// public abstract class ToolViewModel : ActivatableViewModelBase, IToolViewModel { private bool _isSelected; diff --git a/src/Artemis.UI.Shared/Services/ProfileEditor/ProfileEditorService.cs b/src/Artemis.UI.Shared/Services/ProfileEditor/ProfileEditorService.cs index 5ec54e4f3..368f3961c 100644 --- a/src/Artemis.UI.Shared/Services/ProfileEditor/ProfileEditorService.cs +++ b/src/Artemis.UI.Shared/Services/ProfileEditor/ProfileEditorService.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Collections.ObjectModel; using System.Linq; using System.Reactive.Linq; using System.Reactive.Subjects; @@ -26,9 +27,10 @@ internal class ProfileEditorService : IProfileEditorService private readonly Dictionary _profileEditorHistories = new(); private readonly BehaviorSubject _profileElementSubject = new(null); private readonly IProfileService _profileService; - private readonly SourceList _selectedKeyframes = new(); private readonly BehaviorSubject _suspendedEditingSubject = new(false); private readonly BehaviorSubject _timeSubject = new(TimeSpan.Zero); + private readonly SourceList _tools; + private readonly SourceList _selectedKeyframes; private readonly IWindowService _windowService; private ProfileEditorCommandScope? _profileEditorHistoryScope; @@ -46,6 +48,12 @@ internal class ProfileEditorService : IProfileEditorService _layerBrushService = layerBrushService; _windowService = windowService; + _tools = new SourceList(); + _selectedKeyframes = new SourceList(); + _tools.Connect().AutoRefreshOnObservable(t => t.WhenAnyValue(vm => vm.IsSelected)).Subscribe(OnToolSelected); + _tools.Connect().Bind(out ReadOnlyObservableCollection tools).Subscribe(); + _selectedKeyframes.Connect().Bind(out ReadOnlyObservableCollection selectedKeyframes).Subscribe(); + ProfileConfiguration = _profileConfigurationSubject.AsObservable(); ProfileElement = _profileElementSubject.AsObservable(); LayerProperty = _layerPropertySubject.AsObservable(); @@ -54,60 +62,8 @@ internal class ProfileEditorService : IProfileEditorService Playing = _playingSubject.AsObservable(); SuspendedEditing = _suspendedEditingSubject.AsObservable(); PixelsPerSecond = _pixelsPerSecondSubject.AsObservable(); - Tools = new SourceList(); - Tools.Connect().AutoRefreshOnObservable(t => t.WhenAnyValue(vm => vm.IsSelected)).Subscribe(set => - { - IToolViewModel? changed = set.FirstOrDefault()?.Item.Current; - if (changed == null) - return; - - // Disable all others if the changed one is selected and exclusive - if (changed.IsSelected && changed.IsExclusive) - Tools.Edit(list => - { - foreach (IToolViewModel toolViewModel in list.Where(t => t.IsExclusive && t != changed)) - toolViewModel.IsSelected = false; - }); - }); - } - - private ProfileEditorHistory? GetHistory(ProfileConfiguration? profileConfiguration) - { - if (profileConfiguration == null) - return null; - if (_profileEditorHistories.TryGetValue(profileConfiguration, out ProfileEditorHistory? history)) - return history; - - ProfileEditorHistory newHistory = new(profileConfiguration); - _profileEditorHistories.Add(profileConfiguration, newHistory); - return newHistory; - } - - private void Tick(TimeSpan time) - { - if (_profileConfigurationSubject.Value?.Profile == null || _suspendedEditingSubject.Value) - return; - - TickProfileElement(_profileConfigurationSubject.Value.Profile.GetRootFolder(), time); - } - - private void TickProfileElement(ProfileElement profileElement, TimeSpan time) - { - if (profileElement is not RenderProfileElement renderElement) - return; - - if (renderElement.Suspended) - { - renderElement.Disable(); - } - else - { - renderElement.Enable(); - renderElement.OverrideTimelineAndApply(time); - - foreach (ProfileElement child in renderElement.Children) - TickProfileElement(child, time); - } + Tools = tools; + SelectedKeyframes = selectedKeyframes; } public IObservable ProfileConfiguration { get; } @@ -118,12 +74,8 @@ internal class ProfileEditorService : IProfileEditorService public IObservable Time { get; } public IObservable Playing { get; } public IObservable PixelsPerSecond { get; } - public SourceList Tools { get; } - - public IObservable> ConnectToKeyframes() - { - return _selectedKeyframes.Connect(); - } + public ReadOnlyObservableCollection Tools { get; } + public ReadOnlyObservableCollection SelectedKeyframes { get; } public void ChangeCurrentProfileConfiguration(ProfileConfiguration? profileConfiguration) { @@ -318,7 +270,7 @@ internal class ProfileEditorService : IProfileEditorService return folder; } } - + /// public Layer CreateAndAddLayer(ProfileElement target) { @@ -380,6 +332,18 @@ internal class ProfileEditorService : IProfileEditorService _playingSubject.OnNext(false); } + /// + public void AddTool(IToolViewModel toolViewModel) + { + _tools.Add(toolViewModel); + } + + /// + public void RemoveTool(IToolViewModel toolViewModel) + { + _tools.Remove(toolViewModel); + } + #region Commands public void ExecuteCommand(IProfileEditorCommand command) @@ -433,4 +397,58 @@ internal class ProfileEditorService : IProfileEditorService } #endregion + + private void OnToolSelected(IChangeSet changeSet) + { + IToolViewModel? changed = changeSet.FirstOrDefault()?.Item.Current; + if (changed == null) + return; + + // Disable all others if the changed one is selected and exclusive + if (changed.IsSelected && changed.IsExclusive) + _tools.Edit(list => + { + foreach (IToolViewModel toolViewModel in list.Where(t => t.IsExclusive && t != changed)) + toolViewModel.IsSelected = false; + }); + } + + private ProfileEditorHistory? GetHistory(ProfileConfiguration? profileConfiguration) + { + if (profileConfiguration == null) + return null; + if (_profileEditorHistories.TryGetValue(profileConfiguration, out ProfileEditorHistory? history)) + return history; + + ProfileEditorHistory newHistory = new(profileConfiguration); + _profileEditorHistories.Add(profileConfiguration, newHistory); + return newHistory; + } + + private void Tick(TimeSpan time) + { + if (_profileConfigurationSubject.Value?.Profile == null || _suspendedEditingSubject.Value) + return; + + TickProfileElement(_profileConfigurationSubject.Value.Profile.GetRootFolder(), time); + } + + private void TickProfileElement(ProfileElement profileElement, TimeSpan time) + { + if (profileElement is not RenderProfileElement renderElement) + return; + + if (renderElement.Suspended) + { + renderElement.Disable(); + } + else + { + renderElement.Enable(); + renderElement.OverrideTimelineAndApply(time); + + foreach (ProfileElement child in renderElement.Children) + TickProfileElement(child, time); + } + } } \ No newline at end of file diff --git a/src/Artemis.UI.Windows/App.axaml.cs b/src/Artemis.UI.Windows/App.axaml.cs index d527e4d2d..336bb3b37 100644 --- a/src/Artemis.UI.Windows/App.axaml.cs +++ b/src/Artemis.UI.Windows/App.axaml.cs @@ -1,7 +1,5 @@ using Artemis.Core.Services; -using Artemis.UI.Shared.Providers; using Artemis.UI.Windows.Ninject; -using Artemis.UI.Windows.Providers; using Artemis.UI.Windows.Providers.Input; using Avalonia; using Avalonia.Controls.ApplicationLifetimes; @@ -17,6 +15,7 @@ namespace Artemis.UI.Windows public override void Initialize() { _kernel = ArtemisBootstrapper.Bootstrap(this, new WindowsModule()); + Program.CreateLogger(_kernel); RxApp.MainThreadScheduler = AvaloniaScheduler.Instance; AvaloniaXamlLoader.Load(this); } diff --git a/src/Artemis.UI.Windows/Program.cs b/src/Artemis.UI.Windows/Program.cs index 7ca72259b..37d6e82f7 100644 --- a/src/Artemis.UI.Windows/Program.cs +++ b/src/Artemis.UI.Windows/Program.cs @@ -1,6 +1,8 @@ using System; using Avalonia; using Avalonia.ReactiveUI; +using Ninject; +using Serilog; namespace Artemis.UI.Windows { @@ -12,7 +14,15 @@ namespace Artemis.UI.Windows [STAThread] public static void Main(string[] args) { - BuildAvaloniaApp().StartWithClassicDesktopLifetime(args); + try + { + BuildAvaloniaApp().StartWithClassicDesktopLifetime(args); + } + catch (Exception e) + { + Logger?.Fatal(e, "Fatal exception, shutting down"); + throw; + } } // Avalonia configuration, don't remove; also used by visual designer. @@ -20,5 +30,12 @@ namespace Artemis.UI.Windows { return AppBuilder.Configure().UsePlatformDetect().LogToTrace().UseReactiveUI(); } + + private static ILogger? Logger { get; set; } + + public static void CreateLogger(IKernel kernel) + { + Logger = kernel.Get().ForContext(); + } } } \ No newline at end of file diff --git a/src/Artemis.UI/Models/KeyframeClipboardModel.cs b/src/Artemis.UI/Models/KeyframeClipboardModel.cs new file mode 100644 index 000000000..e225fe225 --- /dev/null +++ b/src/Artemis.UI/Models/KeyframeClipboardModel.cs @@ -0,0 +1,25 @@ +using Artemis.Core; +using Artemis.Storage.Entities.Profile; +using Newtonsoft.Json; + +namespace Artemis.UI.Models; + +public class KeyframeClipboardModel +{ + public const string ClipboardDataFormat = "Artemis.Keyframes"; + + [JsonConstructor] + public KeyframeClipboardModel() + { + } + + public KeyframeClipboardModel(ILayerPropertyKeyframe keyframe) + { + KeyframeEntity entity = keyframe.GetKeyframeEntity(); + Path = keyframe.UntypedLayerProperty.Path; + Entity = entity; + } + + public string Path { get; set; } = null!; + public KeyframeEntity Entity { get; set; } = null!; +} \ No newline at end of file diff --git a/src/Artemis.UI/Screens/ProfileEditor/Panels/Properties/Timeline/Keyframes/ITimelineKeyframeViewModel.cs b/src/Artemis.UI/Screens/ProfileEditor/Panels/Properties/Timeline/Keyframes/ITimelineKeyframeViewModel.cs index 91b4af8b5..6a5b6cca3 100644 --- a/src/Artemis.UI/Screens/ProfileEditor/Panels/Properties/Timeline/Keyframes/ITimelineKeyframeViewModel.cs +++ b/src/Artemis.UI/Screens/ProfileEditor/Panels/Properties/Timeline/Keyframes/ITimelineKeyframeViewModel.cs @@ -1,5 +1,7 @@ using System; +using System.Reactive; using Artemis.Core; +using ReactiveUI; namespace Artemis.UI.Screens.ProfileEditor.Properties.Timeline.Keyframes; @@ -22,10 +24,11 @@ public interface ITimelineKeyframeViewModel #region Context menu actions void PopulateEasingViewModels(); - void Duplicate(); - void Copy(); - void Paste(); - void Delete(); + + ReactiveCommand Duplicate { get; } + ReactiveCommand Copy { get; } + ReactiveCommand Paste { get; } + ReactiveCommand Delete { get; } #endregion } \ No newline at end of file diff --git a/src/Artemis.UI/Screens/ProfileEditor/Panels/Properties/Timeline/Keyframes/TimelineKeyframeView.axaml b/src/Artemis.UI/Screens/ProfileEditor/Panels/Properties/Timeline/Keyframes/TimelineKeyframeView.axaml index 49836e813..3e4d5856d 100644 --- a/src/Artemis.UI/Screens/ProfileEditor/Panels/Properties/Timeline/Keyframes/TimelineKeyframeView.axaml +++ b/src/Artemis.UI/Screens/ProfileEditor/Panels/Properties/Timeline/Keyframes/TimelineKeyframeView.axaml @@ -52,23 +52,23 @@ - + - + - + - + diff --git a/src/Artemis.UI/Screens/ProfileEditor/Panels/Properties/Timeline/Keyframes/TimelineKeyframeViewModel.cs b/src/Artemis.UI/Screens/ProfileEditor/Panels/Properties/Timeline/Keyframes/TimelineKeyframeViewModel.cs index 7a6e2618a..8032e68b1 100644 --- a/src/Artemis.UI/Screens/ProfileEditor/Panels/Properties/Timeline/Keyframes/TimelineKeyframeViewModel.cs +++ b/src/Artemis.UI/Screens/ProfileEditor/Panels/Properties/Timeline/Keyframes/TimelineKeyframeViewModel.cs @@ -1,13 +1,22 @@ using System; +using System.Collections.Generic; using System.Collections.ObjectModel; using System.Linq; +using System.Reactive; using System.Reactive.Linq; +using System.Threading.Tasks; using Artemis.Core; +using Artemis.UI.Models; +using Artemis.UI.Services.ProfileEditor.Commands; using Artemis.UI.Shared; +using Artemis.UI.Shared.Extensions; using Artemis.UI.Shared.Services.ProfileEditor; using Artemis.UI.Shared.Services.ProfileEditor.Commands; +using Avalonia; using Avalonia.Controls.Mixins; +using Avalonia.Input; using DynamicData; +using DynamicData.Binding; using ReactiveUI; namespace Artemis.UI.Screens.ProfileEditor.Properties.Timeline.Keyframes; @@ -17,8 +26,9 @@ public class TimelineKeyframeViewModel : ActivatableViewModelBase, ITimelineK private readonly IProfileEditorService _profileEditorService; private ObservableAsPropertyHelper? _isSelected; private string _timestamp; - private double _x; + private bool _canPaste; + private bool _isFlyoutOpen; public TimelineKeyframeViewModel(LayerPropertyKeyframe layerPropertyKeyframe, IProfileEditorService profileEditorService) { @@ -29,20 +39,29 @@ public class TimelineKeyframeViewModel : ActivatableViewModelBase, ITimelineK this.WhenActivated(d => { - _isSelected = profileEditorService.ConnectToKeyframes() - .ToCollection() - .Select(keyframes => keyframes.Contains(LayerPropertyKeyframe)) + _isSelected = profileEditorService.SelectedKeyframes + .ToObservableChangeSet() + .Select(_ => profileEditorService.SelectedKeyframes.Contains(LayerPropertyKeyframe)) .ToProperty(this, vm => vm.IsSelected) .DisposeWith(d); - profileEditorService.ConnectToKeyframes(); profileEditorService.PixelsPerSecond.Subscribe(p => _pixelsPerSecond = p).DisposeWith(d); profileEditorService.PixelsPerSecond.Subscribe(_ => Update()).DisposeWith(d); this.WhenAnyValue(vm => vm.LayerPropertyKeyframe.Position).Subscribe(_ => Update()).DisposeWith(d); }); + this.WhenAnyValue(vm => vm.IsFlyoutOpen).Subscribe(UpdateCanPaste); + + Duplicate = ReactiveCommand.Create(ExecuteDuplicate); + Copy = ReactiveCommand.CreateFromTask(ExecuteCopy); + Paste = ReactiveCommand.CreateFromTask(ExecutePaste); + Delete = ReactiveCommand.Create(ExecuteDelete); } public LayerPropertyKeyframe LayerPropertyKeyframe { get; } public ObservableCollection EasingViewModels { get; } + public ReactiveCommand Duplicate { get; } + public ReactiveCommand Copy { get; } + public ReactiveCommand Paste { get; } + public ReactiveCommand Delete { get; } public double X { @@ -56,6 +75,18 @@ public class TimelineKeyframeViewModel : ActivatableViewModelBase, ITimelineK set => RaiseAndSetIfChanged(ref _timestamp, value); } + public bool IsFlyoutOpen + { + get => _isFlyoutOpen; + set => RaiseAndSetIfChanged(ref _isFlyoutOpen, value); + } + + public bool CanPaste + { + get => _canPaste; + set => RaiseAndSetIfChanged(ref _canPaste, value); + } + public void Update() { X = _pixelsPerSecond * LayerPropertyKeyframe.Position.TotalSeconds; @@ -79,24 +110,104 @@ public class TimelineKeyframeViewModel : ActivatableViewModelBase, ITimelineK #region Context menu actions - public void Duplicate() + private void ExecuteDelete() { - throw new NotImplementedException(); + if (!IsSelected) + { + _profileEditorService.ExecuteCommand(new DeleteKeyframe(Keyframe)); + } + else + { + List keyframes = _profileEditorService.SelectedKeyframes.ToList(); + using ProfileEditorCommandScope scope = _profileEditorService.CreateCommandScope("Delete keyframes"); + foreach (ILayerPropertyKeyframe keyframe in keyframes) + _profileEditorService.ExecuteCommand(new DeleteKeyframe(keyframe)); + } } - public void Copy() + private void ExecuteDuplicate() { - throw new NotImplementedException(); + if (!IsSelected) + { + DuplicateKeyframe command = new(Keyframe, FindKeyframeDuplicationPosition(Keyframe)); + _profileEditorService.ExecuteCommand(command); + _profileEditorService.SelectKeyframe(command.Duplication, false, false); + } + else + { + List keyframes = _profileEditorService.SelectedKeyframes.ToList(); + _profileEditorService.SelectKeyframe(null, false, false); + using ProfileEditorCommandScope scope = _profileEditorService.CreateCommandScope("Duplicate keyframes"); + foreach (ILayerPropertyKeyframe keyframe in keyframes) + { + DuplicateKeyframe command = new(keyframe, FindKeyframeDuplicationPosition(keyframe)); + _profileEditorService.ExecuteCommand(command); + _profileEditorService.SelectKeyframe(command.Duplication, true, false); + } + } } - public void Paste() + private async Task ExecuteCopy() { - throw new NotImplementedException(); + if (Application.Current?.Clipboard == null) + return; + + List keyframes = new(); + if (!IsSelected) + keyframes.Add(new KeyframeClipboardModel(Keyframe)); + else + keyframes.AddRange(_profileEditorService.SelectedKeyframes.Select(k => new KeyframeClipboardModel(k))); + + string copy = CoreJson.SerializeObject(keyframes, true); + DataObject dataObject = new(); + dataObject.Set(KeyframeClipboardModel.ClipboardDataFormat, copy); + await Application.Current.Clipboard.SetDataObjectAsync(dataObject); } - public void Delete() + private async Task ExecutePaste() { - _profileEditorService.ExecuteCommand(new DeleteKeyframe(LayerPropertyKeyframe)); + if (Application.Current?.Clipboard == null) + return; + + List? keyframes = await Application.Current.Clipboard.GetJsonAsync>(KeyframeClipboardModel.ClipboardDataFormat); + if (keyframes == null) + return; + + PasteKeyframes command = new(Keyframe.UntypedLayerProperty.ProfileElement, keyframes, FindKeyframeDuplicationPosition(Keyframe)); + _profileEditorService.ExecuteCommand(command); + if (command.PastedKeyframes != null && command.PastedKeyframes.Any()) + _profileEditorService.SelectKeyframes(command.PastedKeyframes, false); + } + + private TimeSpan FindKeyframeDuplicationPosition(ILayerPropertyKeyframe keyframe) + { + TimeSpan position; + TimeSpan distance = TimeSpan.FromSeconds(15 / _pixelsPerSecond); + TimeSpan maxRight = keyframe.UntypedLayerProperty.ProfileElement.Timeline.Length; + + // Pick the side that has the most available space + // Prefer right side + if (keyframe.Position + distance <= maxRight) + // Put the keyframe as far to the right as possible within the max + position = TimeSpan.FromSeconds(Math.Min(maxRight.TotalSeconds, (keyframe.Position + distance).TotalSeconds)); + // Fall back to left side + else + // Put the keyframe as far to the left as possible withing the max + position = TimeSpan.FromSeconds(Math.Max(0, (keyframe.Position - distance).TotalSeconds)); + + return position; + } + + private async void UpdateCanPaste(bool isFlyoutOpen) + { + if (Application.Current?.Clipboard == null) + { + CanPaste = false; + return; + } + + string[] formats = await Application.Current.Clipboard.GetFormatsAsync(); + CanPaste = formats.Contains("Artemis.Keyframes"); } #endregion diff --git a/src/Artemis.UI/Screens/ProfileEditor/Panels/Properties/Timeline/TimelineView.axaml b/src/Artemis.UI/Screens/ProfileEditor/Panels/Properties/Timeline/TimelineView.axaml index b67715de4..25bb54012 100644 --- a/src/Artemis.UI/Screens/ProfileEditor/Panels/Properties/Timeline/TimelineView.axaml +++ b/src/Artemis.UI/Screens/ProfileEditor/Panels/Properties/Timeline/TimelineView.axaml @@ -12,7 +12,10 @@ - + + + + diff --git a/src/Artemis.UI/Screens/ProfileEditor/Panels/Properties/Timeline/TimelineViewModel.cs b/src/Artemis.UI/Screens/ProfileEditor/Panels/Properties/Timeline/TimelineViewModel.cs index 572a32eb8..2e9b24cb9 100644 --- a/src/Artemis.UI/Screens/ProfileEditor/Panels/Properties/Timeline/TimelineViewModel.cs +++ b/src/Artemis.UI/Screens/ProfileEditor/Panels/Properties/Timeline/TimelineViewModel.cs @@ -2,11 +2,18 @@ using System.Collections.Generic; using System.Collections.ObjectModel; using System.Linq; +using System.Reactive; using System.Reactive.Linq; +using System.Threading.Tasks; +using Artemis.Core; +using Artemis.UI.Models; using Artemis.UI.Screens.ProfileEditor.Properties.Timeline.Keyframes; using Artemis.UI.Screens.ProfileEditor.Properties.Timeline.Segments; +using Artemis.UI.Services.ProfileEditor.Commands; using Artemis.UI.Shared; +using Artemis.UI.Shared.Extensions; using Artemis.UI.Shared.Services.ProfileEditor; +using Avalonia; using Avalonia.Controls.Mixins; using ReactiveUI; @@ -19,6 +26,8 @@ public class TimelineViewModel : ActivatableViewModelBase private ObservableAsPropertyHelper _minWidth; private List? _moveKeyframes; private ObservableAsPropertyHelper? _pixelsPerSecond; + private RenderProfileElement? _profileElement; + private TimeSpan _time; public TimelineViewModel(ObservableCollection propertyGroupViewModels, StartSegmentViewModel startSegmentViewModel, @@ -34,6 +43,8 @@ public class TimelineViewModel : ActivatableViewModelBase _profileEditorService = profileEditorService; this.WhenActivated(d => { + _profileEditorService.ProfileElement.Subscribe(p => _profileElement = p).DisposeWith(d); + _profileEditorService.Time.Subscribe(t => _time = t).DisposeWith(d); _caretPosition = _profileEditorService.Time .CombineLatest(_profileEditorService.PixelsPerSecond, (t, p) => t.TotalSeconds * p) .ToProperty(this, vm => vm.CaretPosition) @@ -46,12 +57,21 @@ public class TimelineViewModel : ActivatableViewModelBase .ToProperty(this, vm => vm.MinWidth) .DisposeWith(d); }); + + DuplicateSelectedKeyframes = ReactiveCommand.Create(ExecuteDuplicateSelectedKeyframes); + CopySelectedKeyframes = ReactiveCommand.Create(ExecuteCopySelectedKeyframes); + PasteKeyframes = ReactiveCommand.CreateFromTask(ExecutePasteKeyframes); + DeleteSelectedKeyframes = ReactiveCommand.Create(ExecuteDeleteSelectedKeyframes); } public ObservableCollection PropertyGroupViewModels { get; } public StartSegmentViewModel StartSegmentViewModel { get; } public MainSegmentViewModel MainSegmentViewModel { get; } public EndSegmentViewModel EndSegmentViewModel { get; } + public ReactiveCommand DuplicateSelectedKeyframes { get; } + public ReactiveCommand CopySelectedKeyframes { get; } + public ReactiveCommand PasteKeyframes { get; } + public ReactiveCommand DeleteSelectedKeyframes { get; } public double CaretPosition => _caretPosition?.Value ?? 0.0; public int PixelsPerSecond => _pixelsPerSecond?.Value ?? 0; @@ -137,64 +157,34 @@ public class TimelineViewModel : ActivatableViewModelBase #region Keyframe actions - public void DuplicateKeyframes(ITimelineKeyframeViewModel? source = null) + private void ExecuteDuplicateSelectedKeyframes() { - if (source is {IsSelected: false}) - { - source.Delete(); - } - else - { - List keyframes = PropertyGroupViewModels.SelectMany(g => g.GetAllKeyframeViewModels(true)).Where(k => k.IsSelected).ToList(); - using ProfileEditorCommandScope scope = _profileEditorService.CreateCommandScope($"Duplicate {keyframes.Count} keyframes."); - foreach (ITimelineKeyframeViewModel timelineKeyframeViewModel in keyframes) - timelineKeyframeViewModel.Duplicate(); - } + PropertyGroupViewModels.SelectMany(g => g.GetAllKeyframeViewModels(true)).FirstOrDefault(k => k.IsSelected)?.Duplicate.Execute().Subscribe(); } - public void CopyKeyframes(ITimelineKeyframeViewModel? source = null) + private void ExecuteCopySelectedKeyframes() { - if (source is {IsSelected: false}) - { - source.Copy(); - } - else - { - List keyframes = PropertyGroupViewModels.SelectMany(g => g.GetAllKeyframeViewModels(true)).Where(k => k.IsSelected).ToList(); - using ProfileEditorCommandScope scope = _profileEditorService.CreateCommandScope($"Copy {keyframes.Count} keyframes."); - foreach (ITimelineKeyframeViewModel timelineKeyframeViewModel in keyframes) - timelineKeyframeViewModel.Copy(); - } + PropertyGroupViewModels.SelectMany(g => g.GetAllKeyframeViewModels(true)).FirstOrDefault(k => k.IsSelected)?.Copy.Execute().Subscribe(); } - public void PasteKeyframes(ITimelineKeyframeViewModel? source = null) + private async Task ExecutePasteKeyframes() { - if (source is {IsSelected: false}) - { - source.Paste(); - } - else - { - List keyframes = PropertyGroupViewModels.SelectMany(g => g.GetAllKeyframeViewModels(true)).Where(k => k.IsSelected).ToList(); - using ProfileEditorCommandScope scope = _profileEditorService.CreateCommandScope($"Paste {keyframes.Count} keyframes."); - foreach (ITimelineKeyframeViewModel timelineKeyframeViewModel in keyframes) - timelineKeyframeViewModel.Paste(); - } + if (_profileElement == null || Application.Current?.Clipboard == null) + return; + + List? keyframes = await Application.Current.Clipboard.GetJsonAsync>(KeyframeClipboardModel.ClipboardDataFormat); + if (keyframes == null) + return; + + PasteKeyframes command = new(_profileElement, keyframes, _time); + _profileEditorService.ExecuteCommand(command); + if (command.PastedKeyframes != null && command.PastedKeyframes.Any()) + _profileEditorService.SelectKeyframes(command.PastedKeyframes, false); } - public void DeleteKeyframes(ITimelineKeyframeViewModel? source = null) + private void ExecuteDeleteSelectedKeyframes() { - if (source is {IsSelected: false}) - { - source.Delete(); - } - else - { - List keyframes = PropertyGroupViewModels.SelectMany(g => g.GetAllKeyframeViewModels(true)).Where(k => k.IsSelected).ToList(); - using ProfileEditorCommandScope scope = _profileEditorService.CreateCommandScope($"Delete {keyframes.Count} keyframes."); - foreach (ITimelineKeyframeViewModel timelineKeyframeViewModel in keyframes) - timelineKeyframeViewModel.Delete(); - } + PropertyGroupViewModels.SelectMany(g => g.GetAllKeyframeViewModels(true)).FirstOrDefault(k => k.IsSelected)?.Delete.Execute().Subscribe(); } #endregion diff --git a/src/Artemis.UI/Screens/ProfileEditor/Panels/VisualEditor/VisualEditorViewModel.cs b/src/Artemis.UI/Screens/ProfileEditor/Panels/VisualEditor/VisualEditorViewModel.cs index ebea2cf85..02ac561f2 100644 --- a/src/Artemis.UI/Screens/ProfileEditor/Panels/VisualEditor/VisualEditorViewModel.cs +++ b/src/Artemis.UI/Screens/ProfileEditor/Panels/VisualEditor/VisualEditorViewModel.cs @@ -44,8 +44,10 @@ public class VisualEditorViewModel : ActivatableViewModelBase profileEditorService.ProfileConfiguration.Subscribe(CreateVisualizers).DisposeWith(d); profileEditorService.Tools - .Connect() - .AutoRefreshOnObservable(t => t.WhenAnyValue(vm => vm.IsSelected)).Filter(t => t.IsSelected).Bind(out ReadOnlyObservableCollection tools) + .ToObservableChangeSet() + .AutoRefreshOnObservable(t => t.WhenAnyValue(vm => vm.IsSelected)) + .Filter(t => t.IsSelected) + .Bind(out ReadOnlyObservableCollection tools) .Subscribe() .DisposeWith(d); Tools = tools; diff --git a/src/Artemis.UI/Screens/ProfileEditor/ProfileEditorViewModel.cs b/src/Artemis.UI/Screens/ProfileEditor/ProfileEditorViewModel.cs index fc11ab8dd..c8cfb35a8 100644 --- a/src/Artemis.UI/Screens/ProfileEditor/ProfileEditorViewModel.cs +++ b/src/Artemis.UI/Screens/ProfileEditor/ProfileEditorViewModel.cs @@ -51,7 +51,8 @@ public class ProfileEditorViewModel : MainScreenViewModel _profileConfiguration = profileEditorService.ProfileConfiguration.ToProperty(this, vm => vm.ProfileConfiguration).DisposeWith(d); _history = profileEditorService.History.ToProperty(this, vm => vm.History).DisposeWith(d); _suspendedEditing = profileEditorService.SuspendedEditing.ToProperty(this, vm => vm.SuspendedEditing).DisposeWith(d); - profileEditorService.Tools.Connect() + profileEditorService.Tools + .ToObservableChangeSet() .Filter(t => t.ShowInToolbar) .Sort(SortExpressionComparer.Ascending(vm => vm.Order)) .Bind(out ReadOnlyObservableCollection tools) diff --git a/src/Artemis.UI/Screens/Sidebar/Dialogs/ModuleActivationRequirementView.axaml b/src/Artemis.UI/Screens/Sidebar/Dialogs/ModuleActivationRequirementView.axaml new file mode 100644 index 000000000..3e495821d --- /dev/null +++ b/src/Artemis.UI/Screens/Sidebar/Dialogs/ModuleActivationRequirementView.axaml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Sidebar/Dialogs/ModuleActivationRequirementView.axaml.cs b/src/Artemis.UI/Screens/Sidebar/Dialogs/ModuleActivationRequirementView.axaml.cs new file mode 100644 index 000000000..248296f5d --- /dev/null +++ b/src/Artemis.UI/Screens/Sidebar/Dialogs/ModuleActivationRequirementView.axaml.cs @@ -0,0 +1,17 @@ +using Avalonia.Markup.Xaml; +using Avalonia.ReactiveUI; + +namespace Artemis.UI.Screens.Sidebar; + +public class ModuleActivationRequirementView : ReactiveUserControl +{ + public ModuleActivationRequirementView() + { + InitializeComponent(); + } + + private void InitializeComponent() + { + AvaloniaXamlLoader.Load(this); + } +} \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Sidebar/Dialogs/ModuleActivationRequirementViewModel.cs b/src/Artemis.UI/Screens/Sidebar/Dialogs/ModuleActivationRequirementViewModel.cs new file mode 100644 index 000000000..b1cad321f --- /dev/null +++ b/src/Artemis.UI/Screens/Sidebar/Dialogs/ModuleActivationRequirementViewModel.cs @@ -0,0 +1,56 @@ +using System; +using System.Reactive.Disposables; +using Artemis.Core.Modules; +using Artemis.UI.Shared; +using Avalonia.Threading; +using Humanizer; +using ReactiveUI; + +namespace Artemis.UI.Screens.Sidebar; + +public class ModuleActivationRequirementViewModel : ActivatableViewModelBase +{ + private readonly IModuleActivationRequirement _activationRequirement; + private string _requirementDescription; + private bool _requirementMet; + private DispatcherTimer? _updateTimer; + + public ModuleActivationRequirementViewModel(IModuleActivationRequirement activationRequirement) + { + RequirementName = activationRequirement.GetType().Name.Humanize(); + _requirementDescription = activationRequirement.GetUserFriendlyDescription(); + _activationRequirement = activationRequirement; + + this.WhenActivated(d => + { + _updateTimer = new DispatcherTimer(TimeSpan.FromMilliseconds(500), DispatcherPriority.Normal, Update); + _updateTimer.Start(); + + Disposable.Create(() => + { + _updateTimer?.Stop(); + _updateTimer = null; + }).DisposeWith(d); + }); + } + + public string RequirementName { get; } + + public string RequirementDescription + { + get => _requirementDescription; + set => RaiseAndSetIfChanged(ref _requirementDescription, value); + } + + public bool RequirementMet + { + get => _requirementMet; + set => RaiseAndSetIfChanged(ref _requirementMet, value); + } + + private void Update(object? sender, EventArgs e) + { + RequirementDescription = _activationRequirement.GetUserFriendlyDescription(); + RequirementMet = _activationRequirement.Evaluate(); + } +} \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Sidebar/Dialogs/ModuleActivationRequirementsView.axaml b/src/Artemis.UI/Screens/Sidebar/Dialogs/ModuleActivationRequirementsView.axaml new file mode 100644 index 000000000..cebee874b --- /dev/null +++ b/src/Artemis.UI/Screens/Sidebar/Dialogs/ModuleActivationRequirementsView.axaml @@ -0,0 +1,28 @@ + + + + + AND + + + + + + + + + + These requirements allow the module creator to decide when the data is available to your profile you cannot override them. + + + + + + \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Sidebar/Dialogs/ModuleActivationRequirementsView.axaml.cs b/src/Artemis.UI/Screens/Sidebar/Dialogs/ModuleActivationRequirementsView.axaml.cs new file mode 100644 index 000000000..8ddca2456 --- /dev/null +++ b/src/Artemis.UI/Screens/Sidebar/Dialogs/ModuleActivationRequirementsView.axaml.cs @@ -0,0 +1,17 @@ +using Avalonia.Controls; +using Avalonia.Markup.Xaml; + +namespace Artemis.UI.Screens.Sidebar; + +public class ModuleActivationRequirementsView : UserControl +{ + public ModuleActivationRequirementsView() + { + InitializeComponent(); + } + + private void InitializeComponent() + { + AvaloniaXamlLoader.Load(this); + } +} \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Sidebar/Dialogs/ModuleActivationRequirementsViewModel.cs b/src/Artemis.UI/Screens/Sidebar/Dialogs/ModuleActivationRequirementsViewModel.cs new file mode 100644 index 000000000..15a7f8b16 --- /dev/null +++ b/src/Artemis.UI/Screens/Sidebar/Dialogs/ModuleActivationRequirementsViewModel.cs @@ -0,0 +1,20 @@ +using System.Collections.ObjectModel; +using System.Linq; +using Artemis.Core.Modules; +using Artemis.UI.Shared; + +namespace Artemis.UI.Screens.Sidebar; + +public class ModuleActivationRequirementsViewModel : ViewModelBase +{ + public ModuleActivationRequirementsViewModel(Module module) + { + ActivationType = module.ActivationRequirementMode == ActivationRequirementType.All ? "all requirements are met" : "any requirements are met"; + ActivationRequirements = module.ActivationRequirements != null + ? new ObservableCollection(module.ActivationRequirements.Select(r => new ModuleActivationRequirementViewModel(r))) + : new ObservableCollection(); + } + + public string ActivationType { get; } + public ObservableCollection ActivationRequirements { get; } +} \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Sidebar/Dialogs/ProfileConfigurationEditView.axaml b/src/Artemis.UI/Screens/Sidebar/Dialogs/ProfileConfigurationEditView.axaml index 012d6e006..080027d25 100644 --- a/src/Artemis.UI/Screens/Sidebar/Dialogs/ProfileConfigurationEditView.axaml +++ b/src/Artemis.UI/Screens/Sidebar/Dialogs/ProfileConfigurationEditView.axaml @@ -53,10 +53,10 @@ - + - - + + @@ -167,7 +167,7 @@ Activation conditions Set up certain conditions under which the profile should be active - + @@ -182,8 +182,9 @@ Edit condition script - + + diff --git a/src/Artemis.UI/Screens/Sidebar/Dialogs/ProfileConfigurationEditViewModel.cs b/src/Artemis.UI/Screens/Sidebar/Dialogs/ProfileConfigurationEditViewModel.cs index 6fc113693..105394aef 100644 --- a/src/Artemis.UI/Screens/Sidebar/Dialogs/ProfileConfigurationEditViewModel.cs +++ b/src/Artemis.UI/Screens/Sidebar/Dialogs/ProfileConfigurationEditViewModel.cs @@ -3,6 +3,7 @@ using System.Collections.ObjectModel; using System.IO; using System.Linq; using System.Reactive; +using System.Reactive.Linq; using System.Threading.Tasks; using Artemis.Core; using Artemis.Core.Modules; @@ -39,6 +40,7 @@ namespace Artemis.UI.Screens.Sidebar private ProfileIconViewModel? _selectedMaterialIcon; private ProfileModuleViewModel? _selectedModule; private SvgImage? _selectedSvgSource; + private readonly ObservableAsPropertyHelper _moduleActivationRequirementsViewModel; public ProfileConfigurationEditViewModel( ProfileCategory profileCategory, @@ -64,11 +66,12 @@ namespace Artemis.UI.Screens.Sidebar IsNew = profileConfiguration == null; DisplayName = IsNew ? "Artemis | Add profile" : "Artemis | Edit profile"; - Modules = new ObservableCollection( + Modules = new ObservableCollection( pluginManagementService.GetFeaturesOfType().Where(m => !m.IsAlwaysAvailable).Select(m => new ProfileModuleViewModel(m)) ); + Modules.Insert(0, null); + VisualEditorViewModel = nodeVmFactory.NodeScriptViewModel(_profileConfiguration.ActivationCondition, true); - Dispatcher.UIThread.Post(LoadIcon, DispatcherPriority.Background); BrowseBitmapFile = ReactiveCommand.CreateFromTask(ExecuteBrowseBitmapFile); BrowseSvgFile = ReactiveCommand.CreateFromTask(ExecuteBrowseSvgFile); @@ -77,7 +80,12 @@ namespace Artemis.UI.Screens.Sidebar Import = ReactiveCommand.CreateFromTask(ExecuteImport); Delete = ReactiveCommand.CreateFromTask(ExecuteDelete); Cancel = ReactiveCommand.Create(ExecuteCancel); - + + _moduleActivationRequirementsViewModel = this.WhenAnyValue(vm => vm.SelectedModule) + .Select(m => m != null ? new ModuleActivationRequirementsViewModel(m.Module) : null) + .ToProperty(this, vm => vm.ModuleActivationRequirementsViewModel); + + Dispatcher.UIThread.Post(LoadIcon, DispatcherPriority.Background); } public bool IsNew { get; } @@ -112,7 +120,7 @@ namespace Artemis.UI.Screens.Sidebar set => RaiseAndSetIfChanged(ref _disableHotkey, value); } - public ObservableCollection Modules { get; } + public ObservableCollection Modules { get; } public ProfileModuleViewModel? SelectedModule { @@ -121,6 +129,7 @@ namespace Artemis.UI.Screens.Sidebar } public NodeScriptViewModel VisualEditorViewModel { get; } + public ModuleActivationRequirementsViewModel? ModuleActivationRequirementsViewModel => _moduleActivationRequirementsViewModel.Value; public ReactiveCommand OpenConditionEditor { get; } public ReactiveCommand BrowseBitmapFile { get; } @@ -129,7 +138,7 @@ namespace Artemis.UI.Screens.Sidebar public ReactiveCommand Import { get; } public ReactiveCommand Delete { get; } public new ReactiveCommand Cancel { get; } - + private async Task ExecuteImport() { if (!IsNew) @@ -306,7 +315,7 @@ namespace Artemis.UI.Screens.Sidebar SelectedSvgSource = new SvgImage {Source = newSource}; _selectedIconPath = result[0]; } - + private async Task ExecuteOpenConditionEditor() { await _windowService.ShowDialogAsync(("nodeScript", ProfileConfiguration.ActivationCondition)); diff --git a/src/Artemis.UI/Services/ProfileEditor/Commands/PasteKeyframes.cs b/src/Artemis.UI/Services/ProfileEditor/Commands/PasteKeyframes.cs new file mode 100644 index 000000000..919e318bc --- /dev/null +++ b/src/Artemis.UI/Services/ProfileEditor/Commands/PasteKeyframes.cs @@ -0,0 +1,69 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Artemis.Core; +using Artemis.UI.Models; +using Artemis.UI.Shared.Services.ProfileEditor; + +namespace Artemis.UI.Services.ProfileEditor.Commands; + +/// +/// Represents a profile editor command that can be used to paste keyframes at a new position. +/// +public class PasteKeyframes : IProfileEditorCommand +{ + private readonly RenderProfileElement _profileElement; + private readonly List _keyframes; + private readonly TimeSpan _startPosition; + + public List? PastedKeyframes { get; set; } + + public PasteKeyframes(RenderProfileElement profileElement, List keyframes, TimeSpan startPosition) + { + _profileElement = profileElement; + _keyframes = keyframes; + _startPosition = startPosition; + } + + /// + public string DisplayName => "Paste keyframes"; + + /// + public void Execute() + { + PastedKeyframes ??= CreateKeyframes(); + foreach (ILayerPropertyKeyframe layerPropertyKeyframe in PastedKeyframes) + layerPropertyKeyframe.UntypedLayerProperty.AddUntypedKeyframe(layerPropertyKeyframe); + } + + /// + public void Undo() + { + if (PastedKeyframes == null) + return; + foreach (ILayerPropertyKeyframe layerPropertyKeyframe in PastedKeyframes) + layerPropertyKeyframe.UntypedLayerProperty.RemoveUntypedKeyframe(layerPropertyKeyframe); + } + + private List CreateKeyframes() + { + List result = new(); + + // Delegate creating the keyframes using the model to the appropriate layer properties + List layerProperties = _profileElement.GetAllLayerProperties(); + foreach (KeyframeClipboardModel clipboardModel in _keyframes) + { + ILayerProperty? layerProperty = layerProperties.FirstOrDefault(p => p.Path == clipboardModel.Path); + ILayerPropertyKeyframe? keyframe = layerProperty?.CreateKeyframeFromEntity(clipboardModel.Entity); + if (keyframe != null) + result.Add(keyframe); + } + + // Apply the position to the keyframes + TimeSpan positionOffset = _startPosition - result.Min(k => k.Position); + foreach (ILayerPropertyKeyframe layerPropertyKeyframe in result) + layerPropertyKeyframe.Position += positionOffset; + + return result; + } +} \ No newline at end of file diff --git a/src/Artemis.UI/Services/RegistrationService.cs b/src/Artemis.UI/Services/RegistrationService.cs index 906d69bbe..8bed959bd 100644 --- a/src/Artemis.UI/Services/RegistrationService.cs +++ b/src/Artemis.UI/Services/RegistrationService.cs @@ -11,10 +11,8 @@ using Artemis.UI.Shared.Providers; using Artemis.UI.Shared.Services; using Artemis.UI.Shared.Services.ProfileEditor; using Artemis.UI.Shared.Services.PropertyInput; -using Artemis.VisualScripting.Nodes; using Artemis.VisualScripting.Nodes.Mathematics; using Avalonia; -using DynamicData; using Ninject; using SkiaSharp; @@ -43,7 +41,8 @@ public class RegistrationService : IRegistrationService _nodeService = nodeService; _dataModelUIService = dataModelUIService; - profileEditorService.Tools.AddRange(toolViewModels); + foreach (IToolViewModel toolViewModel in toolViewModels) + profileEditorService.AddTool(toolViewModel); CreateCursorResources(); }