diff --git a/src/Avalonia/Artemis.UI.Shared/Services/ProfileEditor/IProfileEditorService.cs b/src/Avalonia/Artemis.UI.Shared/Services/ProfileEditor/IProfileEditorService.cs
index aaf57c0be..bef5cab0d 100644
--- a/src/Avalonia/Artemis.UI.Shared/Services/ProfileEditor/IProfileEditorService.cs
+++ b/src/Avalonia/Artemis.UI.Shared/Services/ProfileEditor/IProfileEditorService.cs
@@ -1,4 +1,5 @@
using System;
+using System.Collections.Generic;
using System.Threading.Tasks;
using Artemis.Core;
using Artemis.UI.Shared.Services.Interfaces;
@@ -59,6 +60,17 @@ public interface IProfileEditorService : IArtemisSharedUIService
/// The new time.
void ChangeTime(TimeSpan time);
+ ///
+ /// Snaps the given time to the closest relevant element in the timeline, this can be the cursor, a keyframe or a segment end.
+ ///
+ /// The time to snap.
+ /// How close the time must be to snap.
+ /// Enable snapping to timeline segments.
+ /// Enable snapping to the current time of the editor.
+ /// An optional extra list of times to snap to.
+ /// The snapped time.
+ TimeSpan SnapToTimeline(TimeSpan time, TimeSpan tolerance, bool snapToSegments, bool snapToCurrentTime, List? snapTimes = null);
+
///
/// Executes the provided command and adds it to the history.
///
diff --git a/src/Avalonia/Artemis.UI.Shared/Services/ProfileEditor/ProfileEditorService.cs b/src/Avalonia/Artemis.UI.Shared/Services/ProfileEditor/ProfileEditorService.cs
index ec9d5f05e..bd3663872 100644
--- a/src/Avalonia/Artemis.UI.Shared/Services/ProfileEditor/ProfileEditorService.cs
+++ b/src/Avalonia/Artemis.UI.Shared/Services/ProfileEditor/ProfileEditorService.cs
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
+using System.Linq;
using System.Reactive.Linq;
using System.Reactive.Subjects;
using System.Threading.Tasks;
@@ -110,6 +111,42 @@ internal class ProfileEditorService : IProfileEditorService
_timeSubject.OnNext(time);
}
+ public TimeSpan SnapToTimeline(TimeSpan time, TimeSpan tolerance, bool snapToSegments, bool snapToCurrentTime, List? snapTimes = null)
+ {
+ RenderProfileElement? profileElement = _profileElementSubject.Value;
+ if (snapToSegments && profileElement != null)
+ {
+ // Snap to the end of the start segment
+ if (Math.Abs(time.TotalMilliseconds - profileElement.Timeline.StartSegmentEndPosition.TotalMilliseconds) < tolerance.TotalMilliseconds)
+ return profileElement.Timeline.StartSegmentEndPosition;
+
+ // Snap to the end of the main segment
+ if (Math.Abs(time.TotalMilliseconds - profileElement.Timeline.MainSegmentEndPosition.TotalMilliseconds) < tolerance.TotalMilliseconds)
+ return profileElement.Timeline.MainSegmentEndPosition;
+
+ // Snap to the end of the end segment (end of the timeline)
+ if (Math.Abs(time.TotalMilliseconds - profileElement.Timeline.EndSegmentEndPosition.TotalMilliseconds) < tolerance.TotalMilliseconds)
+ return profileElement.Timeline.EndSegmentEndPosition;
+ }
+
+ // Snap to the current time
+ if (snapToCurrentTime)
+ {
+ if (Math.Abs(time.TotalMilliseconds - _timeSubject.Value.TotalMilliseconds) < tolerance.TotalMilliseconds)
+ return _timeSubject.Value;
+ }
+
+ if (snapTimes != null)
+ {
+ // Find the closest keyframe
+ TimeSpan closeSnapTime = snapTimes.FirstOrDefault(s => Math.Abs(time.TotalMilliseconds - s.TotalMilliseconds) < tolerance.TotalMilliseconds)!;
+ if (closeSnapTime != TimeSpan.Zero)
+ return closeSnapTime;
+ }
+
+ return time;
+ }
+
public void ChangePixelsPerSecond(double pixelsPerSecond)
{
_pixelsPerSecondSubject.OnNext(pixelsPerSecond);
diff --git a/src/Avalonia/Artemis.UI/Screens/ProfileEditor/Panels/ProfileElementProperties/ProfileElementPropertiesView.axaml b/src/Avalonia/Artemis.UI/Screens/ProfileEditor/Panels/ProfileElementProperties/ProfileElementPropertiesView.axaml
index de14a7b71..8e22176a4 100644
--- a/src/Avalonia/Artemis.UI/Screens/ProfileEditor/Panels/ProfileElementProperties/ProfileElementPropertiesView.axaml
+++ b/src/Avalonia/Artemis.UI/Screens/ProfileEditor/Panels/ProfileElementProperties/ProfileElementPropertiesView.axaml
@@ -6,42 +6,103 @@
xmlns:avalonia="clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="Artemis.UI.Screens.ProfileEditor.ProfileElementProperties.ProfileElementPropertiesView">
+
+
+
-
-
+
+
+
+
+
+
+
+
+
+
-
+
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/src/Avalonia/Artemis.UI/Screens/ProfileEditor/Panels/ProfileElementProperties/ProfileElementPropertiesView.axaml.cs b/src/Avalonia/Artemis.UI/Screens/ProfileEditor/Panels/ProfileElementProperties/ProfileElementPropertiesView.axaml.cs
index 69bd34970..890460ebc 100644
--- a/src/Avalonia/Artemis.UI/Screens/ProfileEditor/Panels/ProfileElementProperties/ProfileElementPropertiesView.axaml.cs
+++ b/src/Avalonia/Artemis.UI/Screens/ProfileEditor/Panels/ProfileElementProperties/ProfileElementPropertiesView.axaml.cs
@@ -1,10 +1,21 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Avalonia.Animation;
+using Avalonia.Controls;
+using Avalonia.Controls.Shapes;
+using Avalonia.Input;
using Avalonia.Markup.Xaml;
using Avalonia.ReactiveUI;
+using Avalonia.VisualTree;
namespace Artemis.UI.Screens.ProfileEditor.ProfileElementProperties;
public class ProfileElementPropertiesView : ReactiveUserControl
{
+ private Polygon _timelineCaret;
+ private Line _timelineLine;
+
public ProfileElementPropertiesView()
{
InitializeComponent();
@@ -13,5 +24,69 @@ public class ProfileElementPropertiesView : ReactiveUserControl("TimelineCaret");
+ _timelineLine = this.Get("TimelineLine");
+ }
+
+ private void ApplyTransition(bool enable)
+ {
+ if (enable)
+ {
+ ((DoubleTransition) _timelineCaret.Transitions![0]).Duration = TimeSpan.FromMilliseconds(50);
+ ((DoubleTransition) _timelineLine.Transitions![0]).Duration = TimeSpan.FromMilliseconds(50);
+ }
+ else
+ {
+ ((DoubleTransition) _timelineCaret.Transitions![0]).Duration = TimeSpan.Zero;
+ ((DoubleTransition) _timelineLine.Transitions![0]).Duration = TimeSpan.Zero;
+ }
+ }
+
+ private void TimelineCaret_OnPointerPressed(object? sender, PointerPressedEventArgs e)
+ {
+ e.Pointer.Capture((IInputElement?) sender);
+ ApplyTransition(false);
+ }
+
+ private void TimelineCaret_OnPointerReleased(object? sender, PointerReleasedEventArgs e)
+ {
+ e.Pointer.Capture(null);
+ ApplyTransition(true);
+ }
+
+ private void TimelineCaret_OnPointerMoved(object? sender, PointerEventArgs e)
+ {
+ if (!e.GetCurrentPoint(this).Properties.IsLeftButtonPressed || ViewModel == null)
+ return;
+
+ IInputElement? senderElement = (IInputElement?) sender;
+ if (senderElement == null)
+ return;
+
+ // Get the parent grid, need that for our position
+ IVisual? parent = senderElement.VisualParent;
+ double x = Math.Max(0, e.GetPosition(parent).X);
+ TimeSpan newTime = TimeSpan.FromSeconds(x / ViewModel.PixelsPerSecond);
+
+ // Round the time to something that fits the current zoom level
+ if (ViewModel.PixelsPerSecond < 200)
+ newTime = TimeSpan.FromMilliseconds(Math.Round(newTime.TotalMilliseconds / 5.0) * 5.0);
+ else if (ViewModel.PixelsPerSecond < 500)
+ newTime = TimeSpan.FromMilliseconds(Math.Round(newTime.TotalMilliseconds / 2.0) * 2.0);
+ else
+ newTime = TimeSpan.FromMilliseconds(Math.Round(newTime.TotalMilliseconds));
+
+ // If holding down shift, snap to the closest segment or keyframe
+ if (e.KeyModifiers.HasFlag(KeyModifiers.Shift))
+ {
+ List 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);
+ }
+
+ // If holding down control, round to the closest 50ms
+ if (e.KeyModifiers.HasFlag(KeyModifiers.Control))
+ newTime = TimeSpan.FromMilliseconds(Math.Round(newTime.TotalMilliseconds / 50.0) * 50.0);
+
+ ViewModel.TimelineViewModel.ChangeTime(newTime);
}
}
\ No newline at end of file
diff --git a/src/Avalonia/Artemis.UI/Screens/ProfileEditor/Panels/ProfileElementProperties/ProfileElementPropertiesViewModel.cs b/src/Avalonia/Artemis.UI/Screens/ProfileEditor/Panels/ProfileElementProperties/ProfileElementPropertiesViewModel.cs
index 5e396dd9b..7a4e968cb 100644
--- a/src/Avalonia/Artemis.UI/Screens/ProfileEditor/Panels/ProfileElementProperties/ProfileElementPropertiesViewModel.cs
+++ b/src/Avalonia/Artemis.UI/Screens/ProfileEditor/Panels/ProfileElementProperties/ProfileElementPropertiesViewModel.cs
@@ -10,6 +10,7 @@ using Artemis.Core.LayerBrushes;
using Artemis.Core.LayerEffects;
using Artemis.UI.Ninject.Factories;
using Artemis.UI.Screens.ProfileEditor.Playback;
+using Artemis.UI.Screens.ProfileEditor.ProfileElementProperties.Timeline;
using Artemis.UI.Shared;
using Artemis.UI.Shared.Services.ProfileEditor;
using ReactiveUI;
@@ -19,17 +20,21 @@ namespace Artemis.UI.Screens.ProfileEditor.ProfileElementProperties;
public class ProfileElementPropertiesViewModel : ActivatableViewModelBase
{
private readonly Dictionary _cachedViewModels;
+ private readonly IProfileEditorService _profileEditorService;
private readonly ILayerPropertyVmFactory _layerPropertyVmFactory;
private ObservableAsPropertyHelper? _profileElement;
+ private ObservableAsPropertyHelper? _pixelsPerSecond;
private ObservableCollection _propertyGroupViewModels;
///
- public ProfileElementPropertiesViewModel(IProfileEditorService profileEditorService, ILayerPropertyVmFactory layerPropertyVmFactory, PlaybackViewModel playbackViewModel)
+ public ProfileElementPropertiesViewModel(IProfileEditorService profileEditorService, ILayerPropertyVmFactory layerPropertyVmFactory, PlaybackViewModel playbackViewModel, TimelineViewModel timelineViewModel)
{
+ _profileEditorService = profileEditorService;
_layerPropertyVmFactory = layerPropertyVmFactory;
_propertyGroupViewModels = new ObservableCollection();
_cachedViewModels = new Dictionary();
PlaybackViewModel = playbackViewModel;
+ TimelineViewModel = timelineViewModel;
// Subscribe to events of the latest selected profile element - borrowed from https://stackoverflow.com/a/63950940
this.WhenAnyValue(vm => vm.ProfileElement)
@@ -46,20 +51,27 @@ public class ProfileElementPropertiesViewModel : ActivatableViewModelBase
.Subscribe(_ => UpdateGroups());
// React to service profile element changes as long as the VM is active
- this.WhenActivated(d => _profileElement = profileEditorService.ProfileElement.ToProperty(this, vm => vm.ProfileElement).DisposeWith(d));
+ this.WhenActivated(d =>
+ {
+ _profileElement = profileEditorService.ProfileElement.ToProperty(this, vm => vm.ProfileElement).DisposeWith(d);
+ _pixelsPerSecond = profileEditorService.PixelsPerSecond.ToProperty(this, vm => vm.PixelsPerSecond).DisposeWith(d);
+ });
this.WhenAnyValue(vm => vm.ProfileElement).Subscribe(_ => UpdateGroups());
}
public PlaybackViewModel PlaybackViewModel { get; }
+ public TimelineViewModel TimelineViewModel { get; }
public RenderProfileElement? ProfileElement => _profileElement?.Value;
public Layer? Layer => _profileElement?.Value as Layer;
+ public double PixelsPerSecond => _pixelsPerSecond?.Value ?? 0;
+ public IObservable Playing => _profileEditorService.Playing;
public ObservableCollection PropertyGroupViewModels
{
get => _propertyGroupViewModels;
set => this.RaiseAndSetIfChanged(ref _propertyGroupViewModels, value);
}
-
+
private void UpdateGroups()
{
if (ProfileElement == null)
diff --git a/src/Avalonia/Artemis.UI/Screens/ProfileEditor/Panels/ProfileElementProperties/ProfileElementPropertyGroupViewModel.cs b/src/Avalonia/Artemis.UI/Screens/ProfileEditor/Panels/ProfileElementProperties/ProfileElementPropertyGroupViewModel.cs
index a3b6426a3..03e34276a 100644
--- a/src/Avalonia/Artemis.UI/Screens/ProfileEditor/Panels/ProfileElementProperties/ProfileElementPropertyGroupViewModel.cs
+++ b/src/Avalonia/Artemis.UI/Screens/ProfileEditor/Panels/ProfileElementProperties/ProfileElementPropertyGroupViewModel.cs
@@ -1,4 +1,5 @@
using System;
+using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Reflection;
@@ -6,6 +7,7 @@ using Artemis.Core;
using Artemis.Core.LayerBrushes;
using Artemis.Core.LayerEffects;
using Artemis.UI.Ninject.Factories;
+using Artemis.UI.Screens.ProfileEditor.ProfileElementProperties.Timeline;
using Artemis.UI.Screens.ProfileEditor.ProfileElementProperties.Tree;
using Artemis.UI.Shared;
using Artemis.UI.Shared.Services.PropertyInput;
@@ -35,13 +37,15 @@ public class ProfileElementPropertyGroupViewModel : ViewModelBase
PopulateChildren();
}
- public ProfileElementPropertyGroupViewModel(LayerPropertyGroup layerPropertyGroup, ILayerPropertyVmFactory layerPropertyVmFactory, IPropertyInputService propertyInputService, BaseLayerBrush layerBrush)
+ public ProfileElementPropertyGroupViewModel(LayerPropertyGroup layerPropertyGroup, ILayerPropertyVmFactory layerPropertyVmFactory, IPropertyInputService propertyInputService,
+ BaseLayerBrush layerBrush)
: this(layerPropertyGroup, layerPropertyVmFactory, propertyInputService)
{
LayerBrush = layerBrush;
}
-
- public ProfileElementPropertyGroupViewModel(LayerPropertyGroup layerPropertyGroup, ILayerPropertyVmFactory layerPropertyVmFactory, IPropertyInputService propertyInputService, BaseLayerEffect layerEffect)
+
+ public ProfileElementPropertyGroupViewModel(LayerPropertyGroup layerPropertyGroup, ILayerPropertyVmFactory layerPropertyVmFactory, IPropertyInputService propertyInputService,
+ BaseLayerEffect layerEffect)
: this(layerPropertyGroup, layerPropertyVmFactory, propertyInputService)
{
LayerEffect = layerEffect;
@@ -72,6 +76,21 @@ public class ProfileElementPropertyGroupViewModel : ViewModelBase
set => this.RaiseAndSetIfChanged(ref _hasChildren, value);
}
+ public List GetAllKeyframeViewModels(bool expandedOnly)
+ {
+ List result = new();
+ if (expandedOnly && !IsExpanded)
+ return result;
+
+ foreach (ViewModelBase child in Children)
+ if (child is ProfileElementPropertyViewModel profileElementPropertyViewModel)
+ result.AddRange(profileElementPropertyViewModel.TimelinePropertyViewModel.GetAllKeyframeViewModels());
+ else if (child is ProfileElementPropertyGroupViewModel profileElementPropertyGroupViewModel)
+ result.AddRange(profileElementPropertyGroupViewModel.GetAllKeyframeViewModels(expandedOnly));
+
+ return result;
+ }
+
private void PopulateChildren()
{
// Get all properties and property groups and create VMs for them
diff --git a/src/Avalonia/Artemis.UI/Screens/ProfileEditor/Panels/ProfileElementProperties/Timeline/TimelineView.axaml b/src/Avalonia/Artemis.UI/Screens/ProfileEditor/Panels/ProfileElementProperties/Timeline/TimelineView.axaml
new file mode 100644
index 000000000..f02e22ad9
--- /dev/null
+++ b/src/Avalonia/Artemis.UI/Screens/ProfileEditor/Panels/ProfileElementProperties/Timeline/TimelineView.axaml
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+
+
diff --git a/src/Avalonia/Artemis.UI/Screens/ProfileEditor/Panels/ProfileElementProperties/Timeline/TimelineView.axaml.cs b/src/Avalonia/Artemis.UI/Screens/ProfileEditor/Panels/ProfileElementProperties/Timeline/TimelineView.axaml.cs
new file mode 100644
index 000000000..276fb2637
--- /dev/null
+++ b/src/Avalonia/Artemis.UI/Screens/ProfileEditor/Panels/ProfileElementProperties/Timeline/TimelineView.axaml.cs
@@ -0,0 +1,18 @@
+using Avalonia.Markup.Xaml;
+using Avalonia.ReactiveUI;
+
+namespace Artemis.UI.Screens.ProfileEditor.ProfileElementProperties.Timeline
+{
+ public partial class TimelineView : ReactiveUserControl
+ {
+ public TimelineView()
+ {
+ InitializeComponent();
+ }
+
+ private void InitializeComponent()
+ {
+ AvaloniaXamlLoader.Load(this);
+ }
+ }
+}
diff --git a/src/Avalonia/Artemis.UI/Screens/ProfileEditor/Panels/ProfileElementProperties/Timeline/TimelineViewModel.cs b/src/Avalonia/Artemis.UI/Screens/ProfileEditor/Panels/ProfileElementProperties/Timeline/TimelineViewModel.cs
new file mode 100644
index 000000000..d36490fca
--- /dev/null
+++ b/src/Avalonia/Artemis.UI/Screens/ProfileEditor/Panels/ProfileElementProperties/Timeline/TimelineViewModel.cs
@@ -0,0 +1,39 @@
+using System;
+using System.Collections.Generic;
+using System.Reactive.Linq;
+using Artemis.UI.Shared;
+using Artemis.UI.Shared.Services.ProfileEditor;
+using Avalonia.Controls.Mixins;
+using ReactiveUI;
+
+namespace Artemis.UI.Screens.ProfileEditor.ProfileElementProperties.Timeline;
+
+public class TimelineViewModel : ActivatableViewModelBase
+{
+ private readonly IProfileEditorService _profileEditorService;
+ private ObservableAsPropertyHelper? _caretPosition;
+
+ public TimelineViewModel(IProfileEditorService profileEditorService)
+ {
+ _profileEditorService = profileEditorService;
+ this.WhenActivated(d =>
+ {
+ _caretPosition = _profileEditorService.Time
+ .CombineLatest(_profileEditorService.PixelsPerSecond, (t, p) => t.TotalSeconds * p)
+ .ToProperty(this, vm => vm.CaretPosition)
+ .DisposeWith(d);
+ });
+ }
+
+ public double CaretPosition => _caretPosition?.Value ?? 0.0;
+
+ public void ChangeTime(TimeSpan newTime)
+ {
+ _profileEditorService.ChangeTime(newTime);
+ }
+
+ public TimeSpan SnapToTimeline(TimeSpan time, TimeSpan tolerance, bool snapToSegments, bool snapToCurrentTime, List? snapTimes = null)
+ {
+ return _profileEditorService.SnapToTimeline(time, tolerance, snapToSegments, snapToCurrentTime, snapTimes);
+ }
+}
\ No newline at end of file