mirror of
https://github.com/Artemis-RGB/Artemis
synced 2025-12-13 05:48:35 +00:00
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
This commit is contained in:
parent
d563d17270
commit
dd40bdd544
3
src/.idea/.idea.Artemis/.idea/avalonia.xml
generated
3
src/.idea/.idea.Artemis/.idea/avalonia.xml
generated
@ -16,6 +16,7 @@
|
||||
<entry key="Artemis.UI.Avalonia/Views/MainWindow.axaml" value="Artemis.UI.Avalonia/Artemis.UI.Avalonia.csproj" />
|
||||
<entry key="Artemis.UI.Shared/Controls/EnumComboBox.axaml" value="Artemis.UI.Linux/Artemis.UI.Linux.csproj" />
|
||||
<entry key="Artemis.UI.Shared/Styles/Border.axaml" value="Artemis.UI.Linux/Artemis.UI.Linux.csproj" />
|
||||
<entry key="Artemis.UI.Shared/Styles/TextBlock.axaml" value="Artemis.UI.Linux/Artemis.UI.Linux.csproj" />
|
||||
<entry key="Artemis.UI.Windows/App.axaml" value="Artemis.UI.Windows/Artemis.UI.Windows.csproj" />
|
||||
<entry key="Artemis.UI/DefaultTypes/PropertyInput/ColorGradientPropertyInputView.axaml" value="Artemis.UI.Windows/Artemis.UI.Windows.csproj" />
|
||||
<entry key="Artemis.UI/DefaultTypes/PropertyInput/SKSizePropertyInputView.axaml" value="Artemis.UI.Linux/Artemis.UI.Linux.csproj" />
|
||||
@ -55,6 +56,8 @@
|
||||
<entry key="Artemis.UI/Screens/Root/SplashView.axaml" value="Artemis.UI.Linux/Artemis.UI.Linux.csproj" />
|
||||
<entry key="Artemis.UI/Screens/Settings/Tabs/GeneralTabView.axaml" value="Artemis.UI.Linux/Artemis.UI.Linux.csproj" />
|
||||
<entry key="Artemis.UI/Screens/Sidebar/ContentDialogs/SidebarCategoryEditView.axaml" value="Artemis.UI.Linux/Artemis.UI.Linux.csproj" />
|
||||
<entry key="Artemis.UI/Screens/Sidebar/Dialogs/ModuleActivationRequirementView.axaml" value="Artemis.UI.Linux/Artemis.UI.Linux.csproj" />
|
||||
<entry key="Artemis.UI/Screens/Sidebar/Dialogs/ModuleActivationRequirementsView.axaml" value="Artemis.UI.Linux/Artemis.UI.Linux.csproj" />
|
||||
<entry key="Artemis.UI/Screens/Sidebar/Dialogs/ProfileConfigurationEditView.axaml" value="Artemis.UI.Windows/Artemis.UI.Windows.csproj" />
|
||||
<entry key="Artemis.UI/Screens/Sidebar/SidebarCategoryView.axaml" value="Artemis.UI.Windows/Artemis.UI.Windows.csproj" />
|
||||
<entry key="Artemis.UI/Screens/Sidebar/SidebarProfileConfigurationView.axaml" value="Artemis.UI.Linux/Artemis.UI.Linux.csproj" />
|
||||
|
||||
@ -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);
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// </summary>
|
||||
/// <param name="keyframeEntity">The entity representing the keyframe to add</param>
|
||||
/// <param name="keyframeEntity">The entity representing the keyframe to create</param>
|
||||
/// <returns>If succeeded the resulting keyframe, otherwise <see langword="null" /></returns>
|
||||
ILayerPropertyKeyframe? AddKeyframeEntity(KeyframeEntity keyframeEntity);
|
||||
ILayerPropertyKeyframe? CreateKeyframeFromEntity(KeyframeEntity keyframeEntity);
|
||||
|
||||
/// <summary>
|
||||
/// Overrides the property value with the default value
|
||||
|
||||
@ -32,5 +32,12 @@ namespace Artemis.Core
|
||||
/// Removes the keyframe from the layer property
|
||||
/// </summary>
|
||||
void Remove();
|
||||
|
||||
/// <summary>
|
||||
/// Creates a copy of this keyframe.
|
||||
/// <para>Note: The copied keyframe is not added to the layer property.</para>
|
||||
/// </summary>
|
||||
/// <returns>The resulting copy</returns>
|
||||
ILayerPropertyKeyframe CreateCopy();
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ILayerPropertyKeyframe? AddKeyframeEntity(KeyframeEntity keyframeEntity)
|
||||
public ILayerPropertyKeyframe? CreateKeyframeFromEntity(KeyframeEntity keyframeEntity)
|
||||
{
|
||||
if (keyframeEntity.Position > ProfileElement.Timeline.Length)
|
||||
return null;
|
||||
T? value = CoreJson.DeserializeObject<T>(keyframeEntity.Value);
|
||||
if (value == null)
|
||||
return null;
|
||||
|
||||
LayerPropertyKeyframe<T> keyframe = new(
|
||||
CoreJson.DeserializeObject<T>(keyframeEntity.Value)!, keyframeEntity.Position, (Easings.Functions) keyframeEntity.EasingFunction, this
|
||||
);
|
||||
AddKeyframe(keyframe);
|
||||
return keyframe;
|
||||
try
|
||||
{
|
||||
T? value = CoreJson.DeserializeObject<T>(keyframeEntity.Value);
|
||||
if (value == null)
|
||||
return null;
|
||||
|
||||
LayerPropertyKeyframe<T> keyframe = new(value, keyframeEntity.Position, (Easings.Functions) keyframeEntity.EasingFunction, this);
|
||||
return keyframe;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -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)
|
||||
{
|
||||
|
||||
@ -80,5 +80,11 @@ namespace Artemis.Core
|
||||
{
|
||||
LayerProperty.RemoveKeyframe(this);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ILayerPropertyKeyframe CreateCopy()
|
||||
{
|
||||
return new LayerPropertyKeyframe<T>(Value, Position, EasingFunction, LayerProperty);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
29
src/Artemis.UI.Shared/Extensions/ClipboardExtensions.cs
Normal file
29
src/Artemis.UI.Shared/Extensions/ClipboardExtensions.cs
Normal file
@ -0,0 +1,29 @@
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using Artemis.Core;
|
||||
using Avalonia.Input.Platform;
|
||||
|
||||
namespace Artemis.UI.Shared.Extensions;
|
||||
|
||||
/// <summary>
|
||||
/// Provides extension methods for Avalonia's <see cref="IClipboard" /> type.
|
||||
/// </summary>
|
||||
public static class ClipboardExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Retrieves clipboard JSON data representing <typeparamref name="T" /> and deserializes it into an instance of
|
||||
/// <typeparamref name="T" />.
|
||||
/// </summary>
|
||||
/// <param name="clipboard">The clipboard to retrieve the data off.</param>
|
||||
/// <param name="format">The data format to retrieve data for.</param>
|
||||
/// <typeparam name="T">The type of data to retrieve</typeparam>
|
||||
/// <returns>
|
||||
/// The resulting value or if the clipboard did not contain data for the provided <paramref name="format" />;
|
||||
/// <see langword="null" />.
|
||||
/// </returns>
|
||||
public static async Task<T?> GetJsonAsync<T>(this IClipboard clipboard, string format)
|
||||
{
|
||||
byte[]? bytes = (byte[]?) await clipboard.GetDataAsync(format);
|
||||
return bytes == null ? default : CoreJson.DeserializeObject<T>(Encoding.Unicode.GetString(bytes), true);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,55 @@
|
||||
using System;
|
||||
using Artemis.Core;
|
||||
|
||||
namespace Artemis.UI.Shared.Services.ProfileEditor.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a profile editor command that can be used to duplicate a keyframe at a new position.
|
||||
/// </summary>
|
||||
public class DuplicateKeyframe : IProfileEditorCommand
|
||||
{
|
||||
private readonly ILayerPropertyKeyframe _keyframe;
|
||||
private readonly TimeSpan _position;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the duplicated keyframe, only available after the command has been executed.
|
||||
/// </summary>
|
||||
public ILayerPropertyKeyframe? Duplication { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new instance of the <see cref="DeleteKeyframe" /> class.
|
||||
/// </summary>
|
||||
/// <param name="keyframe">The keyframe to duplicate.</param>
|
||||
/// <param name="position">The position of the duplicated keyframe.</param>
|
||||
public DuplicateKeyframe(ILayerPropertyKeyframe keyframe, TimeSpan position)
|
||||
{
|
||||
_keyframe = keyframe;
|
||||
_position = position;
|
||||
}
|
||||
|
||||
#region Implementation of IProfileEditorCommand
|
||||
|
||||
/// <inheritdoc />
|
||||
public string DisplayName => "Duplicate keyframe";
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Execute()
|
||||
{
|
||||
if (Duplication == null)
|
||||
{
|
||||
Duplication = _keyframe.CreateCopy();
|
||||
Duplication.Position = _position;
|
||||
}
|
||||
|
||||
_keyframe.UntypedLayerProperty.AddUntypedKeyframe(Duplication);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Undo()
|
||||
{
|
||||
if (Duplication != null)
|
||||
_keyframe.UntypedLayerProperty.RemoveUntypedKeyframe(Duplication);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@ -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<bool> SuspendedEditing { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a source list of all available editor tools.
|
||||
/// Gets an observable read only collection of all available editor tools.
|
||||
/// </summary>
|
||||
SourceList<IToolViewModel> Tools { get; }
|
||||
ReadOnlyObservableCollection<IToolViewModel> Tools { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <returns>An observable which emits the change set.</returns>
|
||||
IObservable<IChangeSet<ILayerPropertyKeyframe>> ConnectToKeyframes();
|
||||
ReadOnlyObservableCollection<ILayerPropertyKeyframe> SelectedKeyframes { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Changes the selected profile by its <see cref="Core.ProfileConfiguration" />.
|
||||
@ -188,4 +187,16 @@ public interface IProfileEditorService : IArtemisSharedUIService
|
||||
/// Pauses profile preview playback.
|
||||
/// </summary>
|
||||
void Pause();
|
||||
|
||||
/// <summary>
|
||||
/// Adds a profile editor tool by it's view model.
|
||||
/// </summary>
|
||||
/// <param name="toolViewModel">The view model of the tool to add.</param>
|
||||
void AddTool(IToolViewModel toolViewModel);
|
||||
|
||||
/// <summary>
|
||||
/// Removes a profile editor tool by it's view model.
|
||||
/// </summary>
|
||||
/// <param name="toolViewModel">The view model of the tool to remove.</param>
|
||||
void RemoveTool(IToolViewModel toolViewModel);
|
||||
}
|
||||
@ -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; }
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="IToolViewModel" />
|
||||
public abstract class ToolViewModel : ActivatableViewModelBase, IToolViewModel
|
||||
{
|
||||
private bool _isSelected;
|
||||
|
||||
@ -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<ProfileConfiguration, ProfileEditorHistory> _profileEditorHistories = new();
|
||||
private readonly BehaviorSubject<RenderProfileElement?> _profileElementSubject = new(null);
|
||||
private readonly IProfileService _profileService;
|
||||
private readonly SourceList<ILayerPropertyKeyframe> _selectedKeyframes = new();
|
||||
private readonly BehaviorSubject<bool> _suspendedEditingSubject = new(false);
|
||||
private readonly BehaviorSubject<TimeSpan> _timeSubject = new(TimeSpan.Zero);
|
||||
private readonly SourceList<IToolViewModel> _tools;
|
||||
private readonly SourceList<ILayerPropertyKeyframe> _selectedKeyframes;
|
||||
private readonly IWindowService _windowService;
|
||||
private ProfileEditorCommandScope? _profileEditorHistoryScope;
|
||||
|
||||
@ -46,6 +48,12 @@ internal class ProfileEditorService : IProfileEditorService
|
||||
_layerBrushService = layerBrushService;
|
||||
_windowService = windowService;
|
||||
|
||||
_tools = new SourceList<IToolViewModel>();
|
||||
_selectedKeyframes = new SourceList<ILayerPropertyKeyframe>();
|
||||
_tools.Connect().AutoRefreshOnObservable(t => t.WhenAnyValue(vm => vm.IsSelected)).Subscribe(OnToolSelected);
|
||||
_tools.Connect().Bind(out ReadOnlyObservableCollection<IToolViewModel> tools).Subscribe();
|
||||
_selectedKeyframes.Connect().Bind(out ReadOnlyObservableCollection<ILayerPropertyKeyframe> 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<IToolViewModel>();
|
||||
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?> ProfileConfiguration { get; }
|
||||
@ -118,12 +74,8 @@ internal class ProfileEditorService : IProfileEditorService
|
||||
public IObservable<TimeSpan> Time { get; }
|
||||
public IObservable<bool> Playing { get; }
|
||||
public IObservable<int> PixelsPerSecond { get; }
|
||||
public SourceList<IToolViewModel> Tools { get; }
|
||||
|
||||
public IObservable<IChangeSet<ILayerPropertyKeyframe>> ConnectToKeyframes()
|
||||
{
|
||||
return _selectedKeyframes.Connect();
|
||||
}
|
||||
public ReadOnlyObservableCollection<IToolViewModel> Tools { get; }
|
||||
public ReadOnlyObservableCollection<ILayerPropertyKeyframe> SelectedKeyframes { get; }
|
||||
|
||||
public void ChangeCurrentProfileConfiguration(ProfileConfiguration? profileConfiguration)
|
||||
{
|
||||
@ -318,7 +270,7 @@ internal class ProfileEditorService : IProfileEditorService
|
||||
return folder;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// <inheritdoc />
|
||||
public Layer CreateAndAddLayer(ProfileElement target)
|
||||
{
|
||||
@ -380,6 +332,18 @@ internal class ProfileEditorService : IProfileEditorService
|
||||
_playingSubject.OnNext(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void AddTool(IToolViewModel toolViewModel)
|
||||
{
|
||||
_tools.Add(toolViewModel);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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<IToolViewModel> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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<App>().UsePlatformDetect().LogToTrace().UseReactiveUI();
|
||||
}
|
||||
|
||||
private static ILogger? Logger { get; set; }
|
||||
|
||||
public static void CreateLogger(IKernel kernel)
|
||||
{
|
||||
Logger = kernel.Get<ILogger>().ForContext<Program>();
|
||||
}
|
||||
}
|
||||
}
|
||||
25
src/Artemis.UI/Models/KeyframeClipboardModel.cs
Normal file
25
src/Artemis.UI/Models/KeyframeClipboardModel.cs
Normal file
@ -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!;
|
||||
}
|
||||
@ -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<Unit, Unit> Duplicate { get; }
|
||||
ReactiveCommand<Unit, Unit> Copy { get; }
|
||||
ReactiveCommand<Unit, Unit> Paste { get; }
|
||||
ReactiveCommand<Unit, Unit> Delete { get; }
|
||||
|
||||
#endregion
|
||||
}
|
||||
@ -52,23 +52,23 @@
|
||||
</MenuItem.Icon>
|
||||
</MenuItem>
|
||||
<MenuItem Header="-" />
|
||||
<MenuItem Header="Duplicate" Command="{Binding $parent[timeline:TimelineView].DataContext.DuplicateKeyframes}" CommandParameter="{Binding}" InputGesture="Ctrl+D">
|
||||
<MenuItem Header="Duplicate" Command="{Binding Duplicate}" InputGesture="Ctrl+D">
|
||||
<MenuItem.Icon>
|
||||
<avalonia:MaterialIcon Kind="ContentDuplicate" />
|
||||
</MenuItem.Icon>
|
||||
</MenuItem>
|
||||
<MenuItem Header="Copy" Command="{Binding $parent[timeline:TimelineView].DataContext.CopyKeyframes}" CommandParameter="{Binding}" InputGesture="Ctrl+C">
|
||||
<MenuItem Header="Copy" Command="{Binding Copy}" InputGesture="Ctrl+C">
|
||||
<MenuItem.Icon>
|
||||
<avalonia:MaterialIcon Kind="ContentCopy" />
|
||||
</MenuItem.Icon>
|
||||
</MenuItem>
|
||||
<MenuItem Header="Paste" Command="{Binding $parent[timeline:TimelineView].DataContext.PasteKeyframes}" CommandParameter="{Binding}" InputGesture="Ctrl+V">
|
||||
<MenuItem Header="Paste" Command="{Binding Paste}" InputGesture="Ctrl+V">
|
||||
<MenuItem.Icon>
|
||||
<avalonia:MaterialIcon Kind="ContentPaste" />
|
||||
</MenuItem.Icon>
|
||||
</MenuItem>
|
||||
<MenuItem Header="-" />
|
||||
<MenuItem Header="Delete" Command="{Binding $parent[timeline:TimelineView].DataContext.DeleteKeyframes}" CommandParameter="{Binding}" InputGesture="Delete">
|
||||
<MenuItem Header="Delete" Command="{Binding Delete}" InputGesture="Delete">
|
||||
<MenuItem.Icon>
|
||||
<avalonia:MaterialIcon Kind="Delete" />
|
||||
</MenuItem.Icon>
|
||||
|
||||
@ -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<T> : ActivatableViewModelBase, ITimelineK
|
||||
private readonly IProfileEditorService _profileEditorService;
|
||||
private ObservableAsPropertyHelper<bool>? _isSelected;
|
||||
private string _timestamp;
|
||||
|
||||
private double _x;
|
||||
private bool _canPaste;
|
||||
private bool _isFlyoutOpen;
|
||||
|
||||
public TimelineKeyframeViewModel(LayerPropertyKeyframe<T> layerPropertyKeyframe, IProfileEditorService profileEditorService)
|
||||
{
|
||||
@ -29,20 +39,29 @@ public class TimelineKeyframeViewModel<T> : 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<T> LayerPropertyKeyframe { get; }
|
||||
public ObservableCollection<TimelineEasingViewModel> EasingViewModels { get; }
|
||||
public ReactiveCommand<Unit, Unit> Duplicate { get; }
|
||||
public ReactiveCommand<Unit, Unit> Copy { get; }
|
||||
public ReactiveCommand<Unit, Unit> Paste { get; }
|
||||
public ReactiveCommand<Unit, Unit> Delete { get; }
|
||||
|
||||
public double X
|
||||
{
|
||||
@ -56,6 +75,18 @@ public class TimelineKeyframeViewModel<T> : 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<T> : ActivatableViewModelBase, ITimelineK
|
||||
|
||||
#region Context menu actions
|
||||
|
||||
public void Duplicate()
|
||||
private void ExecuteDelete()
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
if (!IsSelected)
|
||||
{
|
||||
_profileEditorService.ExecuteCommand(new DeleteKeyframe(Keyframe));
|
||||
}
|
||||
else
|
||||
{
|
||||
List<ILayerPropertyKeyframe> 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<ILayerPropertyKeyframe> 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<KeyframeClipboardModel> 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<KeyframeClipboardModel>? keyframes = await Application.Current.Clipboard.GetJsonAsync<List<KeyframeClipboardModel>>(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
|
||||
|
||||
@ -12,7 +12,10 @@
|
||||
</UserControl.Resources>
|
||||
<Grid Background="Transparent" PointerReleased="InputElement_OnPointerReleased" Focusable="True" MinWidth="{Binding MinWidth}">
|
||||
<Grid.KeyBindings>
|
||||
<KeyBinding Command="{Binding DeleteKeyframes}" Gesture="Delete" />
|
||||
<KeyBinding Command="{CompiledBinding CopySelectedKeyframes}" Gesture="Ctrl+C" />
|
||||
<KeyBinding Command="{CompiledBinding DuplicateSelectedKeyframes}" Gesture="Ctrl+D" />
|
||||
<KeyBinding Command="{CompiledBinding PasteKeyframes}" Gesture="Ctrl+V" />
|
||||
<KeyBinding Command="{CompiledBinding DeleteSelectedKeyframes}" Gesture="Delete" />
|
||||
</Grid.KeyBindings>
|
||||
<ItemsControl Items="{CompiledBinding PropertyGroupViewModels}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
|
||||
@ -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<double> _minWidth;
|
||||
private List<ITimelineKeyframeViewModel>? _moveKeyframes;
|
||||
private ObservableAsPropertyHelper<int>? _pixelsPerSecond;
|
||||
private RenderProfileElement? _profileElement;
|
||||
private TimeSpan _time;
|
||||
|
||||
public TimelineViewModel(ObservableCollection<PropertyGroupViewModel> 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<PropertyGroupViewModel> PropertyGroupViewModels { get; }
|
||||
public StartSegmentViewModel StartSegmentViewModel { get; }
|
||||
public MainSegmentViewModel MainSegmentViewModel { get; }
|
||||
public EndSegmentViewModel EndSegmentViewModel { get; }
|
||||
public ReactiveCommand<Unit, Unit> DuplicateSelectedKeyframes { get; }
|
||||
public ReactiveCommand<Unit, Unit> CopySelectedKeyframes { get; }
|
||||
public ReactiveCommand<Unit, Unit> PasteKeyframes { get; }
|
||||
public ReactiveCommand<Unit, Unit> 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<ITimelineKeyframeViewModel> 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<ITimelineKeyframeViewModel> 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<ITimelineKeyframeViewModel> 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<KeyframeClipboardModel>? keyframes = await Application.Current.Clipboard.GetJsonAsync<List<KeyframeClipboardModel>>(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<ITimelineKeyframeViewModel> 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
|
||||
|
||||
@ -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<IToolViewModel> tools)
|
||||
.ToObservableChangeSet()
|
||||
.AutoRefreshOnObservable(t => t.WhenAnyValue(vm => vm.IsSelected))
|
||||
.Filter(t => t.IsSelected)
|
||||
.Bind(out ReadOnlyObservableCollection<IToolViewModel> tools)
|
||||
.Subscribe()
|
||||
.DisposeWith(d);
|
||||
Tools = tools;
|
||||
|
||||
@ -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<IToolViewModel>.Ascending(vm => vm.Order))
|
||||
.Bind(out ReadOnlyObservableCollection<IToolViewModel> tools)
|
||||
|
||||
@ -0,0 +1,42 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:sidebar="clr-namespace:Artemis.UI.Screens.Sidebar"
|
||||
xmlns:avalonia="clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia"
|
||||
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
|
||||
x:Class="Artemis.UI.Screens.Sidebar.ModuleActivationRequirementView"
|
||||
x:DataType="sidebar:ModuleActivationRequirementViewModel">
|
||||
<UserControl.Styles>
|
||||
<Styles>
|
||||
<Style Selector="Border.status-border">
|
||||
<Setter Property="Width" Value="32" />
|
||||
<Setter Property="Height" Value="32" />
|
||||
<Setter Property="CornerRadius" Value="16" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Border.status-border avalonia|MaterialIcon">
|
||||
<Setter Property="Width" Value="16" />
|
||||
<Setter Property="Height" Value="16" />
|
||||
<Setter Property="VerticalAlignment" Value="Center" />
|
||||
<Setter Property="HorizontalAlignment" Value="Center" />
|
||||
</Style>
|
||||
</Styles>
|
||||
</UserControl.Styles>
|
||||
<StackPanel>
|
||||
<Separator Classes="card-separator" />
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<StackPanel Grid.Column="0">
|
||||
<TextBlock TextWrapping="Wrap" Text="{CompiledBinding RequirementName}" />
|
||||
<TextBlock Classes="subtitle" TextWrapping="Wrap" Text="{CompiledBinding RequirementDescription}" />
|
||||
</StackPanel>
|
||||
|
||||
<Border Grid.Row="0" Grid.Column="1" Classes="status-border" IsVisible="{CompiledBinding !RequirementMet}" Background="#ff3838">
|
||||
<avalonia:MaterialIcon Kind="Close" />
|
||||
</Border>
|
||||
<Border Grid.Row="0" Grid.Column="1" Classes="status-border" IsVisible="{CompiledBinding RequirementMet}" Background="#32a852">
|
||||
<avalonia:MaterialIcon Kind="Check" />
|
||||
</Border>
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</UserControl>
|
||||
@ -0,0 +1,17 @@
|
||||
using Avalonia.Markup.Xaml;
|
||||
using Avalonia.ReactiveUI;
|
||||
|
||||
namespace Artemis.UI.Screens.Sidebar;
|
||||
|
||||
public class ModuleActivationRequirementView : ReactiveUserControl<ModuleActivationRequirementViewModel>
|
||||
{
|
||||
public ModuleActivationRequirementView()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
private void InitializeComponent()
|
||||
{
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,28 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:sidebar="clr-namespace:Artemis.UI.Screens.Sidebar"
|
||||
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
|
||||
x:Class="Artemis.UI.Screens.Sidebar.ModuleActivationRequirementsView"
|
||||
x:DataType="sidebar:ModuleActivationRequirementsViewModel">
|
||||
<StackPanel>
|
||||
<Grid ColumnDefinitions="*,Auto,*">
|
||||
<Separator Grid.Column="0" Height="1" Background="{DynamicResource TextFillColorTertiaryBrush}" />
|
||||
<TextBlock Grid.Column="1" Margin="16 0">AND</TextBlock>
|
||||
<Separator Grid.Column="2" Height="1" Background="{DynamicResource TextFillColorTertiaryBrush}" />
|
||||
</Grid>
|
||||
<Border Classes="card" Margin="0 5">
|
||||
<StackPanel Orientation="Vertical">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<TextBlock Text="This module has built-in activation requirements and your profile won't activate until " TextWrapping="Wrap" />
|
||||
<TextBlock Text="{CompiledBinding ActivationType}" Foreground="{DynamicResource TextFillColorSecondaryBrush}" FontWeight="Medium" TextWrapping="Wrap" />
|
||||
<TextBlock Text="." />
|
||||
</StackPanel>
|
||||
<TextBlock TextWrapping="Wrap">These requirements allow the module creator to decide when the data is available to your profile you cannot override them.</TextBlock>
|
||||
|
||||
<ItemsControl Items="{CompiledBinding ActivationRequirements}" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</UserControl>
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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<ModuleActivationRequirementViewModel>(module.ActivationRequirements.Select(r => new ModuleActivationRequirementViewModel(r)))
|
||||
: new ObservableCollection<ModuleActivationRequirementViewModel>();
|
||||
}
|
||||
|
||||
public string ActivationType { get; }
|
||||
public ObservableCollection<ModuleActivationRequirementViewModel> ActivationRequirements { get; }
|
||||
}
|
||||
@ -53,10 +53,10 @@
|
||||
</ItemsPanelTemplate>
|
||||
</ComboBox.ItemsPanel>
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate DataType="{x:Type local:ProfileModuleViewModel}">
|
||||
<DataTemplate>
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<shared:ArtemisIcon Icon="{CompiledBinding Icon}" Width="16" Height="16" Margin="0 0 5 0" />
|
||||
<TextBlock Text="{CompiledBinding Name}" />
|
||||
<shared:ArtemisIcon Icon="{CompiledBinding Icon, FallbackValue='Close'}" Width="16" Height="16" Margin="0 0 5 0" />
|
||||
<TextBlock Text="{CompiledBinding Name, FallbackValue='None'}" />
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
@ -167,7 +167,7 @@
|
||||
<TextBlock Classes="h5 section-header">Activation conditions</TextBlock>
|
||||
<TextBlock Classes="subtitle section-subtitle">Set up certain conditions under which the profile should be active</TextBlock>
|
||||
</StackPanel>
|
||||
<Border Classes="card" Margin="0 5 0 15">
|
||||
<Border Classes="card" Margin="0 5">
|
||||
<Grid>
|
||||
<Border CornerRadius="5" ClipToBounds="True">
|
||||
<ContentControl Content="{CompiledBinding VisualEditorViewModel}" Height="150" />
|
||||
@ -182,8 +182,9 @@
|
||||
Edit condition script
|
||||
</Button>
|
||||
</Grid>
|
||||
|
||||
</Border>
|
||||
|
||||
<ContentControl Content="{CompiledBinding ModuleActivationRequirementsViewModel}" />
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
|
||||
|
||||
@ -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?> _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<ProfileModuleViewModel>(
|
||||
Modules = new ObservableCollection<ProfileModuleViewModel?>(
|
||||
pluginManagementService.GetFeaturesOfType<Module>().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<ProfileModuleViewModel> Modules { get; }
|
||||
public ObservableCollection<ProfileModuleViewModel?> 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<Unit, Unit> OpenConditionEditor { get; }
|
||||
public ReactiveCommand<Unit, Unit> BrowseBitmapFile { get; }
|
||||
@ -129,7 +138,7 @@ namespace Artemis.UI.Screens.Sidebar
|
||||
public ReactiveCommand<Unit, Unit> Import { get; }
|
||||
public ReactiveCommand<Unit, Unit> Delete { get; }
|
||||
public new ReactiveCommand<Unit, Unit> 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<NodeScriptWindowViewModel, bool>(("nodeScript", ProfileConfiguration.ActivationCondition));
|
||||
|
||||
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a profile editor command that can be used to paste keyframes at a new position.
|
||||
/// </summary>
|
||||
public class PasteKeyframes : IProfileEditorCommand
|
||||
{
|
||||
private readonly RenderProfileElement _profileElement;
|
||||
private readonly List<KeyframeClipboardModel> _keyframes;
|
||||
private readonly TimeSpan _startPosition;
|
||||
|
||||
public List<ILayerPropertyKeyframe>? PastedKeyframes { get; set; }
|
||||
|
||||
public PasteKeyframes(RenderProfileElement profileElement, List<KeyframeClipboardModel> keyframes, TimeSpan startPosition)
|
||||
{
|
||||
_profileElement = profileElement;
|
||||
_keyframes = keyframes;
|
||||
_startPosition = startPosition;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string DisplayName => "Paste keyframes";
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Execute()
|
||||
{
|
||||
PastedKeyframes ??= CreateKeyframes();
|
||||
foreach (ILayerPropertyKeyframe layerPropertyKeyframe in PastedKeyframes)
|
||||
layerPropertyKeyframe.UntypedLayerProperty.AddUntypedKeyframe(layerPropertyKeyframe);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Undo()
|
||||
{
|
||||
if (PastedKeyframes == null)
|
||||
return;
|
||||
foreach (ILayerPropertyKeyframe layerPropertyKeyframe in PastedKeyframes)
|
||||
layerPropertyKeyframe.UntypedLayerProperty.RemoveUntypedKeyframe(layerPropertyKeyframe);
|
||||
}
|
||||
|
||||
private List<ILayerPropertyKeyframe> CreateKeyframes()
|
||||
{
|
||||
List<ILayerPropertyKeyframe> result = new();
|
||||
|
||||
// Delegate creating the keyframes using the model to the appropriate layer properties
|
||||
List<ILayerProperty> 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;
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user