Преглед изворни кода

early animations prototype

flabbet пре 1 година
родитељ
комит
145ff54b40
30 измењених фајлова са 401 додато и 77 уклоњено
  1. 12 12
      src/PixiEditor.AvaloniaUI/Helpers/Behaviours/SliderUpdateBehavior.cs
  2. 15 0
      src/PixiEditor.AvaloniaUI/Models/DocumentModels/ChangeExecutionController.cs
  3. 16 8
      src/PixiEditor.AvaloniaUI/Models/DocumentModels/DocumentUpdater.cs
  4. 3 0
      src/PixiEditor.AvaloniaUI/Models/DocumentModels/Public/DocumentEventsModule.cs
  5. 10 0
      src/PixiEditor.AvaloniaUI/Models/DocumentModels/Public/DocumentOperationsModule.cs
  6. 1 0
      src/PixiEditor.AvaloniaUI/Models/DocumentModels/Public/DocumentToolsModule.cs
  7. 40 0
      src/PixiEditor.AvaloniaUI/Models/DocumentModels/UpdateableChangeExecutors/AnimationFrameExecutor.cs
  8. 3 0
      src/PixiEditor.AvaloniaUI/Models/DocumentModels/UpdateableChangeExecutors/UpdateableChangeExecutor.cs
  9. 3 1
      src/PixiEditor.AvaloniaUI/Models/Handlers/IAnimationHandler.cs
  10. 1 1
      src/PixiEditor.AvaloniaUI/Models/Handlers/IKeyFrameHandler.cs
  11. 1 1
      src/PixiEditor.AvaloniaUI/Models/Handlers/IRasterKeyFrameHandler.cs
  12. 5 1
      src/PixiEditor.AvaloniaUI/Models/Rendering/AffectedAreasGatherer.cs
  13. 27 10
      src/PixiEditor.AvaloniaUI/Styles/Templates/Timeline.axaml
  14. 22 1
      src/PixiEditor.AvaloniaUI/ViewModels/Document/AnimationDataViewModel.cs
  15. 1 1
      src/PixiEditor.AvaloniaUI/ViewModels/Document/KeyFrameViewModel.cs
  16. 2 2
      src/PixiEditor.AvaloniaUI/ViewModels/Document/RasterKeyFrameViewModel.cs
  17. 42 1
      src/PixiEditor.AvaloniaUI/ViewModels/SubViewModels/AnimationsViewModel.cs
  18. 109 7
      src/PixiEditor.AvaloniaUI/Views/Animations/Timeline.cs
  19. 4 1
      src/PixiEditor.AvaloniaUI/Views/Dock/TimelineDockView.axaml
  20. 1 1
      src/PixiEditor.AvaloniaUI/Views/Layers/LayersManager.axaml
  21. 3 0
      src/PixiEditor.ChangeableDocument/ChangeInfos/Animation/ActiveFrame_ChangeInfo.cs
  22. 1 1
      src/PixiEditor.ChangeableDocument/ChangeInfos/Animation/CreateRasterKeyFrame_ChangeInfo.cs
  23. 1 1
      src/PixiEditor.ChangeableDocument/ChangeInfos/Animation/DeleteKeyFrame_ChangeInfo.cs
  24. 16 16
      src/PixiEditor.ChangeableDocument/Changeables/Animations/AnimationData.cs
  25. 1 1
      src/PixiEditor.ChangeableDocument/Changeables/Animations/KeyFrame.cs
  26. 2 2
      src/PixiEditor.ChangeableDocument/Changeables/Animations/RasterKeyFrame.cs
  27. 2 2
      src/PixiEditor.ChangeableDocument/Changeables/Interfaces/IReadOnlyAnimationData.cs
  28. 1 1
      src/PixiEditor.ChangeableDocument/Changeables/Interfaces/IReadOnlyKeyFrame.cs
  29. 51 0
      src/PixiEditor.ChangeableDocument/Changes/Animation/ActiveFrame_UpdateableChange.cs
  30. 5 5
      src/PixiEditor.ChangeableDocument/Changes/Animation/CreateRasterClip_Change.cs

+ 12 - 12
src/PixiEditor.AvaloniaUI/Helpers/Behaviours/SliderUpdateBehavior.cs

@@ -47,13 +47,13 @@ internal class SliderUpdateBehavior : Behavior<Slider>
         set => SetValue(DragStartedProperty, value);
     }
 
-    public static readonly StyledProperty<ICommand> SetOpacityProperty = AvaloniaProperty.Register<SliderUpdateBehavior, ICommand>(
-        nameof(SetOpacity));
+    public static readonly StyledProperty<ICommand> SetValueCommandProperty = AvaloniaProperty.Register<SliderUpdateBehavior, ICommand>(
+        nameof(SetValueCommand));
 
-    public ICommand SetOpacity
+    public ICommand SetValueCommand
     {
-        get => GetValue(SetOpacityProperty);
-        set => SetValue(SetOpacityProperty, value);
+        get => GetValue(SetValueCommandProperty);
+        set => SetValue(SetValueCommandProperty, value);
     }
 
     public static readonly StyledProperty<double> ValueFromSliderProperty = AvaloniaProperty.Register<SliderUpdateBehavior, double>(
@@ -76,7 +76,7 @@ internal class SliderUpdateBehavior : Behavior<Slider>
     private bool bindingValueChangedWhileDragging = false;
     private double bindingValueWhileDragging = 0.0;
 
-    private bool skipSetOpacity;
+    private bool skipSetValue;
     
     protected override void OnAttached()
     {
@@ -130,26 +130,26 @@ internal class SliderUpdateBehavior : Behavior<Slider>
             if (obj.DragValueChanged is not null && obj.DragValueChanged.CanExecute(e.NewValue.Value))
                 obj.DragValueChanged.Execute(e.NewValue.Value);
         }
-        else if (!obj.skipSetOpacity)
+        else if (!obj.skipSetValue)
         {
-            if (obj.SetOpacity is not null && obj.SetOpacity.CanExecute(e.NewValue.Value))
-                obj.SetOpacity.Execute(e.NewValue.Value);
+            if (obj.SetValueCommand is not null && obj.SetValueCommand.CanExecute(e.NewValue.Value))
+                obj.SetValueCommand.Execute(e.NewValue.Value);
         }
     }
 
     private static void OnBindingValuePropertyChange(AvaloniaPropertyChangedEventArgs<double> args)
     {
         SliderUpdateBehavior obj = (SliderUpdateBehavior)args.Sender;
-        obj.skipSetOpacity = true;
+        obj.skipSetValue = true;
         if (obj.dragging)
         {
             obj.bindingValueChangedWhileDragging = true;
             obj.bindingValueWhileDragging = args.NewValue.Value;
-            obj.skipSetOpacity = false;
+            obj.skipSetValue = false;
             return;
         }
         obj.ValueFromSlider = args.NewValue.Value;
-        obj.skipSetOpacity = false;
+        obj.skipSetValue = false;
     }
 
     private void Thumb_DragCompleted(object sender, VectorEventArgs e)

+ 15 - 0
src/PixiEditor.AvaloniaUI/Models/DocumentModels/ChangeExecutionController.cs

@@ -200,4 +200,19 @@ internal class ChangeExecutionController
     {
         currentSession?.OnSelectedObjectNudged(distance);
     }
+
+    public void ActiveAnimationFrameStartedInlet()
+    {
+        currentSession?.OnActiveAnimationFrameStarted();
+    }
+    
+    public void ActiveAnimationFrameChangedInlet(int newFrame)
+    {
+        currentSession?.ActiveFrameChanged(newFrame);
+    }
+    
+    public void ActiveAnimationFrameEndedInlet()
+    {
+        currentSession?.OnActiveAnimationFrameEnded();
+    }
 }

+ 16 - 8
src/PixiEditor.AvaloniaUI/Models/DocumentModels/DocumentUpdater.cs

@@ -127,11 +127,14 @@ internal class DocumentUpdater
             case ClearSoftSelectedMembers_PassthroughAction info:
                 ProcessClearSoftSelectedMembers(info);
                 break;
-            case CreateRasterClip_ChangeInfo info:
-                ProcessCreateRasterClip(info);
+            case CreateRasterKeyFrame_ChangeInfo info:
+                ProcessCreateRasterKeyFrame(info);
                 break;
-            case DeleteClip_ChangeInfo info:
-                ProcessDeleteClip(info);
+            case DeleteKeyFrame_ChangeInfo info:
+                ProcessDeleteKeyFrame(info);
+                break;
+            case ActiveFrame_ChangeInfo info:
+                ProcessActiveFrame(info);
                 break;
         }
     }
@@ -400,13 +403,18 @@ internal class DocumentUpdater
         //doc.InternalRaiseLayersChanged(new LayersChangedEventArgs(info.GuidValue, LayerAction.Move));
     }
     
-    private void ProcessCreateRasterClip(CreateRasterClip_ChangeInfo info)
+    private void ProcessCreateRasterKeyFrame(CreateRasterKeyFrame_ChangeInfo info)
+    {
+        doc.AnimationHandler.KeyFrames.Add(new RasterKeyFrameViewModel(info.TargetLayerGuid, info.Frame, 1));
+    }
+    
+    private void ProcessDeleteKeyFrame(DeleteKeyFrame_ChangeInfo info)
     {
-        doc.AnimationHandler.Clips.Add(new RasterClipViewModel(info.TargetLayerGuid, info.Frame, 1));
+        doc.AnimationHandler.KeyFrames.RemoveAt(info.IndexOfDeletedClip);
     }
     
-    private void ProcessDeleteClip(DeleteClip_ChangeInfo info)
+    private void ProcessActiveFrame(ActiveFrame_ChangeInfo info)
     {
-        doc.AnimationHandler.Clips.RemoveAt(info.IndexOfDeletedClip);
+        doc.AnimationHandler.SetActiveFrame(info.ActiveFrame);
     }
 }

+ 3 - 0
src/PixiEditor.AvaloniaUI/Models/DocumentModels/Public/DocumentEventsModule.cs

@@ -46,4 +46,7 @@ internal class DocumentEventsModule
     public void OnSymmetryDragStarted(SymmetryAxisDirection dir) => Internals.ChangeController.SymmetryDragStartedInlet(dir);
     public void OnSymmetryDragged(SymmetryAxisDragInfo info) => Internals.ChangeController.SymmetryDraggedInlet(info);
     public void OnSymmetryDragEnded(SymmetryAxisDirection dir) => Internals.ChangeController.SymmetryDragEndedInlet(dir);
+    public void StartChangeActiveFrame() => Internals.ChangeController.ActiveAnimationFrameStartedInlet();
+    public void ChangeActiveFrame(int activeFrame) => Internals.ChangeController.ActiveAnimationFrameChangedInlet(activeFrame);
+    public void EndChangeActiveFrame() => Internals.ChangeController.ActiveAnimationFrameEndedInlet();
 }

+ 10 - 0
src/PixiEditor.AvaloniaUI/Models/DocumentModels/Public/DocumentOperationsModule.cs

@@ -604,4 +604,14 @@ internal class DocumentOperationsModule : IDocumentOperations
 
         Internals.ActionAccumulator.AddFinishedActions(new SetSelection_Action(inverse.Op(selection, VectorPathOp.Difference)));
     }
+
+    public void SetActiveFrame(int value)
+    {
+        if (Internals.ChangeController.IsChangeActive || value is < 0)
+            return;
+        
+        Internals.ActionAccumulator.AddFinishedActions(
+            new ActiveFrame_Action(value),
+            new EndActiveFrame_Action());
+    }
 }

+ 1 - 0
src/PixiEditor.AvaloniaUI/Models/DocumentModels/Public/DocumentToolsModule.cs

@@ -16,6 +16,7 @@ internal class DocumentToolsModule
     }
 
     public void UseSymmetry(SymmetryAxisDirection dir) => Internals.ChangeController.TryStartExecutor(new SymmetryExecutor(dir));
+    public void UseActiveFrame(int activeFrame) => Internals.ChangeController.TryStartExecutor(new AnimationFrameExecutor(activeFrame));
 
     public void UseOpacitySlider() => Internals.ChangeController.TryStartExecutor<StructureMemberOpacityExecutor>();
 

+ 40 - 0
src/PixiEditor.AvaloniaUI/Models/DocumentModels/UpdateableChangeExecutors/AnimationFrameExecutor.cs

@@ -0,0 +1,40 @@
+using PixiEditor.AvaloniaUI.Models.Tools;
+using PixiEditor.ChangeableDocument.Actions.Generated;
+using PixiEditor.ChangeableDocument.ChangeInfos.Animation;
+
+namespace PixiEditor.AvaloniaUI.Models.DocumentModels.UpdateableChangeExecutors;
+
+internal class AnimationFrameExecutor : UpdateableChangeExecutor
+{
+    private readonly int activeFrame;
+    
+    public AnimationFrameExecutor(int activeFrame)
+    {
+        this.activeFrame = activeFrame;
+    }
+    
+    public override ExecutionState Start()
+    {
+        internals.ActionAccumulator.AddActions(new ActiveFrame_Action(activeFrame));
+        
+        return ExecutionState.Success;
+    }
+
+    public override void ActiveFrameChanged(int newActiveFrame)
+    {
+        if (newActiveFrame == activeFrame)
+            return;
+        internals.ActionAccumulator.AddActions(new ActiveFrame_Action(newActiveFrame));
+    }
+
+    public override void OnActiveAnimationFrameEnded()
+    {
+        internals.ActionAccumulator.AddFinishedActions(new EndActiveFrame_Action());
+        onEnded?.Invoke(this);
+    }
+
+    public override void ForceStop()
+    {
+        internals.ActionAccumulator.AddFinishedActions(new EndActiveFrame_Action());
+    }
+}

+ 3 - 0
src/PixiEditor.AvaloniaUI/Models/DocumentModels/UpdateableChangeExecutors/UpdateableChangeExecutor.cs

@@ -62,4 +62,7 @@ internal abstract class UpdateableChangeExecutor
     public virtual void OnMidChangeUndo() { }
     public virtual void OnMidChangeRedo() { }
     public virtual void OnSelectedObjectNudged(VecI distance) { }
+    public virtual void OnActiveAnimationFrameStarted() { }
+    public virtual void ActiveFrameChanged(int newActiveFrame) { }
+    public virtual void OnActiveAnimationFrameEnded() { }
 }

+ 3 - 1
src/PixiEditor.AvaloniaUI/Models/Handlers/IAnimationHandler.cs

@@ -5,6 +5,8 @@ namespace PixiEditor.AvaloniaUI.Models.Handlers;
 
 public interface IAnimationHandler
 {
-    public ObservableCollection<IClipHandler> Clips { get; }
+    public ObservableCollection<IKeyFrameHandler> KeyFrames { get; }
+    public int ActiveFrameBindable { get; set; }
     public void AddRasterClip(Guid targetLayerGuid, int frame, bool cloneFromExisting);
+    public void SetActiveFrame(int newFrame);
 }

+ 1 - 1
src/PixiEditor.AvaloniaUI/Models/Handlers/IClipHandler.cs → src/PixiEditor.AvaloniaUI/Models/Handlers/IKeyFrameHandler.cs

@@ -1,6 +1,6 @@
 namespace PixiEditor.AvaloniaUI.Models.Handlers;
 
-public interface IClipHandler
+public interface IKeyFrameHandler
 {
     public int StartFrame { get; }
     public int Duration { get; }

+ 1 - 1
src/PixiEditor.AvaloniaUI/Models/Handlers/IRasterClipHandler.cs → src/PixiEditor.AvaloniaUI/Models/Handlers/IRasterKeyFrameHandler.cs

@@ -1,6 +1,6 @@
 namespace PixiEditor.AvaloniaUI.Models.Handlers;
 
-public interface IRasterClipHandler : IClipHandler
+public interface IRasterKeyFrameHandler : IKeyFrameHandler
 {
     public Guid TargetLayerGuid { get; }
 }

+ 5 - 1
src/PixiEditor.AvaloniaUI/Models/Rendering/AffectedAreasGatherer.cs

@@ -92,7 +92,7 @@ internal class AffectedAreasGatherer
                     AddAllToMainImage(info.GuidValue, false);
                     AddAllToImagePreviews(info.GuidValue, true);
                     break;
-                case CreateRasterClip_ChangeInfo info:
+                case CreateRasterKeyFrame_ChangeInfo info:
                     if (info.CloneFromExisting)
                     {
                         AddAllToMainImage(info.TargetLayerGuid);
@@ -104,6 +104,10 @@ internal class AffectedAreasGatherer
                         AddWholeCanvasToImagePreviews(info.TargetLayerGuid);
                     }
                     break;
+                case ActiveFrame_ChangeInfo info:
+                    AddWholeCanvasToMainImage();
+                    AddWholeCanvasToEveryImagePreview();
+                    break;
             }
         }
     }

+ 27 - 10
src/PixiEditor.AvaloniaUI/Styles/Templates/Timeline.axaml

@@ -2,12 +2,15 @@
                     xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
                     xmlns:animations="clr-namespace:PixiEditor.AvaloniaUI.Views.Animations"
                     xmlns:document="clr-namespace:PixiEditor.AvaloniaUI.ViewModels.Document"
-                    xmlns:handlers="clr-namespace:PixiEditor.AvaloniaUI.Models.Handlers">
+                    xmlns:handlers="clr-namespace:PixiEditor.AvaloniaUI.Models.Handlers"
+                    xmlns:behaviours="clr-namespace:PixiEditor.AvaloniaUI.Helpers.Behaviours"
+                    xmlns:commands="clr-namespace:PixiEditor.AvaloniaUI.Models.Commands.Attributes.Commands"
+                    xmlns:xaml="clr-namespace:PixiEditor.AvaloniaUI.Models.Commands.XAML">
     <Design.PreviewWith>
         <animations:Timeline>
-            <animations:Timeline.Clips>
-                <document:RasterClipViewModel Duration="100"/>
-            </animations:Timeline.Clips>
+            <animations:Timeline.KeyFrames>
+                <document:RasterKeyFrameViewModel Duration="100"/>
+            </animations:Timeline.KeyFrames>
         </animations:Timeline>
     </Design.PreviewWith>
 
@@ -21,23 +24,37 @@
                         <RowDefinition Height="*"/>
                     </Grid.RowDefinitions>
                     
-                    <ToggleButton Content="Play"/>
+                    <ToggleButton Content="Play" Name="PART_PlayToggle"/>
                     
                     <DockPanel Grid.Row="1" LastChildFill="True">
-                        <TextBlock Text="0" DockPanel.Dock="Left"/>
-                        <TextBlock Text="{Binding Clips.Count, RelativeSource={RelativeSource TemplatedParent}}" DockPanel.Dock="Right"/>
-                        <Slider Margin="10 0" Maximum="{Binding Clips.Count, RelativeSource={RelativeSource TemplatedParent}}"/>
+                        <Slider Margin="10 0" TickFrequency="1" TickPlacement="BottomRight" 
+                                SmallChange="1"
+                                LargeChange="10"
+                                IsSnapToTickEnabled="True"
+                                Name="ActiveFrameSlider"
+                                Minimum="0"
+                                Maximum="{Binding KeyFrames.Count, RelativeSource={RelativeSource TemplatedParent}}">
+                            <Interaction.Behaviors>
+                                <behaviours:SliderUpdateBehavior
+                                    Binding="{Binding RelativeSource={RelativeSource TemplatedParent}, Path=ActiveFrame, Mode=OneWay}"
+                                    DragStarted="{xaml:Command PixiEditor.Document.StartChangeActiveFrame}"
+                                    DragValueChanged="{xaml:Command PixiEditor.Document.ChangeActiveFrame, UseProvided=True}"
+                                    DragEnded="{xaml:Command PixiEditor.Document.EndChangeActiveFrame}"
+                                    SetValueCommand="{xaml:Command PixiEditor.Animation.ActiveFrameSet, UseProvided=True}"
+                                    ValueFromSlider="{Binding ElementName=ActiveFrameSlider, Path=Value, Mode=TwoWay}" />
+                            </Interaction.Behaviors>
+                        </Slider>
                     </DockPanel>
                     
                     <ScrollViewer Grid.Row="2" HorizontalScrollBarVisibility="Auto">
-                        <ItemsControl ItemsSource="{TemplateBinding Clips}">
+                        <ItemsControl ItemsSource="{TemplateBinding KeyFrames}">
                             <ItemsControl.ItemsPanel>
                                 <ItemsPanelTemplate>
                                     <StackPanel Orientation="Horizontal"/>
                                 </ItemsPanelTemplate>
                             </ItemsControl.ItemsPanel>
                             <ItemsControl.ItemTemplate>
-                                <DataTemplate DataType="handlers:IClipHandler">
+                                <DataTemplate DataType="handlers:IKeyFrameHandler">
                                     <Border BorderBrush="Black" BorderThickness="1" Margin="2">
                                         <Grid>
                                             <Grid.ColumnDefinitions>

+ 22 - 1
src/PixiEditor.AvaloniaUI/ViewModels/Document/AnimationDataViewModel.cs

@@ -9,9 +9,24 @@ namespace PixiEditor.AvaloniaUI.ViewModels.Document;
 
 internal class AnimationDataViewModel : ObservableObject, IAnimationHandler
 {
+    private int _activeFrameBindable;
     public DocumentViewModel Document { get; }
     protected DocumentInternalParts Internals { get; }
-    public ObservableCollection<IClipHandler> Clips { get; } = new();
+    public ObservableCollection<IKeyFrameHandler> KeyFrames { get; } = new();
+
+    public int ActiveFrameBindable
+    {
+        get => _activeFrameBindable;
+        set
+        {
+            if (Document.UpdateableChangeActive)
+                return;
+            
+            Internals.ActionAccumulator.AddFinishedActions(
+                new ActiveFrame_Action(value),
+                new EndActiveFrame_Action());
+        }
+    }
 
     public AnimationDataViewModel(DocumentViewModel document, DocumentInternalParts internals)
     {
@@ -24,4 +39,10 @@ internal class AnimationDataViewModel : ObservableObject, IAnimationHandler
         if (!Document.UpdateableChangeActive)
             Internals.ActionAccumulator.AddFinishedActions(new CreateRasterClip_Action(targetLayerGuid, frame, cloneFromExisting));
     }
+
+    public void SetActiveFrame(int newFrame)
+    {
+        _activeFrameBindable = newFrame;
+        OnPropertyChanged(nameof(ActiveFrameBindable));
+    }
 }

+ 1 - 1
src/PixiEditor.AvaloniaUI/ViewModels/Document/AnimationClipViewModel.cs → src/PixiEditor.AvaloniaUI/ViewModels/Document/KeyFrameViewModel.cs

@@ -2,7 +2,7 @@
 
 namespace PixiEditor.AvaloniaUI.ViewModels.Document;
 
-public class AnimationClipViewModel(int startFrame, int duration) : IClipHandler
+public class KeyFrameViewModel(int startFrame, int duration) : IKeyFrameHandler
 {
     public int StartFrame { get; } = startFrame;
     public int Duration { get; } = duration;

+ 2 - 2
src/PixiEditor.AvaloniaUI/ViewModels/Document/RasterClipViewModel.cs → src/PixiEditor.AvaloniaUI/ViewModels/Document/RasterKeyFrameViewModel.cs

@@ -2,11 +2,11 @@
 
 namespace PixiEditor.AvaloniaUI.ViewModels.Document;
 
-public class RasterClipViewModel : AnimationClipViewModel, IRasterClipHandler
+public class RasterKeyFrameViewModel : KeyFrameViewModel, IRasterKeyFrameHandler
 {
     public Guid TargetLayerGuid { get; }
     
-    public RasterClipViewModel(Guid targetLayerGuid, int startFrame, int duration) : base(startFrame, duration)
+    public RasterKeyFrameViewModel(Guid targetLayerGuid, int startFrame, int duration) : base(startFrame, duration)
     {
         TargetLayerGuid = targetLayerGuid;
     }

+ 42 - 1
src/PixiEditor.AvaloniaUI/ViewModels/SubViewModels/AnimationsViewModel.cs

@@ -22,7 +22,48 @@ internal class AnimationsViewModel : SubViewModel<ViewModelMain>
         
         activeDocument.AnimationDataViewModel.AddRasterClip(
             activeDocument.SelectedStructureMember.GuidValue, 
-            activeDocument.AnimationDataViewModel.Clips.Count,
+            activeDocument.AnimationDataViewModel.KeyFrames.Count,
             false);
     }
+    
+    [Command.Internal("PixiEditor.Document.StartChangeActiveFrame", CanExecute = "PixiEditor.HasDocument")]
+    public void StartChangeActiveFrame(int newActiveFrame)
+    {
+        if (Owner.DocumentManagerSubViewModel.ActiveDocument is null)
+            return;
+        
+        Owner.DocumentManagerSubViewModel.ActiveDocument.EventInlet.StartChangeActiveFrame();
+        Owner.DocumentManagerSubViewModel.ActiveDocument.Tools.UseActiveFrame(newActiveFrame);
+    }
+    
+    [Command.Internal("PixiEditor.Document.ChangeActiveFrame", CanExecute = "PixiEditor.HasDocument")]
+    public void ChangeActiveFrame(double newActiveFrame)
+    {
+        if (Owner.DocumentManagerSubViewModel.ActiveDocument is null)
+            return;
+        
+        int intNewActiveFrame = (int)newActiveFrame;
+        
+        Owner.DocumentManagerSubViewModel.ActiveDocument.EventInlet.ChangeActiveFrame(intNewActiveFrame);
+    }
+
+    [Command.Internal("PixiEditor.Document.EndChangeActiveFrame", CanExecute = "PixiEditor.HasDocument")]
+    public void EndChangeActiveFrame()
+    {
+        if (Owner.DocumentManagerSubViewModel.ActiveDocument is null)
+            return;
+        
+        Owner.DocumentManagerSubViewModel.ActiveDocument.EventInlet.EndChangeActiveFrame();
+    }
+    
+    [Command.Internal("PixiEditor.Animation.ActiveFrameSet")]
+    public void ActiveFrameSet(double value)
+    {
+        var document = Owner.DocumentManagerSubViewModel.ActiveDocument;
+        
+        if (document is null)
+            return;
+
+        document.Operations.SetActiveFrame((int)value);
+    }
 }

+ 109 - 7
src/PixiEditor.AvaloniaUI/Views/Animations/Timeline.cs

@@ -1,22 +1,124 @@
 using System.Collections.ObjectModel;
+using System.Windows.Input;
 using Avalonia;
 using Avalonia.Controls;
+using Avalonia.Controls.Metadata;
 using Avalonia.Controls.Primitives;
+using Avalonia.Input;
+using Avalonia.Interactivity;
+using Avalonia.Threading;
 using PixiEditor.AvaloniaUI.Models.Handlers;
 using PixiEditor.AvaloniaUI.ViewModels.Document;
 
 namespace PixiEditor.AvaloniaUI.Views.Animations;
 
+[TemplatePart("PART_PlayToggle", typeof(ToggleButton))]
 public class Timeline : TemplatedControl
 {
-    public static readonly StyledProperty<ObservableCollection<IClipHandler>> ClipsProperty =
-        AvaloniaProperty.Register<Timeline, ObservableCollection<IClipHandler>>(
-            nameof(Clips));
+    public static readonly StyledProperty<ObservableCollection<IKeyFrameHandler>> KeyFramesProperty =
+        AvaloniaProperty.Register<Timeline, ObservableCollection<IKeyFrameHandler>>(
+            nameof(KeyFrames));
 
-    public ObservableCollection<IClipHandler> Clips
+    public static readonly StyledProperty<int> ActiveFrameProperty =
+        AvaloniaProperty.Register<Timeline, int>(nameof(ActiveFrame));
+
+    public static readonly StyledProperty<bool> IsPlayingProperty = AvaloniaProperty.Register<Timeline, bool>(
+        nameof(IsPlaying));
+
+    public bool IsPlaying
     {
-        get => GetValue(ClipsProperty);
-        set => SetValue(ClipsProperty, value);
+        get => GetValue(IsPlayingProperty);
+        set => SetValue(IsPlayingProperty, value);
+    }
+
+    public ObservableCollection<IKeyFrameHandler> KeyFrames
+    {
+        get => GetValue(KeyFramesProperty);
+        set => SetValue(KeyFramesProperty, value);
+    }
+
+    public int ActiveFrame
+    {
+        get { return (int)GetValue(ActiveFrameProperty); }
+        set { SetValue(ActiveFrameProperty, value); }
+    }
+
+    private ToggleButton? _playToggle;
+    private DispatcherTimer _playTimer;
+
+    static Timeline()
+    {
+        IsPlayingProperty.Changed.Subscribe(IsPlayingChanged);
+    }
+
+    public Timeline()
+    {
+        _playTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(1000 / 60f) };
+        _playTimer.Tick += PlayTimerOnTick;
     }
-}
 
+    protected override void OnApplyTemplate(TemplateAppliedEventArgs e)
+    {
+        base.OnApplyTemplate(e);
+        _playToggle = e.NameScope.Find<ToggleButton>("PART_PlayToggle");
+        
+        if (_playToggle != null)
+        {
+            _playToggle.Click += PlayToggleOnClick;
+        }
+    }
+    
+    private void PlayTimerOnTick(object? sender, EventArgs e)
+    {
+        ActiveFrame++;
+        
+        if (ActiveFrame >= KeyFrames.Count)
+        {
+            ActiveFrame = 0;
+        }
+    }
+
+    private void PlayToggleOnClick(object? sender, RoutedEventArgs e)
+    {
+        if (sender is not ToggleButton toggleButton)
+        {
+            return;
+        }
+
+        if (toggleButton.IsChecked == true)
+        {
+            IsPlaying = true;
+        }
+        else
+        {
+            IsPlaying = false;
+        }
+    }
+    
+    public void Play()
+    {
+        IsPlaying = true;
+    }
+    
+    public void Pause()
+    {
+        IsPlaying = false;
+    }
+    
+    private static void IsPlayingChanged(AvaloniaPropertyChangedEventArgs e)
+    {
+        if (e.Sender is not Timeline timeline)
+        {
+            return;
+        }
+
+        if (timeline.IsPlaying)
+        {
+            timeline._playTimer.Start();
+        }
+        else
+        {
+            timeline._playTimer.Stop();
+        }
+    }
+}

+ 4 - 1
src/PixiEditor.AvaloniaUI/Views/Dock/TimelineDockView.axaml

@@ -5,11 +5,14 @@
              xmlns:document="clr-namespace:PixiEditor.AvaloniaUI.ViewModels.Document"
              xmlns:dock="clr-namespace:PixiEditor.AvaloniaUI.ViewModels.Dock"
              xmlns:animations="clr-namespace:PixiEditor.AvaloniaUI.Views.Animations"
+             xmlns:xaml="clr-namespace:PixiEditor.AvaloniaUI.Models.Commands.XAML"
              mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
              x:DataType="dock:TimelineDockViewModel"
              x:Class="PixiEditor.AvaloniaUI.Views.Dock.TimelineDockView">
     <Design.DataContext>
         <dock:TimelineDockViewModel />
     </Design.DataContext>
-    <animations:Timeline Clips="{Binding DocumentManagerSubViewModel.ActiveDocument.AnimationDataViewModel.Clips}"/>
+    <animations:Timeline 
+        KeyFrames="{Binding DocumentManagerSubViewModel.ActiveDocument.AnimationDataViewModel.KeyFrames}" 
+        ActiveFrame="{Binding DocumentManagerSubViewModel.ActiveDocument.AnimationDataViewModel.ActiveFrameBindable, Mode=TwoWay}"/>
 </UserControl>

+ 1 - 1
src/PixiEditor.AvaloniaUI/Views/Layers/LayersManager.axaml

@@ -110,7 +110,7 @@
                                 DragStarted="{xaml:Command PixiEditor.Layer.OpacitySliderDragStarted}"
                                 DragValueChanged="{xaml:Command PixiEditor.Layer.OpacitySliderDragged, UseProvided=True}"
                                 DragEnded="{xaml:Command PixiEditor.Layer.OpacitySliderDragEnded}"
-                                SetOpacity="{xaml:Command PixiEditor.Layer.OpacitySliderSet, UseProvided=True}"
+                                SetValueCommand="{xaml:Command PixiEditor.Layer.OpacitySliderSet, UseProvided=True}"
                                 ValueFromSlider="{Binding ElementName=opacitySlider, Path=Value, Mode=TwoWay}" />
                     </Interaction.Behaviors>
                 </Slider>

+ 3 - 0
src/PixiEditor.ChangeableDocument/ChangeInfos/Animation/ActiveFrame_ChangeInfo.cs

@@ -0,0 +1,3 @@
+namespace PixiEditor.ChangeableDocument.ChangeInfos.Animation;
+
+public record ActiveFrame_ChangeInfo(int ActiveFrame) : IChangeInfo;

+ 1 - 1
src/PixiEditor.ChangeableDocument/ChangeInfos/Animation/CreateRasterClip_ChangeInfo.cs → src/PixiEditor.ChangeableDocument/ChangeInfos/Animation/CreateRasterKeyFrame_ChangeInfo.cs

@@ -1,6 +1,6 @@
 namespace PixiEditor.ChangeableDocument.ChangeInfos.Animation;
 
-public record CreateRasterClip_ChangeInfo(
+public record CreateRasterKeyFrame_ChangeInfo(
     Guid TargetLayerGuid,
     int Frame,
     int IndexOfCreatedClip,

+ 1 - 1
src/PixiEditor.ChangeableDocument/ChangeInfos/Animation/DeleteClip_ChangeInfo.cs → src/PixiEditor.ChangeableDocument/ChangeInfos/Animation/DeleteKeyFrame_ChangeInfo.cs

@@ -1,5 +1,5 @@
 namespace PixiEditor.ChangeableDocument.ChangeInfos.Animation;
 
-public record DeleteClip_ChangeInfo(
+public record DeleteKeyFrame_ChangeInfo(
     int Frame,
     int IndexOfDeletedClip) : IChangeInfo;

+ 16 - 16
src/PixiEditor.ChangeableDocument/Changeables/Animations/AnimationData.cs

@@ -4,60 +4,60 @@ namespace PixiEditor.ChangeableDocument.Changeables.Animations;
 
 public class AnimationData : IReadOnlyAnimationData
 {
-    private int _currentFrame;
+    private int _activeFrame;
 
-    public int CurrentFrame
+    public int ActiveFrame
     {
-        get => _currentFrame;
+        get => _activeFrame;
         set
         {
             int lastFrame = value;
             if (value < 0)
             {
-                _currentFrame = 0;
+                _activeFrame = 0;
             }
             else
             {
-                _currentFrame = value;
+                _activeFrame = value;
             }
             
             OnPreviewFrameChanged(lastFrame);
         }
     }
     
-    public List<Clip> Clips { get; set; } = new List<Clip>();
-    IReadOnlyList<IReadOnlyClip> IReadOnlyAnimationData.Clips => Clips;
+    public List<KeyFrame> KeyFrames { get; set; } = new List<KeyFrame>();
+    IReadOnlyList<IReadOnlyKeyFrame> IReadOnlyAnimationData.KeyFrames => KeyFrames;
     
     public void ChangePreviewFrame(int frame)
     {
-        CurrentFrame = frame;
+        ActiveFrame = frame;
     }
     
     private void OnPreviewFrameChanged(int lastFrame)
     {
-        if (Clips == null)
+        if (KeyFrames == null)
         {
             return;
         }
         
-        foreach (var clip in Clips)
+        foreach (var keyFrame in KeyFrames)
         {
-            if (IsWithinRange(clip, CurrentFrame))
+            if (IsWithinRange(keyFrame, ActiveFrame))
             {
-                if (!IsWithinRange(clip, lastFrame))
+                if (!IsWithinRange(keyFrame, lastFrame))
                 {
-                    clip.Deactivated(CurrentFrame);
+                    keyFrame.Deactivated(ActiveFrame);
                 }
                 else
                 {
-                    clip.ActiveFrameChanged(CurrentFrame);   
+                    keyFrame.ActiveFrameChanged(ActiveFrame);   
                 }
             }
         }
     }
 
-    private bool IsWithinRange(Clip clip, int frame)
+    private bool IsWithinRange(KeyFrame keyFrame, int frame)
     {
-        return frame >= clip.StartFrame && frame < clip.StartFrame + clip.Duration;
+        return frame >= keyFrame.StartFrame && frame < keyFrame.StartFrame + keyFrame.Duration;
     }
 }

+ 1 - 1
src/PixiEditor.ChangeableDocument/Changeables/Animations/Clip.cs → src/PixiEditor.ChangeableDocument/Changeables/Animations/KeyFrame.cs

@@ -2,7 +2,7 @@
 
 namespace PixiEditor.ChangeableDocument.Changeables.Animations;
 
-public abstract class Clip : IReadOnlyClip
+public abstract class KeyFrame : IReadOnlyKeyFrame
 {
     public int StartFrame { get; set; }
     public int Duration { get; } = 1;

+ 2 - 2
src/PixiEditor.ChangeableDocument/Changeables/Animations/RasterClip.cs → src/PixiEditor.ChangeableDocument/Changeables/Animations/RasterKeyFrame.cs

@@ -1,6 +1,6 @@
 namespace PixiEditor.ChangeableDocument.Changeables.Animations;
 
-internal class RasterClip : Clip
+internal class RasterKeyFrame : KeyFrame
 {
     public Guid TargetLayerGuid { get; set; }
     public ChunkyImage Image { get; set; }
@@ -8,7 +8,7 @@ internal class RasterClip : Clip
     
     private ChunkyImage originalLayerImage;
 
-    public RasterClip(Guid targetLayerGuid, int startFrame, Document document, ChunkyImage? cloneFrom = null)
+    public RasterKeyFrame(Guid targetLayerGuid, int startFrame, Document document, ChunkyImage? cloneFrom = null)
     {
         TargetLayerGuid = targetLayerGuid;
         StartFrame = startFrame;

+ 2 - 2
src/PixiEditor.ChangeableDocument/Changeables/Interfaces/IReadOnlyAnimationData.cs

@@ -2,6 +2,6 @@
 
 public interface IReadOnlyAnimationData
 {
-    public int CurrentFrame { get; }
-    public IReadOnlyList<IReadOnlyClip> Clips { get; }
+    public int ActiveFrame { get; }
+    public IReadOnlyList<IReadOnlyKeyFrame> KeyFrames { get; }
 }

+ 1 - 1
src/PixiEditor.ChangeableDocument/Changeables/Interfaces/IReadOnlyClip.cs → src/PixiEditor.ChangeableDocument/Changeables/Interfaces/IReadOnlyKeyFrame.cs

@@ -1,6 +1,6 @@
 namespace PixiEditor.ChangeableDocument.Changeables.Interfaces;
 
-public interface IReadOnlyClip
+public interface IReadOnlyKeyFrame
 {
     public int StartFrame { get; }
     public int Duration { get; }

+ 51 - 0
src/PixiEditor.ChangeableDocument/Changes/Animation/ActiveFrame_UpdateableChange.cs

@@ -0,0 +1,51 @@
+using PixiEditor.ChangeableDocument.ChangeInfos.Animation;
+
+namespace PixiEditor.ChangeableDocument.Changes.Animation;
+
+internal class ActiveFrame_UpdateableChange : UpdateableChange
+{
+    private int newFrame;
+    private int originalFrame;
+    
+    [GenerateUpdateableChangeActions]
+
+    public ActiveFrame_UpdateableChange(int activeFrame)
+    {
+        newFrame = activeFrame;
+    }
+    
+    [UpdateChangeMethod]
+    public void Update(int activeFrame)
+    {
+        newFrame = activeFrame;
+    }
+    
+    public override bool InitializeAndValidate(Document target)
+    {
+        return true;
+    }
+    
+    public override OneOf<None, IChangeInfo, List<IChangeInfo>> ApplyTemporarily(Document target)
+    {
+        target.AnimationData.ActiveFrame = newFrame;
+        return new ActiveFrame_ChangeInfo(newFrame);
+    }
+
+    public override OneOf<None, IChangeInfo, List<IChangeInfo>> Apply(Document target, bool firstApply, out bool ignoreInUndo)
+    {
+        ignoreInUndo = originalFrame == newFrame;
+        target.AnimationData.ActiveFrame = newFrame;
+        return new ActiveFrame_ChangeInfo(newFrame);
+    }
+
+    public override OneOf<None, IChangeInfo, List<IChangeInfo>> Revert(Document target)
+    {
+        target.AnimationData.ActiveFrame = originalFrame;
+        return new ActiveFrame_ChangeInfo(originalFrame);
+    }
+
+    public override bool IsMergeableWith(Change other)
+    {
+        return other is ActiveFrame_UpdateableChange;
+    }
+}

+ 5 - 5
src/PixiEditor.ChangeableDocument/Changes/Animation/CreateRasterClip_Change.cs

@@ -26,16 +26,16 @@ internal class CreateRasterClip_Change : Change
 
     public override OneOf<None, IChangeInfo, List<IChangeInfo>> Apply(Document target, bool firstApply, out bool ignoreInUndo)
     {
-        indexOfCreatedClip = target.AnimationData.Clips.Count;
-        target.AnimationData.Clips.Add(new RasterClip(_targetLayerGuid, _frame, target, _cloneFromExisting ? _layer.LayerImage : null));
+        indexOfCreatedClip = target.AnimationData.KeyFrames.Count;
+        target.AnimationData.KeyFrames.Add(new RasterKeyFrame(_targetLayerGuid, _frame, target, _cloneFromExisting ? _layer.LayerImage : null));
         target.AnimationData.ChangePreviewFrame(_frame);
         ignoreInUndo = false;
-        return new CreateRasterClip_ChangeInfo(_targetLayerGuid, _frame, indexOfCreatedClip, _cloneFromExisting);
+        return new CreateRasterKeyFrame_ChangeInfo(_targetLayerGuid, _frame, indexOfCreatedClip, _cloneFromExisting);
     }
 
     public override OneOf<None, IChangeInfo, List<IChangeInfo>> Revert(Document target)
     {
-        target.AnimationData.Clips.RemoveAt(indexOfCreatedClip);
-        return new DeleteClip_ChangeInfo(_frame, indexOfCreatedClip);
+        target.AnimationData.KeyFrames.RemoveAt(indexOfCreatedClip);
+        return new DeleteKeyFrame_ChangeInfo(_frame, indexOfCreatedClip);
     }
 }