1
0
mirror of https://github.com/Artemis-RGB/Artemis synced 2025-12-13 05:48:35 +00:00

Profile editor - Ported time caret

This commit is contained in:
Robert 2022-01-16 17:29:26 +01:00
parent 1832a25426
commit 098c44ebc8
9 changed files with 325 additions and 37 deletions

View File

@ -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
/// <param name="time">The new time.</param>
void ChangeTime(TimeSpan time);
/// <summary>
/// Snaps the given time to the closest relevant element in the timeline, this can be the cursor, a keyframe or a segment end.
/// </summary>
/// <param name="time">The time to snap.</param>
/// <param name="tolerance">How close the time must be to snap.</param>
/// <param name="snapToSegments">Enable snapping to timeline segments.</param>
/// <param name="snapToCurrentTime">Enable snapping to the current time of the editor.</param>
/// <param name="snapTimes">An optional extra list of times to snap to.</param>
/// <returns>The snapped time.</returns>
TimeSpan SnapToTimeline(TimeSpan time, TimeSpan tolerance, bool snapToSegments, bool snapToCurrentTime, List<TimeSpan>? snapTimes = null);
/// <summary>
/// Executes the provided command and adds it to the history.
/// </summary>

View File

@ -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<TimeSpan>? 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);

View File

@ -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">
<Grid ColumnDefinitions="*,Auto,*" Name="ContainerGrid">
<Grid RowDefinitions="48,*">
<ContentControl Grid.Row="0" Content="{Binding PlaybackViewModel}"></ContentControl>
<Grid ColumnDefinitions="*,Auto,*" RowDefinitions="48,*">
<ContentControl Grid.Row="0" Content="{Binding PlaybackViewModel}"></ContentControl>
<ScrollViewer Grid.Row="1"
Grid.Column="0"
Name="TreeScrollViewer"
Offset="{Binding #TimelineScrollViewer.Offset, Mode=OneWay}"
Background="{DynamicResource CardStrokeColorDefaultSolidBrush}">
<ItemsControl Items="{Binding PropertyGroupViewModels}" Padding="0 0 8 0">
<ItemsControl.ItemTemplate>
<TreeDataTemplate DataType="{x:Type local:ProfileElementPropertyGroupViewModel}" ItemsSource="{Binding Children}">
<ContentControl Content="{Binding TreeGroupViewModel}" />
</TreeDataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</Grid>
<GridSplitter Grid.Row="0" Grid.Column="1" Cursor="SizeWestEast" Foreground="Transparent" Background="Transparent" />
<GridSplitter Grid.Column="1"
Cursor="SizeWestEast"
Foreground="Transparent"
Background="Transparent"
Margin="0 0 -5 0"/>
<ScrollViewer Grid.Row="1"
Grid.Column="0"
Name="TreeScrollViewer"
Offset="{Binding #TimelineScrollViewer.Offset, Mode=OneWay}"
Background="{DynamicResource CardStrokeColorDefaultSolidBrush}">
<ItemsControl Items="{Binding PropertyGroupViewModels}" Padding="0 0 8 0">
<ItemsControl.ItemTemplate>
<TreeDataTemplate DataType="{x:Type local:ProfileElementPropertyGroupViewModel}" ItemsSource="{Binding Children}">
<ContentControl Content="{Binding TreeGroupViewModel}" />
</TreeDataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
<Grid Grid.Column="2" RowDefinitions="48,*">
<Canvas Grid.Row="0" ZIndex="2">
<!-- Timeline segments -->
<ContentControl Canvas.Left="{Binding EndTimelineSegmentViewModel.SegmentStartPosition}" Content="{Binding EndTimelineSegmentViewModel}" />
<ContentControl Canvas.Left="{Binding MainTimelineSegmentViewModel.SegmentStartPosition}" Content="{Binding MainTimelineSegmentViewModel}" />
<ContentControl Canvas.Left="{Binding StartTimelineSegmentViewModel.SegmentStartPosition}" Content="{Binding StartTimelineSegmentViewModel}" />
<!-- Timeline header body -->
<!-- <controls:PropertyTimelineHeader Margin="0 18 0 0" -->
<!-- Foreground="{DynamicResource MaterialDesignBody}" -->
<!-- Background="{DynamicResource MaterialDesignCardBackground}" -->
<!-- PixelsPerSecond="{Binding ProfileEditorService.PixelsPerSecond}" -->
<!-- HorizontalOffset="{Binding ContentHorizontalOffset, ElementName=TimelineHeaderScrollViewer}" -->
<!-- VisibleWidth="{Binding ActualWidth, ElementName=TimelineHeaderScrollViewer}" -->
<!-- OffsetFirstValue="True" -->
<!-- MouseLeftButtonUp="{s:Action TimelineJump}" -->
<!-- Width="{Binding ActualWidth, ElementName=PropertyTimeLine}" -->
<!-- Cursor="Hand" /> -->
<!-- Timeline caret -->
<Polygon Name="TimelineCaret"
Canvas.Left="{Binding TimelineViewModel.CaretPosition}"
Cursor="SizeWestEast"
PointerPressed="TimelineCaret_OnPointerPressed"
PointerReleased="TimelineCaret_OnPointerReleased"
PointerMoved="TimelineCaret_OnPointerMoved"
Points="-8,0 -8,8 0,20, 8,8 8,0"
Fill="{DynamicResource SystemAccentColorLight1}">
<Polygon.Transitions>
<Transitions>
<DoubleTransition Property="Canvas.Left" Duration="0.05"></DoubleTransition>
</Transitions>
</Polygon.Transitions>
</Polygon>
<Line Name="TimelineLine"
Canvas.Left="{Binding TimelineViewModel.CaretPosition}"
Cursor="SizeWestEast"
PointerPressed="TimelineCaret_OnPointerPressed"
PointerReleased="TimelineCaret_OnPointerReleased"
PointerMoved="TimelineCaret_OnPointerMoved"
StartPoint="0,0"
EndPoint="0,1"
StrokeThickness="2"
Stroke="{DynamicResource SystemAccentColorLight1}"
RenderTransformOrigin="0,0">
<Line.Transitions>
<Transitions>
<DoubleTransition Property="Canvas.Left" Duration="0.05"></DoubleTransition>
</Transitions>
</Line.Transitions>
<Line.RenderTransform>
<ScaleTransform ScaleX="1" ScaleY="{Binding #ContainerGrid.Bounds.Height}"></ScaleTransform>
</Line.RenderTransform>
</Line>
</Canvas>
<!-- Horizontal scrolling -->
<ScrollViewer Grid.Row="1">
<ScrollViewer Name="TimelineScrollViewer"
Offset="{Binding #TreeScrollViewer.Offset, Mode=OneWay}"
VerticalScrollBarVisibility="Hidden"
Background="{DynamicResource CardStrokeColorDefaultSolidBrush}">
<ContentControl Content="{Binding TimelineViewModel}"></ContentControl>
</ScrollViewer>
</ScrollViewer>
<!-- TODO: Databindings here -->
</Grid>
<GridSplitter Grid.Row="1" Grid.Column="1" Cursor="SizeWestEast" Foreground="Transparent" Background="{DynamicResource CardStrokeColorDefaultSolidBrush}" />
<ScrollViewer Grid.Row="1"
Grid.Column="2"
Name="TimelineScrollViewer"
Offset="{Binding #TreeScrollViewer.Offset, Mode=OneWay}"
VerticalScrollBarVisibility="Hidden"
Background="{DynamicResource CardStrokeColorDefaultSolidBrush}">
<ItemsControl Items="{Binding PropertyGroupViewModels}">
<ItemsControl.ItemTemplate>
<TreeDataTemplate DataType="{x:Type local:ProfileElementPropertyGroupViewModel}" ItemsSource="{Binding Children}">
<ContentControl Content="{Binding TreeGroupViewModel}" />
</TreeDataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</Grid>
</UserControl>

View File

@ -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<ProfileElementPropertiesViewModel>
{
private Polygon _timelineCaret;
private Line _timelineLine;
public ProfileElementPropertiesView()
{
InitializeComponent();
@ -13,5 +24,69 @@ public class ProfileElementPropertiesView : ReactiveUserControl<ProfileElementPr
private void InitializeComponent()
{
AvaloniaXamlLoader.Load(this);
_timelineCaret = this.Get<Polygon>("TimelineCaret");
_timelineLine = this.Get<Line>("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<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);
}
// 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);
}
}

View File

@ -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<LayerPropertyGroup, ProfileElementPropertyGroupViewModel> _cachedViewModels;
private readonly IProfileEditorService _profileEditorService;
private readonly ILayerPropertyVmFactory _layerPropertyVmFactory;
private ObservableAsPropertyHelper<RenderProfileElement?>? _profileElement;
private ObservableAsPropertyHelper<double>? _pixelsPerSecond;
private ObservableCollection<ProfileElementPropertyGroupViewModel> _propertyGroupViewModels;
/// <inheritdoc />
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<ProfileElementPropertyGroupViewModel>();
_cachedViewModels = new Dictionary<LayerPropertyGroup, ProfileElementPropertyGroupViewModel>();
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<bool> Playing => _profileEditorService.Playing;
public ObservableCollection<ProfileElementPropertyGroupViewModel> PropertyGroupViewModels
{
get => _propertyGroupViewModels;
set => this.RaiseAndSetIfChanged(ref _propertyGroupViewModels, value);
}
private void UpdateGroups()
{
if (ProfileElement == null)

View File

@ -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<ITimelineKeyframeViewModel> GetAllKeyframeViewModels(bool expandedOnly)
{
List<ITimelineKeyframeViewModel> 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

View File

@ -0,0 +1,15 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:local="clr-namespace:Artemis.UI.Screens.ProfileEditor.ProfileElementProperties"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="Artemis.UI.Screens.ProfileEditor.ProfileElementProperties.Timeline.TimelineView">
<ItemsControl Items="{Binding PropertyGroupViewModels}" Padding="0 0 8 0">
<ItemsControl.ItemTemplate>
<TreeDataTemplate DataType="{x:Type local:ProfileElementPropertyGroupViewModel}" ItemsSource="{Binding Children}">
<ContentControl Content="{Binding TimelineGroupViewModel}" />
</TreeDataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</UserControl>

View File

@ -0,0 +1,18 @@
using Avalonia.Markup.Xaml;
using Avalonia.ReactiveUI;
namespace Artemis.UI.Screens.ProfileEditor.ProfileElementProperties.Timeline
{
public partial class TimelineView : ReactiveUserControl<TimelineViewModel>
{
public TimelineView()
{
InitializeComponent();
}
private void InitializeComponent()
{
AvaloniaXamlLoader.Load(this);
}
}
}

View File

@ -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<double>? _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<TimeSpan>? snapTimes = null)
{
return _profileEditorService.SnapToTimeline(time, tolerance, snapToSegments, snapToCurrentTime, snapTimes);
}
}