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

Profile editor - Ported timeline scaling, timeline header

This commit is contained in:
Robert 2022-01-17 09:39:34 +01:00
parent 098c44ebc8
commit 66a2e51979
8 changed files with 276 additions and 59 deletions

View File

@ -39,8 +39,7 @@ public interface IProfileEditorService : IArtemisSharedUIService
/// <summary>
/// Gets an observable of the zoom level.
/// </summary>
IObservable<double> PixelsPerSecond { get; }
IObservable<int> PixelsPerSecond { get; }
/// <summary>
/// Changes the selected profile by its <see cref="Core.ProfileConfiguration" />.
@ -61,7 +60,14 @@ public interface IProfileEditorService : IArtemisSharedUIService
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.
/// Changes the current pixels per second
/// </summary>
/// <param name="pixelsPerSecond">The new pixels per second.</param>
void ChangePixelsPerSecond(int pixelsPerSecond);
/// <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>

View File

@ -19,7 +19,7 @@ internal class ProfileEditorService : IProfileEditorService
private readonly BehaviorSubject<TimeSpan> _timeSubject = new(TimeSpan.Zero);
private readonly BehaviorSubject<bool> _playingSubject = new(false);
private readonly BehaviorSubject<bool> _suspendedEditingSubject = new(false);
private readonly BehaviorSubject<double> _pixelsPerSecondSubject = new(300);
private readonly BehaviorSubject<int> _pixelsPerSecondSubject = new(120);
private readonly ILogger _logger;
private readonly IProfileService _profileService;
private readonly IModuleService _moduleService;
@ -59,7 +59,7 @@ internal class ProfileEditorService : IProfileEditorService
public IObservable<TimeSpan> Time { get; }
public IObservable<bool> Playing { get; }
public IObservable<bool> SuspendedEditing { get; }
public IObservable<double> PixelsPerSecond { get; }
public IObservable<int> PixelsPerSecond { get; }
public void ChangeCurrentProfileConfiguration(ProfileConfiguration? profileConfiguration)
{
@ -147,7 +147,7 @@ internal class ProfileEditorService : IProfileEditorService
return time;
}
public void ChangePixelsPerSecond(double pixelsPerSecond)
public void ChangePixelsPerSecond(int pixelsPerSecond)
{
_pixelsPerSecondSubject.OnNext(pixelsPerSecond);
}

View File

@ -0,0 +1,186 @@
using System;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Media;
namespace Artemis.UI.Controls;
public class TimelineHeader : Control
{
public static readonly StyledProperty<Brush> ForegroundProperty = AvaloniaProperty.Register<TimelineHeader, Brush>(nameof(Foreground), new SolidColorBrush(Colors.Black));
public static readonly StyledProperty<Brush> BackgroundProperty = AvaloniaProperty.Register<TimelineHeader, Brush>(nameof(Background), new SolidColorBrush(Colors.Transparent));
public static readonly StyledProperty<FontFamily> FontFamilyProperty = AvaloniaProperty.Register<TimelineHeader, FontFamily>(nameof(FontFamily), FontFamily.Default);
public static readonly StyledProperty<int> PixelsPerSecondProperty = AvaloniaProperty.Register<TimelineHeader, int>(nameof(PixelsPerSecond));
public static readonly StyledProperty<double> HorizontalOffsetProperty = AvaloniaProperty.Register<TimelineHeader, double>(nameof(HorizontalOffset));
public static readonly StyledProperty<double> VisibleWidthProperty = AvaloniaProperty.Register<TimelineHeader, double>(nameof(VisibleWidth));
public static readonly StyledProperty<bool> OffsetFirstValueProperty = AvaloniaProperty.Register<TimelineHeader, bool>(nameof(OffsetFirstValue));
/// <inheritdoc />
static TimelineHeader()
{
AffectsRender<TimelineHeader>(
ForegroundProperty,
BackgroundProperty,
FontFamilyProperty,
PixelsPerSecondProperty,
HorizontalOffsetProperty,
VisibleWidthProperty,
OffsetFirstValueProperty
);
}
private double _subd1;
private double _subd2;
private double _subd3;
public Brush Foreground
{
get => GetValue(ForegroundProperty);
set => SetValue(ForegroundProperty, value);
}
public Brush Background
{
get => GetValue(BackgroundProperty);
set => SetValue(BackgroundProperty, value);
}
public FontFamily FontFamily
{
get => GetValue(FontFamilyProperty);
set => SetValue(FontFamilyProperty, value);
}
public int PixelsPerSecond
{
get => GetValue(PixelsPerSecondProperty);
set => SetValue(PixelsPerSecondProperty, value);
}
public double HorizontalOffset
{
get => GetValue(HorizontalOffsetProperty);
set => SetValue(HorizontalOffsetProperty, value);
}
public double VisibleWidth
{
get => GetValue(VisibleWidthProperty);
set => SetValue(VisibleWidthProperty, value);
}
public bool OffsetFirstValue
{
get => GetValue(OffsetFirstValueProperty);
set => SetValue(OffsetFirstValueProperty, value);
}
public override void Render(DrawingContext drawingContext)
{
UpdateTimeScale();
drawingContext.DrawRectangle(Background, null, new Rect(0, 0, Bounds.Width, 30));
Pen linePen = new(Foreground);
double width = HorizontalOffset + VisibleWidth;
int frameStart = 0;
double units = PixelsPerSecond / _subd1;
double offsetUnits = frameStart * PixelsPerSecond % units;
// Labels
double count = (width + offsetUnits) / units;
for (int i = 0; i < count; i++)
{
double x = i * units - offsetUnits;
// Add a 100px margin to allow the text to partially render when needed
if (x < HorizontalOffset - 100 || x > HorizontalOffset + width)
continue;
TimeSpan t = TimeSpan.FromSeconds((i * units - offsetUnits) / PixelsPerSecond + frameStart);
// 0.00 is always formatted as 0.00
if (t == TimeSpan.Zero)
RenderLabel(drawingContext, "0.00", x);
else if (PixelsPerSecond > 200)
RenderLabel(drawingContext, $"{Math.Floor(t.TotalSeconds):00}.{t.Milliseconds:000}", x);
else if (PixelsPerSecond > 60)
RenderLabel(drawingContext, $"{Math.Floor(t.TotalSeconds):00}.{t.Milliseconds:000}", x);
else
RenderLabel(drawingContext, $"{Math.Floor(t.TotalMinutes):0}:{t.Seconds:00}", x);
}
// Large ticks
units = PixelsPerSecond / _subd2;
count = (width + offsetUnits) / units;
for (int i = 0; i < count; i++)
{
double x = i * units - offsetUnits;
if (x == 0 && OffsetFirstValue)
drawingContext.DrawLine(linePen, new Point(1, 20), new Point(1, 30));
else if (x > HorizontalOffset && x < HorizontalOffset + width)
drawingContext.DrawLine(linePen, new Point(x, 20), new Point(x, 30));
}
// Small ticks
double mul = _subd3 / _subd2;
units = PixelsPerSecond / _subd3;
count = (width + offsetUnits) / units;
for (int i = 0; i < count; i++)
{
if (Math.Abs(i % mul) < 0.001) continue;
double x = i * units - offsetUnits;
if (x > HorizontalOffset && x < HorizontalOffset + width)
drawingContext.DrawLine(linePen, new Point(x, 25), new Point(x, 30));
}
}
private void RenderLabel(DrawingContext drawingContext, string text, double x)
{
Typeface typeFace = new(FontFamily);
FormattedText formattedText = new(text, typeFace, 9, TextAlignment.Left, TextWrapping.NoWrap, Bounds.Size);
if (x == 0 && OffsetFirstValue)
drawingContext.DrawText(Foreground, new Point(2, 5), formattedText);
else
drawingContext.DrawText(Foreground, new Point(x - formattedText.Bounds.Width / 2, 5), formattedText);
}
private void UpdateTimeScale()
{
object[] subds;
if (PixelsPerSecond > 350)
subds = new object[] {12d, 12d, 60d};
else if (PixelsPerSecond > 250)
subds = new object[] {6d, 12d, 60d};
else if (PixelsPerSecond > 200)
subds = new object[] {6d, 6d, 30d};
else if (PixelsPerSecond > 150)
subds = new object[] {4d, 4d, 20d};
else if (PixelsPerSecond > 140)
subds = new object[] {4d, 4d, 20d};
else if (PixelsPerSecond > 90)
subds = new object[] {2d, 4d, 20d};
else if (PixelsPerSecond > 60)
subds = new object[] {2d, 4d, 8d};
else if (PixelsPerSecond > 40)
subds = new object[] {1d, 2d, 10d};
else if (PixelsPerSecond > 30)
subds = new object[] {1d, 2d, 10d};
else if (PixelsPerSecond > 10)
subds = new object[] {1d / 2d, 1d / 2d, 1d / 2d};
else if (PixelsPerSecond > 4)
subds = new object[] {1d / 5d, 1d / 5d, 1d / 5d};
else if (PixelsPerSecond > 3)
subds = new object[] {1d / 10d, 1d / 10d, 1d / 5d};
else if (PixelsPerSecond > 1)
subds = new object[] {1d / 20d, 1d / 20d, 1d / 10d};
else if (PixelsPerSecond >= 1)
subds = new object[] {1d / 30d, 1d / 30d, 1d / 15d};
else
// 1s per pixel
subds = new object[] {1d / 60d, 1d / 60d, 1d / 15d};
_subd1 = (double) subds[0]; // big ticks / labels
_subd2 = (double) subds[1]; // medium ticks
_subd3 = (double) subds[2]; // small ticks
}
}

View File

@ -4,6 +4,7 @@
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:local="clr-namespace:Artemis.UI.Screens.ProfileEditor.ProfileElementProperties"
xmlns:avalonia="clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia"
xmlns:controls="clr-namespace:Artemis.UI.Controls"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="Artemis.UI.Screens.ProfileEditor.ProfileElementProperties.ProfileElementPropertiesView">
<Grid ColumnDefinitions="*,Auto,*" Name="ContainerGrid">
@ -32,6 +33,18 @@
Margin="0 0 -5 0" />
<Grid Grid.Column="2" RowDefinitions="48,*">
<!-- Timeline header body -->
<controls:TimelineHeader Grid.Row="0"
Name="TimelineHeader"
Margin="0 18 0 0"
Foreground="{DynamicResource TextFillColorPrimaryBrush}"
PixelsPerSecond="{Binding PixelsPerSecond}"
HorizontalOffset="{Binding #TimelineScrollViewer.Offset.X, Mode=OneWay}"
VisibleWidth="{Binding #TimelineScrollViewer.Bounds.Width}"
OffsetFirstValue="True"
PointerReleased="TimelineHeader_OnPointerReleased"
Width="{Binding #TimelineScrollViewer.Viewport.Width}"
Cursor="Hand" />
<Canvas Grid.Row="0" ZIndex="2">
<!-- Timeline segments -->
@ -39,18 +52,6 @@
<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}"

View File

@ -13,33 +13,33 @@ namespace Artemis.UI.Screens.ProfileEditor.ProfileElementProperties;
public class ProfileElementPropertiesView : ReactiveUserControl<ProfileElementPropertiesViewModel>
{
private Polygon _timelineCaret;
private Line _timelineLine;
private readonly Polygon _timelineCaret;
private readonly Line _timelineLine;
public ProfileElementPropertiesView()
{
InitializeComponent();
_timelineCaret = this.Get<Polygon>("TimelineCaret");
_timelineLine = this.Get<Line>("TimelineLine");
}
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
{
// 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)
@ -67,14 +67,7 @@ public class ProfileElementPropertiesView : ReactiveUserControl<ProfileElementPr
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));
newTime = RoundTime(newTime);
// If holding down shift, snap to the closest segment or keyframe
if (e.KeyModifiers.HasFlag(KeyModifiers.Shift))
@ -89,4 +82,26 @@ public class ProfileElementPropertiesView : ReactiveUserControl<ProfileElementPr
ViewModel.TimelineViewModel.ChangeTime(newTime);
}
private void TimelineHeader_OnPointerReleased(object? sender, PointerReleasedEventArgs e)
{
if (ViewModel == null || sender is not IInputElement senderElement)
return;
// Get the parent grid, need that for our position
double x = Math.Max(0, e.GetPosition(senderElement.VisualParent).X);
TimeSpan newTime = TimeSpan.FromSeconds(x / ViewModel.PixelsPerSecond);
ViewModel.TimelineViewModel.ChangeTime(RoundTime(newTime));
}
private TimeSpan RoundTime(TimeSpan time)
{
// Round the time to something that fits the current zoom level
if (ViewModel!.PixelsPerSecond < 200)
return TimeSpan.FromMilliseconds(Math.Round(time.TotalMilliseconds / 5.0) * 5.0);
if (ViewModel.PixelsPerSecond < 500)
return TimeSpan.FromMilliseconds(Math.Round(time.TotalMilliseconds / 2.0) * 2.0);
return TimeSpan.FromMilliseconds(Math.Round(time.TotalMilliseconds));
}
}

View File

@ -23,7 +23,7 @@ public class ProfileElementPropertiesViewModel : ActivatableViewModelBase
private readonly IProfileEditorService _profileEditorService;
private readonly ILayerPropertyVmFactory _layerPropertyVmFactory;
private ObservableAsPropertyHelper<RenderProfileElement?>? _profileElement;
private ObservableAsPropertyHelper<double>? _pixelsPerSecond;
private ObservableAsPropertyHelper<int>? _pixelsPerSecond;
private ObservableCollection<ProfileElementPropertyGroupViewModel> _propertyGroupViewModels;
/// <inheritdoc />
@ -63,7 +63,7 @@ public class ProfileElementPropertiesViewModel : ActivatableViewModelBase
public TimelineViewModel TimelineViewModel { get; }
public RenderProfileElement? ProfileElement => _profileElement?.Value;
public Layer? Layer => _profileElement?.Value as Layer;
public double PixelsPerSecond => _pixelsPerSecond?.Value ?? 0;
public int PixelsPerSecond => _pixelsPerSecond?.Value ?? 0;
public IObservable<bool> Playing => _profileEditorService.Playing;
public ObservableCollection<ProfileElementPropertyGroupViewModel> PropertyGroupViewModels

View File

@ -23,7 +23,7 @@
<Setter Property="Opacity" Value="0" />
</Style>
</UserControl.Styles>
<Grid ColumnDefinitions="Auto, Auto,*" Height="23" Margin="5 0">
<Grid ColumnDefinitions="Auto, Auto,*,*" Height="23" Margin="5 0">
<ContentControl Grid.Column="0" Content="{Binding ProfileElement}">
<ContentControl.DataTemplates>
<DataTemplate DataType="core:Folder">
@ -40,5 +40,13 @@
<Border Grid.Column="2" Classes="status-message-border" Classes.hidden="{Binding !ShowStatusMessage}">
<TextBlock Margin="5 0 0 0" Text="{Binding StatusMessage}" />
</Border>
<Slider Grid.Column="3"
Margin="0 -11 0 0"
Minimum="31"
Maximum="350"
Width="319"
Value="{Binding PixelsPerSecond}"
HorizontalAlignment="Right"/>
</Grid>
</UserControl>

View File

@ -10,17 +10,21 @@ namespace Artemis.UI.Screens.ProfileEditor.StatusBar;
public class StatusBarViewModel : ActivatableViewModelBase
{
private ProfileEditorHistory? _history;
private RenderProfileElement? _profileElement;
private string? _statusMessage;
private readonly IProfileEditorService _profileEditorService;
private ObservableAsPropertyHelper<ProfileEditorHistory?>? _history;
private ObservableAsPropertyHelper<int>? _pixelsPerSecond;
private ObservableAsPropertyHelper<RenderProfileElement?>? _profileElement;
private bool _showStatusMessage;
private string? _statusMessage;
public StatusBarViewModel(IProfileEditorService profileEditorService)
{
_profileEditorService = profileEditorService;
this.WhenActivated(d =>
{
profileEditorService.ProfileElement.Subscribe(p => ProfileElement = p).DisposeWith(d);
profileEditorService.History.Subscribe(history => History = history).DisposeWith(d);
_profileElement = profileEditorService.ProfileElement.ToProperty(this, vm => vm.ProfileElement).DisposeWith(d);
_history = profileEditorService.History.ToProperty(this, vm => vm.History).DisposeWith(d);
_pixelsPerSecond = profileEditorService.PixelsPerSecond.ToProperty(this, vm => vm.PixelsPerSecond);
});
this.WhenAnyValue(vm => vm.History)
@ -36,16 +40,13 @@ public class StatusBarViewModel : ActivatableViewModelBase
this.WhenAnyValue(vm => vm.StatusMessage).Throttle(TimeSpan.FromSeconds(3)).Subscribe(_ => ShowStatusMessage = false);
}
public RenderProfileElement? ProfileElement
{
get => _profileElement;
set => this.RaiseAndSetIfChanged(ref _profileElement, value);
}
public RenderProfileElement? ProfileElement => _profileElement?.Value;
public ProfileEditorHistory? History => _history?.Value;
public ProfileEditorHistory? History
public int PixelsPerSecond
{
get => _history;
set => this.RaiseAndSetIfChanged(ref _history, value);
get => _pixelsPerSecond?.Value ?? 0;
set => _profileEditorService.ChangePixelsPerSecond(value);
}
public string? StatusMessage