1
0
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:
Robert 2022-01-22 01:19:10 +01:00
parent a1f7f6dff8
commit 913117ad0a
15 changed files with 515 additions and 172 deletions

View File

@ -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();
}

View File

@ -57,6 +57,7 @@ namespace Artemis.Core
{
SetAndNotify(ref _position, value);
LayerProperty.SortKeyframes();
LayerProperty.ReapplyUpdate();
}
}

View File

@ -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();
}

View File

@ -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
}

View File

@ -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 />

View File

@ -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>

View File

@ -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();
}
}

View File

@ -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());
}
}
}

View File

@ -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()
{

View File

@ -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

View File

@ -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
}

View File

@ -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>

View File

@ -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)

View File

@ -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
}

View File

@ -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
}