From 098c44ebc84460e5264bb2bdd182d1f623947c98 Mon Sep 17 00:00:00 2001 From: Robert Date: Sun, 16 Jan 2022 17:29:26 +0100 Subject: [PATCH] Profile editor - Ported time caret --- .../ProfileEditor/IProfileEditorService.cs | 12 ++ .../ProfileEditor/ProfileEditorService.cs | 37 ++++++ .../ProfileElementPropertiesView.axaml | 123 +++++++++++++----- .../ProfileElementPropertiesView.axaml.cs | 75 +++++++++++ .../ProfileElementPropertiesViewModel.cs | 18 ++- .../ProfileElementPropertyGroupViewModel.cs | 25 +++- .../Timeline/TimelineView.axaml | 15 +++ .../Timeline/TimelineView.axaml.cs | 18 +++ .../Timeline/TimelineViewModel.cs | 39 ++++++ 9 files changed, 325 insertions(+), 37 deletions(-) create mode 100644 src/Avalonia/Artemis.UI/Screens/ProfileEditor/Panels/ProfileElementProperties/Timeline/TimelineView.axaml create mode 100644 src/Avalonia/Artemis.UI/Screens/ProfileEditor/Panels/ProfileElementProperties/Timeline/TimelineView.axaml.cs create mode 100644 src/Avalonia/Artemis.UI/Screens/ProfileEditor/Panels/ProfileElementProperties/Timeline/TimelineViewModel.cs 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