mirror of
https://github.com/Artemis-RGB/Artemis
synced 2025-12-13 05:48:35 +00:00
Profile editor - Implemented keyframe selection, movement, deletion
Profile editor - Added command scoping
This commit is contained in:
parent
a1f7f6dff8
commit
913117ad0a
@ -213,7 +213,7 @@ namespace Artemis.Core
|
||||
SetCurrentValue(CoreJson.DeserializeObject<T>(json)!, null);
|
||||
}
|
||||
|
||||
private void ReapplyUpdate()
|
||||
internal void ReapplyUpdate()
|
||||
{
|
||||
// Create a timeline with the same position but a delta of zero
|
||||
Timeline temporaryTimeline = new();
|
||||
@ -291,6 +291,7 @@ namespace Artemis.Core
|
||||
KeyframesEnabled = true;
|
||||
|
||||
SortKeyframes();
|
||||
ReapplyUpdate();
|
||||
OnKeyframeAdded();
|
||||
}
|
||||
|
||||
@ -323,7 +324,9 @@ namespace Artemis.Core
|
||||
return;
|
||||
|
||||
_keyframes.Remove(keyframe);
|
||||
|
||||
SortKeyframes();
|
||||
ReapplyUpdate();
|
||||
OnKeyframeRemoved();
|
||||
}
|
||||
|
||||
|
||||
@ -57,6 +57,7 @@ namespace Artemis.Core
|
||||
{
|
||||
SetAndNotify(ref _position, value);
|
||||
LayerProperty.SortKeyframes();
|
||||
LayerProperty.ReapplyUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -2,21 +2,22 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace Artemis.UI.Shared.Services.ProfileEditor;
|
||||
namespace Artemis.UI.Shared.Services.ProfileEditor.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a profile editor command that can be used to combine multiple commands into one.
|
||||
/// </summary>
|
||||
public class CompositeProfileEditorCommand : IProfileEditorCommand, IDisposable
|
||||
public class CompositeCommand : IProfileEditorCommand, IDisposable
|
||||
{
|
||||
private bool _ignoreNextExecute;
|
||||
private readonly List<IProfileEditorCommand> _commands;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new instance of the <see cref="CompositeProfileEditorCommand" /> class.
|
||||
/// Creates a new instance of the <see cref="CompositeCommand" /> class.
|
||||
/// </summary>
|
||||
/// <param name="commands">The commands to execute.</param>
|
||||
/// <param name="displayName">The display name of the composite command.</param>
|
||||
public CompositeProfileEditorCommand(IEnumerable<IProfileEditorCommand> commands, string displayName)
|
||||
public CompositeCommand(IEnumerable<IProfileEditorCommand> commands, string displayName)
|
||||
{
|
||||
if (commands == null)
|
||||
throw new ArgumentNullException(nameof(commands));
|
||||
@ -24,6 +25,22 @@ public class CompositeProfileEditorCommand : IProfileEditorCommand, IDisposable
|
||||
DisplayName = displayName ?? throw new ArgumentNullException(nameof(displayName));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new instance of the <see cref="CompositeCommand" /> class.
|
||||
/// </summary>
|
||||
/// <param name="commands">The commands to execute.</param>
|
||||
/// <param name="displayName">The display name of the composite command.</param>
|
||||
/// <param name="ignoreFirstExecute">Whether or not to ignore the first execute because commands are already executed</param>
|
||||
internal CompositeCommand(IEnumerable<IProfileEditorCommand> commands, string displayName, bool ignoreFirstExecute)
|
||||
{
|
||||
if (commands == null)
|
||||
throw new ArgumentNullException(nameof(commands));
|
||||
|
||||
_ignoreNextExecute = ignoreFirstExecute;
|
||||
_commands = commands.ToList();
|
||||
DisplayName = displayName ?? throw new ArgumentNullException(nameof(displayName));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{
|
||||
@ -40,6 +57,12 @@ public class CompositeProfileEditorCommand : IProfileEditorCommand, IDisposable
|
||||
/// <inheritdoc />
|
||||
public void Execute()
|
||||
{
|
||||
if (_ignoreNextExecute)
|
||||
{
|
||||
_ignoreNextExecute = false;
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (IProfileEditorCommand profileEditorCommand in _commands)
|
||||
profileEditorCommand.Execute();
|
||||
}
|
||||
@ -48,7 +71,7 @@ public class CompositeProfileEditorCommand : IProfileEditorCommand, IDisposable
|
||||
public void Undo()
|
||||
{
|
||||
// Undo in reverse by iterating from the back
|
||||
for (int index = _commands.Count; index >= 0; index--)
|
||||
for (int index = _commands.Count - 1; index >= 0; index--)
|
||||
_commands[index].Undo();
|
||||
}
|
||||
|
||||
@ -0,0 +1,38 @@
|
||||
using Artemis.Core;
|
||||
|
||||
namespace Artemis.UI.Shared.Services.ProfileEditor.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a profile editor command that can be used to delete a keyframe.
|
||||
/// </summary>
|
||||
public class DeleteKeyframe<T> : IProfileEditorCommand
|
||||
{
|
||||
private readonly LayerPropertyKeyframe<T> _keyframe;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new instance of the <see cref="DeleteKeyframe{T}" /> class.
|
||||
/// </summary>
|
||||
public DeleteKeyframe(LayerPropertyKeyframe<T> keyframe)
|
||||
{
|
||||
_keyframe = keyframe;
|
||||
}
|
||||
|
||||
#region Implementation of IProfileEditorCommand
|
||||
|
||||
/// <inheritdoc />
|
||||
public string DisplayName => "Delete keyframe";
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Execute()
|
||||
{
|
||||
_keyframe.LayerProperty.RemoveKeyframe(_keyframe);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Undo()
|
||||
{
|
||||
_keyframe.LayerProperty.AddKeyframe(_keyframe);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@ -22,6 +22,16 @@ public class MoveKeyframe : IProfileEditorCommand
|
||||
_originalPosition = keyframe.Position;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new instance of the <see cref="MoveKeyframe" /> class.
|
||||
/// </summary>
|
||||
public MoveKeyframe(ILayerPropertyKeyframe keyframe, TimeSpan position, TimeSpan originalPosition)
|
||||
{
|
||||
_keyframe = keyframe;
|
||||
_position = position;
|
||||
_originalPosition = originalPosition;
|
||||
}
|
||||
|
||||
#region Implementation of IProfileEditorCommand
|
||||
|
||||
/// <inheritdoc />
|
||||
|
||||
@ -76,15 +76,24 @@ public interface IProfileEditorService : IArtemisSharedUIService
|
||||
/// Selects the provided keyframe.
|
||||
/// </summary>
|
||||
/// <param name="keyframe">The keyframe to select.</param>
|
||||
/// <param name="expand">If <see langword="true"/> expands the current selection; otherwise replaces it with only the provided <paramref name="keyframe"/>.</param>
|
||||
/// <param name="toggle">If <see langword="true"/> toggles the selection and only for the provided <paramref name="keyframe"/>.</param>
|
||||
/// <param name="expand">
|
||||
/// If <see langword="true" /> expands the current selection; otherwise replaces it with only the
|
||||
/// provided <paramref name="keyframe" />.
|
||||
/// </param>
|
||||
/// <param name="toggle">
|
||||
/// If <see langword="true" /> toggles the selection and only for the provided
|
||||
/// <paramref name="keyframe" />.
|
||||
/// </param>
|
||||
void SelectKeyframe(ILayerPropertyKeyframe? keyframe, bool expand, bool toggle);
|
||||
|
||||
/// <summary>
|
||||
/// Selects the provided keyframes.
|
||||
/// </summary>
|
||||
/// <param name="keyframes">The keyframes to select.</param>
|
||||
/// <param name="expand">If <see langword="true"/> expands the current selection; otherwise replaces it with only the provided <paramref name="keyframes"/>.</param>
|
||||
/// <param name="expand">
|
||||
/// If <see langword="true" /> expands the current selection; otherwise replaces it with only the
|
||||
/// provided <paramref name="keyframes" />.
|
||||
/// </param>
|
||||
void SelectKeyframes(IEnumerable<ILayerPropertyKeyframe> keyframes, bool expand);
|
||||
|
||||
/// <summary>
|
||||
@ -99,12 +108,26 @@ public interface IProfileEditorService : IArtemisSharedUIService
|
||||
/// <returns>The snapped time.</returns>
|
||||
TimeSpan SnapToTimeline(TimeSpan time, TimeSpan tolerance, bool snapToSegments, bool snapToCurrentTime, List<TimeSpan>? snapTimes = null);
|
||||
|
||||
/// <summary>
|
||||
/// Rounds the given time to something appropriate for the current zoom level.
|
||||
/// </summary>
|
||||
/// <param name="time">The time to round</param>
|
||||
/// <returns>The rounded time.</returns>
|
||||
TimeSpan RoundTime(TimeSpan time);
|
||||
|
||||
/// <summary>
|
||||
/// Executes the provided command and adds it to the history.
|
||||
/// </summary>
|
||||
/// <param name="command">The command to execute.</param>
|
||||
void ExecuteCommand(IProfileEditorCommand command);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new command scope which can be used to group undo/redo actions of multiple commands.
|
||||
/// </summary>
|
||||
/// <param name="name">The name of the command scope.</param>
|
||||
/// <returns>The command scope that will group any commands until disposed.</returns>
|
||||
ProfileEditorCommandScope CreateCommandScope(string name);
|
||||
|
||||
/// <summary>
|
||||
/// Saves the current profile.
|
||||
/// </summary>
|
||||
|
||||
@ -0,0 +1,44 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
|
||||
namespace Artemis.UI.Shared.Services.ProfileEditor;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a scope in which editor commands are executed until disposed.
|
||||
/// </summary>
|
||||
public class ProfileEditorCommandScope : IDisposable
|
||||
{
|
||||
private readonly List<IProfileEditorCommand> _commands;
|
||||
|
||||
private readonly ProfileEditorService _profileEditorService;
|
||||
|
||||
internal ProfileEditorCommandScope(ProfileEditorService profileEditorService, string name)
|
||||
{
|
||||
Name = name;
|
||||
_profileEditorService = profileEditorService;
|
||||
_commands = new List<IProfileEditorCommand>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the name of the scope.
|
||||
/// </summary>
|
||||
public string Name { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a read only collection of commands in the scope.
|
||||
/// </summary>
|
||||
public ReadOnlyCollection<IProfileEditorCommand> ProfileEditorCommands => new(_commands);
|
||||
|
||||
internal void AddCommand(IProfileEditorCommand command)
|
||||
{
|
||||
command.Execute();
|
||||
_commands.Add(command);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{
|
||||
_profileEditorService.StopCommandScope();
|
||||
}
|
||||
}
|
||||
@ -7,8 +7,8 @@ using System.Reactive.Subjects;
|
||||
using Artemis.Core;
|
||||
using ReactiveUI;
|
||||
|
||||
namespace Artemis.UI.Shared.Services.ProfileEditor
|
||||
{
|
||||
namespace Artemis.UI.Shared.Services.ProfileEditor;
|
||||
|
||||
public class ProfileEditorHistory
|
||||
{
|
||||
private readonly Subject<bool> _canRedo = new();
|
||||
@ -97,4 +97,3 @@ namespace Artemis.UI.Shared.Services.ProfileEditor
|
||||
_canRedo.OnNext(_redoCommands.Any());
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -8,6 +8,7 @@ using System.Threading.Tasks;
|
||||
using Artemis.Core;
|
||||
using Artemis.Core.Services;
|
||||
using Artemis.UI.Shared.Services.Interfaces;
|
||||
using Artemis.UI.Shared.Services.ProfileEditor.Commands;
|
||||
using DynamicData;
|
||||
using Serilog;
|
||||
|
||||
@ -28,6 +29,7 @@ internal class ProfileEditorService : IProfileEditorService
|
||||
private readonly IProfileService _profileService;
|
||||
private readonly IModuleService _moduleService;
|
||||
private readonly IWindowService _windowService;
|
||||
private ProfileEditorCommandScope? _profileEditorHistoryScope;
|
||||
|
||||
public ProfileEditorService(ILogger logger, IProfileService profileService, IModuleService moduleService, IWindowService windowService)
|
||||
{
|
||||
@ -86,6 +88,9 @@ internal class ProfileEditorService : IProfileEditorService
|
||||
// Deselect whatever profile element was active
|
||||
ChangeCurrentProfileElement(null);
|
||||
|
||||
// Close the command scope if one was open
|
||||
_profileEditorHistoryScope?.Dispose();
|
||||
|
||||
// The new profile may need activation
|
||||
if (profileConfiguration != null)
|
||||
{
|
||||
@ -205,11 +210,27 @@ internal class ProfileEditorService : IProfileEditorService
|
||||
return time;
|
||||
}
|
||||
|
||||
public TimeSpan RoundTime(TimeSpan time)
|
||||
{
|
||||
// Round the time to something that fits the current zoom level
|
||||
if (_pixelsPerSecondSubject.Value < 50)
|
||||
return TimeSpan.FromMilliseconds(Math.Round(time.TotalMilliseconds / 200.0) * 200.0);
|
||||
if (_pixelsPerSecondSubject.Value < 100)
|
||||
return TimeSpan.FromMilliseconds(Math.Round(time.TotalMilliseconds / 100.0) * 100.0);
|
||||
if (_pixelsPerSecondSubject.Value < 200)
|
||||
return TimeSpan.FromMilliseconds(Math.Round(time.TotalMilliseconds / 50.0) * 50.0);
|
||||
if (_pixelsPerSecondSubject.Value < 500)
|
||||
return TimeSpan.FromMilliseconds(Math.Round(time.TotalMilliseconds / 20.0) * 20.0);
|
||||
return TimeSpan.FromMilliseconds(Math.Round(time.TotalMilliseconds));
|
||||
}
|
||||
|
||||
public void ChangePixelsPerSecond(int pixelsPerSecond)
|
||||
{
|
||||
_pixelsPerSecondSubject.OnNext(pixelsPerSecond);
|
||||
}
|
||||
|
||||
#region Commands
|
||||
|
||||
public void ExecuteCommand(IProfileEditorCommand command)
|
||||
{
|
||||
try
|
||||
@ -218,6 +239,13 @@ internal class ProfileEditorService : IProfileEditorService
|
||||
if (history == null)
|
||||
throw new ArtemisSharedUIException("Can't execute a command when there's no active profile configuration");
|
||||
|
||||
// If a scope is active add the command to it, the scope will execute it immediately
|
||||
if (_profileEditorHistoryScope != null)
|
||||
{
|
||||
_profileEditorHistoryScope.AddCommand(command);
|
||||
return;
|
||||
}
|
||||
|
||||
history.Execute.Execute(command).Subscribe();
|
||||
}
|
||||
catch (Exception e)
|
||||
@ -227,6 +255,36 @@ internal class ProfileEditorService : IProfileEditorService
|
||||
}
|
||||
}
|
||||
|
||||
public ProfileEditorCommandScope CreateCommandScope(string name)
|
||||
{
|
||||
if (_profileEditorHistoryScope != null)
|
||||
throw new ArtemisSharedUIException($"A command scope is already active, name: {_profileEditorHistoryScope.Name}.");
|
||||
|
||||
if (name == null)
|
||||
throw new ArgumentNullException(nameof(name));
|
||||
|
||||
_profileEditorHistoryScope = new ProfileEditorCommandScope(this, name);
|
||||
return _profileEditorHistoryScope;
|
||||
}
|
||||
|
||||
internal void StopCommandScope()
|
||||
{
|
||||
// This might happen if the scope is disposed twice, it's no biggie
|
||||
if (_profileEditorHistoryScope == null)
|
||||
return;
|
||||
|
||||
ProfileEditorCommandScope scope = _profileEditorHistoryScope;
|
||||
_profileEditorHistoryScope = null;
|
||||
|
||||
// Executing the composite command won't do anything the first time (see last ctor variable)
|
||||
// commands were already executed each time they were added to the scope
|
||||
ExecuteCommand(new CompositeCommand(scope.ProfileEditorCommands, scope.Name, true));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
|
||||
|
||||
/// <inheritdoc />
|
||||
public void SaveProfile()
|
||||
{
|
||||
|
||||
@ -72,7 +72,7 @@ public class PropertiesView : ReactiveUserControl<PropertiesViewModel>
|
||||
if (e.KeyModifiers.HasFlag(KeyModifiers.Shift))
|
||||
{
|
||||
List<TimeSpan> snapTimes = ViewModel.PropertyGroupViewModels.SelectMany(g => g.GetAllKeyframeViewModels(true)).Select(k => k.Position).ToList();
|
||||
newTime = ViewModel.TimelineViewModel.SnapToTimeline(newTime, TimeSpan.FromMilliseconds(1000f / ViewModel.PixelsPerSecond * 5), true, false, snapTimes);
|
||||
newTime = ViewModel.TimelineViewModel.SnapToTimeline(newTime, true, false, snapTimes);
|
||||
}
|
||||
|
||||
// If holding down control, round to the closest 50ms
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using Artemis.Core;
|
||||
using Artemis.UI.Shared.Services.ProfileEditor.Commands;
|
||||
|
||||
namespace Artemis.UI.Screens.ProfileEditor.Properties.Timeline;
|
||||
|
||||
@ -12,20 +13,20 @@ public interface ITimelineKeyframeViewModel
|
||||
#region Movement
|
||||
|
||||
void Select(bool expand, bool toggle);
|
||||
// void StartMovement();
|
||||
// void FinishMovement();
|
||||
|
||||
void SaveOffsetToKeyframe(ITimelineKeyframeViewModel source);
|
||||
void ApplyOffsetToKeyframe(ITimelineKeyframeViewModel source);
|
||||
void UpdatePosition(TimeSpan position);
|
||||
void ReleaseMovement();
|
||||
void StartMovement(ITimelineKeyframeViewModel source);
|
||||
void UpdateMovement(TimeSpan position);
|
||||
void FinishMovement();
|
||||
TimeSpan GetTimeSpanAtPosition(double x);
|
||||
|
||||
#endregion
|
||||
|
||||
#region Context menu actions
|
||||
|
||||
void PopulateEasingViewModels();
|
||||
void Delete(bool save = true);
|
||||
void Duplicate();
|
||||
void Copy();
|
||||
void Paste();
|
||||
void Delete();
|
||||
|
||||
#endregion
|
||||
}
|
||||
@ -17,7 +17,8 @@
|
||||
Classes.selected="{Binding IsSelected}"
|
||||
PointerPressed="InputElement_OnPointerPressed"
|
||||
PointerReleased="InputElement_OnPointerReleased"
|
||||
PointerMoved="InputElement_OnPointerMoved">
|
||||
PointerMoved="InputElement_OnPointerMoved"
|
||||
Cursor="Hand">
|
||||
<Ellipse.Styles>
|
||||
<Style Selector="Ellipse">
|
||||
<Setter Property="StrokeThickness" Value="0" />
|
||||
@ -52,23 +53,23 @@
|
||||
</MenuItem.Icon>
|
||||
</MenuItem>
|
||||
<Separator />
|
||||
<MenuItem Header="Duplicate" Command="{Binding DuplicateKeyframes}" CommandParameter="{Binding}" InputGesture="Ctrl+D">
|
||||
<MenuItem Header="Duplicate" Command="{Binding $parent[timeline:TimelineView].DataContext.DuplicateKeyframes}" CommandParameter="{Binding}" InputGesture="Ctrl+D">
|
||||
<MenuItem.Icon>
|
||||
<avalonia:MaterialIcon Kind="ContentDuplicate" />
|
||||
</MenuItem.Icon>
|
||||
</MenuItem>
|
||||
<MenuItem Header="Copy" Command="{Binding CopyKeyframes}" CommandParameter="{Binding}" InputGesture="Ctrl+C">
|
||||
<MenuItem Header="Copy" Command="{Binding $parent[timeline:TimelineView].DataContext.CopyKeyframes}" CommandParameter="{Binding}" InputGesture="Ctrl+C">
|
||||
<MenuItem.Icon>
|
||||
<avalonia:MaterialIcon Kind="ContentCopy" />
|
||||
</MenuItem.Icon>
|
||||
</MenuItem>
|
||||
<MenuItem Header="Paste" Command="{Binding PasteKeyframes}" CommandParameter="{Binding}" InputGesture="Ctrl+V">
|
||||
<MenuItem Header="Paste" Command="{Binding $parent[timeline:TimelineView].DataContext.PasteKeyframes}" CommandParameter="{Binding}" InputGesture="Ctrl+V">
|
||||
<MenuItem.Icon>
|
||||
<avalonia:MaterialIcon Kind="ContentPaste" />
|
||||
</MenuItem.Icon>
|
||||
</MenuItem>
|
||||
<Separator />
|
||||
<MenuItem Header="Delete" Command="{Binding DeleteKeyframes}" InputGesture="Delete">
|
||||
<MenuItem Header="Delete" Command="{Binding $parent[timeline:TimelineView].DataContext.DeleteKeyframes}" CommandParameter="{Binding}" InputGesture="Delete">
|
||||
<MenuItem.Icon>
|
||||
<avalonia:MaterialIcon Kind="Delete" />
|
||||
</MenuItem.Icon>
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.LogicalTree;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using Avalonia.ReactiveUI;
|
||||
|
||||
@ -7,6 +8,8 @@ namespace Artemis.UI.Screens.ProfileEditor.Properties.Timeline;
|
||||
|
||||
public class TimelineKeyframeView : ReactiveUserControl<ITimelineKeyframeViewModel>
|
||||
{
|
||||
private TimelinePropertyView? _timelinePropertyView;
|
||||
private TimelineView? _timelineView;
|
||||
private bool _moved;
|
||||
|
||||
public TimelineKeyframeView()
|
||||
@ -14,6 +17,19 @@ public class TimelineKeyframeView : ReactiveUserControl<ITimelineKeyframeViewMod
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
#region Overrides of TemplatedControl
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void OnAttachedToLogicalTree(LogicalTreeAttachmentEventArgs e)
|
||||
{
|
||||
_timelineView = this.FindLogicalAncestorOfType<TimelineView>();
|
||||
_timelinePropertyView = this.FindLogicalAncestorOfType<TimelinePropertyView>();
|
||||
|
||||
base.OnAttachedToLogicalTree(e);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
private void InitializeComponent()
|
||||
{
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
@ -21,6 +37,9 @@ public class TimelineKeyframeView : ReactiveUserControl<ITimelineKeyframeViewMod
|
||||
|
||||
private void InputElement_OnPointerPressed(object? sender, PointerPressedEventArgs e)
|
||||
{
|
||||
if (ViewModel == null || !e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
|
||||
return;
|
||||
|
||||
e.Pointer.Capture((IInputElement?) sender);
|
||||
e.Handled = true;
|
||||
|
||||
@ -29,20 +48,34 @@ public class TimelineKeyframeView : ReactiveUserControl<ITimelineKeyframeViewMod
|
||||
|
||||
private void InputElement_OnPointerMoved(object? sender, PointerEventArgs e)
|
||||
{
|
||||
if (e.Pointer.Captured != sender)
|
||||
if (ViewModel == null || _timelineView?.ViewModel == null || e.Pointer.Captured != sender || !e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
|
||||
return;
|
||||
|
||||
if (!_moved)
|
||||
_timelineView.ViewModel.StartKeyframeMovement(ViewModel);
|
||||
_moved = true;
|
||||
|
||||
TimeSpan time = ViewModel.GetTimeSpanAtPosition(e.GetPosition(_timelinePropertyView).X);
|
||||
_timelineView.ViewModel.UpdateKeyframeMovement(ViewModel, time, e.KeyModifiers.HasFlag(KeyModifiers.Shift), e.KeyModifiers.HasFlag(KeyModifiers.Control));
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
private void InputElement_OnPointerReleased(object? sender, PointerReleasedEventArgs e)
|
||||
{
|
||||
if (ViewModel == null || _timelineView?.ViewModel == null || e.Pointer.Captured != sender || e.InitialPressMouseButton != MouseButton.Left)
|
||||
return;
|
||||
|
||||
e.Pointer.Capture(null);
|
||||
e.Handled = true;
|
||||
|
||||
// Select the keyframe if the user didn't move
|
||||
if (!_moved)
|
||||
ViewModel?.Select(e.KeyModifiers.HasFlag(KeyModifiers.Shift), e.KeyModifiers.HasFlag(KeyModifiers.Control));
|
||||
ViewModel.Select(e.KeyModifiers.HasFlag(KeyModifiers.Shift), e.KeyModifiers.HasFlag(KeyModifiers.Control));
|
||||
else
|
||||
{
|
||||
TimeSpan time = ViewModel.GetTimeSpanAtPosition(e.GetPosition(_timelinePropertyView).X);
|
||||
_timelineView.ViewModel.FinishKeyframeMovement(ViewModel, time, e.KeyModifiers.HasFlag(KeyModifiers.Shift), e.KeyModifiers.HasFlag(KeyModifiers.Control));
|
||||
}
|
||||
}
|
||||
|
||||
private void FlyoutBase_OnOpening(object? sender, EventArgs e)
|
||||
|
||||
@ -5,10 +5,10 @@ using System.Reactive.Linq;
|
||||
using Artemis.Core;
|
||||
using Artemis.UI.Shared;
|
||||
using Artemis.UI.Shared.Services.ProfileEditor;
|
||||
using Artemis.UI.Shared.Services.ProfileEditor.Commands;
|
||||
using Avalonia.Controls.Mixins;
|
||||
using Avalonia.Input;
|
||||
using DynamicData;
|
||||
using Humanizer;
|
||||
using ReactiveUI;
|
||||
|
||||
namespace Artemis.UI.Screens.ProfileEditor.Properties.Timeline;
|
||||
@ -30,14 +30,15 @@ public class TimelineKeyframeViewModel<T> : ActivatableViewModelBase, ITimelineK
|
||||
|
||||
this.WhenActivated(d =>
|
||||
{
|
||||
_isSelected = profileEditorService.ConnectToKeyframes().ToCollection().Select(keyframes => keyframes.Contains(LayerPropertyKeyframe)).ToProperty(this, vm => vm.IsSelected).DisposeWith(d);
|
||||
profileEditorService.ConnectToKeyframes();
|
||||
profileEditorService.PixelsPerSecond.Subscribe(p =>
|
||||
{
|
||||
_pixelsPerSecond = p;
|
||||
profileEditorService.PixelsPerSecond.Subscribe(_ => Update()).DisposeWith(d);
|
||||
}).DisposeWith(d);
|
||||
|
||||
_isSelected = profileEditorService.ConnectToKeyframes().ToCollection().Select(keyframes => keyframes.Contains(LayerPropertyKeyframe)).ToProperty(this, vm => vm.IsSelected).DisposeWith(d);
|
||||
profileEditorService.ConnectToKeyframes();
|
||||
this.WhenAnyValue(vm => vm.LayerPropertyKeyframe.Position).Subscribe(_ => Update()).DisposeWith(d);
|
||||
});
|
||||
}
|
||||
|
||||
@ -72,60 +73,76 @@ public class TimelineKeyframeViewModel<T> : ActivatableViewModelBase, ITimelineK
|
||||
_profileEditorService.SelectKeyframe(Keyframe, expand, toggle);
|
||||
}
|
||||
|
||||
public TimeSpan GetTimeSpanAtPosition(double x)
|
||||
{
|
||||
return TimeSpan.FromSeconds(x / _pixelsPerSecond);
|
||||
}
|
||||
|
||||
#region Context menu actions
|
||||
|
||||
public void Delete(bool save = true)
|
||||
public void Duplicate()
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public void Copy()
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public void Paste()
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public void Delete()
|
||||
{
|
||||
_profileEditorService.ExecuteCommand(new DeleteKeyframe<T>(LayerPropertyKeyframe));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
|
||||
#region Movement
|
||||
|
||||
private TimeSpan? _offset;
|
||||
private TimeSpan? _startPosition;
|
||||
private double _pixelsPerSecond;
|
||||
|
||||
public void ReleaseMovement()
|
||||
public void StartMovement(ITimelineKeyframeViewModel source)
|
||||
{
|
||||
_offset = null;
|
||||
}
|
||||
|
||||
public void SaveOffsetToKeyframe(ITimelineKeyframeViewModel source)
|
||||
{
|
||||
if (source == this)
|
||||
{
|
||||
_offset = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (_offset != null)
|
||||
return;
|
||||
|
||||
_startPosition = LayerPropertyKeyframe.Position;
|
||||
if (source != this)
|
||||
_offset = LayerPropertyKeyframe.Position - source.Position;
|
||||
}
|
||||
|
||||
public void ApplyOffsetToKeyframe(ITimelineKeyframeViewModel source)
|
||||
public void UpdateMovement(TimeSpan position)
|
||||
{
|
||||
if (source == this || _offset == null)
|
||||
return;
|
||||
|
||||
UpdatePosition(source.Position + _offset.Value);
|
||||
if (_offset == null)
|
||||
UpdatePosition(position);
|
||||
else
|
||||
UpdatePosition(position + _offset.Value);
|
||||
}
|
||||
|
||||
public void UpdatePosition(TimeSpan position)
|
||||
public void FinishMovement()
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
MoveKeyframe command = _startPosition != null
|
||||
? new MoveKeyframe(Keyframe, Keyframe.Position, _startPosition.Value)
|
||||
: new MoveKeyframe(Keyframe, Keyframe.Position);
|
||||
|
||||
// if (position < TimeSpan.Zero)
|
||||
// LayerPropertyKeyframe.Position = TimeSpan.Zero;
|
||||
// else if (position > _profileEditorService.SelectedProfileElement.Timeline.Length)
|
||||
// LayerPropertyKeyframe.Position = _profileEditorService.SelectedProfileElement.Timeline.Length;
|
||||
// else
|
||||
// LayerPropertyKeyframe.Position = position;
|
||||
_startPosition = null;
|
||||
_offset = null;
|
||||
|
||||
Update();
|
||||
_profileEditorService.ExecuteCommand(command);
|
||||
}
|
||||
|
||||
private void UpdatePosition(TimeSpan position)
|
||||
{
|
||||
if (position < TimeSpan.Zero)
|
||||
LayerPropertyKeyframe.Position = TimeSpan.Zero;
|
||||
else if (position > LayerPropertyKeyframe.LayerProperty.ProfileElement.Timeline.Length)
|
||||
LayerPropertyKeyframe.Position = LayerPropertyKeyframe.LayerProperty.ProfileElement.Timeline.Length;
|
||||
else
|
||||
LayerPropertyKeyframe.Position = position;
|
||||
}
|
||||
|
||||
#endregion
|
||||
@ -150,35 +167,3 @@ public class TimelineKeyframeViewModel<T> : ActivatableViewModelBase, ITimelineK
|
||||
#endregion
|
||||
}
|
||||
|
||||
public class ChangeKeyframeEasing : IProfileEditorCommand
|
||||
{
|
||||
private readonly ILayerPropertyKeyframe _keyframe;
|
||||
private readonly Easings.Functions _easingFunction;
|
||||
private readonly Easings.Functions _originalEasingFunction;
|
||||
|
||||
public ChangeKeyframeEasing(ILayerPropertyKeyframe keyframe, Easings.Functions easingFunction)
|
||||
{
|
||||
_keyframe = keyframe;
|
||||
_easingFunction = easingFunction;
|
||||
_originalEasingFunction = keyframe.EasingFunction;
|
||||
}
|
||||
|
||||
#region Implementation of IProfileEditorCommand
|
||||
|
||||
/// <inheritdoc />
|
||||
public string DisplayName => "Change easing to " + _easingFunction.Humanize(LetterCasing.LowerCase);
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Execute()
|
||||
{
|
||||
_keyframe.EasingFunction = _easingFunction;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Undo()
|
||||
{
|
||||
_keyframe.EasingFunction = _originalEasingFunction;
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@ -5,6 +5,7 @@ using System.Linq;
|
||||
using System.Reactive.Linq;
|
||||
using Artemis.UI.Shared;
|
||||
using Artemis.UI.Shared.Services.ProfileEditor;
|
||||
using Artemis.UI.Shared.Services.ProfileEditor.Commands;
|
||||
using Avalonia.Controls.Mixins;
|
||||
using ReactiveUI;
|
||||
|
||||
@ -14,6 +15,8 @@ public class TimelineViewModel : ActivatableViewModelBase
|
||||
{
|
||||
private readonly IProfileEditorService _profileEditorService;
|
||||
private ObservableAsPropertyHelper<double>? _caretPosition;
|
||||
private ObservableAsPropertyHelper<int>? _pixelsPerSecond;
|
||||
private List<ITimelineKeyframeViewModel>? _moveKeyframes;
|
||||
|
||||
public TimelineViewModel(ObservableCollection<PropertyGroupViewModel> propertyGroupViewModels, IProfileEditorService profileEditorService)
|
||||
{
|
||||
@ -26,20 +29,23 @@ public class TimelineViewModel : ActivatableViewModelBase
|
||||
.CombineLatest(_profileEditorService.PixelsPerSecond, (t, p) => t.TotalSeconds * p)
|
||||
.ToProperty(this, vm => vm.CaretPosition)
|
||||
.DisposeWith(d);
|
||||
|
||||
_pixelsPerSecond = _profileEditorService.PixelsPerSecond.ToProperty(this, vm => vm.PixelsPerSecond).DisposeWith(d);
|
||||
});
|
||||
}
|
||||
|
||||
public ObservableCollection<PropertyGroupViewModel> PropertyGroupViewModels { get; }
|
||||
|
||||
public double CaretPosition => _caretPosition?.Value ?? 0.0;
|
||||
public int PixelsPerSecond => _pixelsPerSecond?.Value ?? 0;
|
||||
|
||||
public void ChangeTime(TimeSpan newTime)
|
||||
{
|
||||
_profileEditorService.ChangeTime(newTime);
|
||||
}
|
||||
|
||||
public TimeSpan SnapToTimeline(TimeSpan time, TimeSpan tolerance, bool snapToSegments, bool snapToCurrentTime, List<TimeSpan>? snapTimes = null)
|
||||
public TimeSpan SnapToTimeline(TimeSpan time, bool snapToSegments, bool snapToCurrentTime, List<TimeSpan>? snapTimes = null)
|
||||
{
|
||||
TimeSpan tolerance = TimeSpan.FromMilliseconds(1000f / PixelsPerSecond * 5);
|
||||
return _profileEditorService.SnapToTimeline(time, tolerance, snapToSegments, snapToCurrentTime, snapTimes);
|
||||
}
|
||||
|
||||
@ -47,4 +53,122 @@ public class TimelineViewModel : ActivatableViewModelBase
|
||||
{
|
||||
_profileEditorService.SelectKeyframes(keyframes.Select(k => k.Keyframe), expand);
|
||||
}
|
||||
|
||||
#region Keyframe movement
|
||||
|
||||
public void StartKeyframeMovement(ITimelineKeyframeViewModel source)
|
||||
{
|
||||
if (!source.IsSelected)
|
||||
SelectKeyframes(new List<ITimelineKeyframeViewModel> {source}, false);
|
||||
|
||||
_moveKeyframes = PropertyGroupViewModels.SelectMany(g => g.GetAllKeyframeViewModels(true)).Where(k => k.IsSelected).ToList();
|
||||
|
||||
source.StartMovement(source);
|
||||
foreach (ITimelineKeyframeViewModel keyframeViewModel in _moveKeyframes)
|
||||
keyframeViewModel.StartMovement(source);
|
||||
}
|
||||
|
||||
public void UpdateKeyframeMovement(ITimelineKeyframeViewModel source, TimeSpan time, bool snap, bool round)
|
||||
{
|
||||
if (_moveKeyframes == null)
|
||||
return;
|
||||
|
||||
if (round)
|
||||
time = _profileEditorService.RoundTime(time);
|
||||
if (snap)
|
||||
time = SnapToTimeline(time, true, true);
|
||||
|
||||
// Always update source first
|
||||
source.UpdateMovement(time);
|
||||
foreach (ITimelineKeyframeViewModel keyframeViewModel in _moveKeyframes)
|
||||
keyframeViewModel.UpdateMovement(time);
|
||||
}
|
||||
|
||||
public void FinishKeyframeMovement(ITimelineKeyframeViewModel source, TimeSpan time, bool snap, bool round)
|
||||
{
|
||||
if (_moveKeyframes == null)
|
||||
return;
|
||||
|
||||
if (round)
|
||||
time = _profileEditorService.RoundTime(time);
|
||||
if (snap)
|
||||
time = SnapToTimeline(time, true, true);
|
||||
|
||||
// If only one selected it's always the source, update it as a single command
|
||||
if (_moveKeyframes.Count == 1)
|
||||
{
|
||||
source.UpdateMovement(time);
|
||||
source.FinishMovement();
|
||||
}
|
||||
// Otherwise update in a scope
|
||||
else
|
||||
{
|
||||
using ProfileEditorCommandScope scope = _profileEditorService.CreateCommandScope($"Move {_moveKeyframes.Count} keyframes.");
|
||||
// Always update source first
|
||||
source.UpdateMovement(time);
|
||||
foreach (ITimelineKeyframeViewModel keyframeViewModel in _moveKeyframes)
|
||||
{
|
||||
keyframeViewModel.UpdateMovement(time);
|
||||
keyframeViewModel.FinishMovement();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Keyframe actions
|
||||
|
||||
public void DuplicateKeyframes(ITimelineKeyframeViewModel source)
|
||||
{
|
||||
if (!source.IsSelected)
|
||||
source.Duplicate();
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
public void CopyKeyframes(ITimelineKeyframeViewModel source)
|
||||
{
|
||||
if (!source.IsSelected)
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
public void PasteKeyframes(ITimelineKeyframeViewModel source)
|
||||
{
|
||||
if (!source.IsSelected)
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
public void DeleteKeyframes(ITimelineKeyframeViewModel source)
|
||||
{
|
||||
if (!source.IsSelected)
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user