diff --git a/src/Avalonia/Artemis.UI.Shared/Services/ProfileEditor/IProfileEditorService.cs b/src/Avalonia/Artemis.UI.Shared/Services/ProfileEditor/IProfileEditorService.cs index bef5cab0d..40cb889a5 100644 --- a/src/Avalonia/Artemis.UI.Shared/Services/ProfileEditor/IProfileEditorService.cs +++ b/src/Avalonia/Artemis.UI.Shared/Services/ProfileEditor/IProfileEditorService.cs @@ -39,9 +39,8 @@ public interface IProfileEditorService : IArtemisSharedUIService /// /// Gets an observable of the zoom level. /// - IObservable PixelsPerSecond { get; } - - + IObservable PixelsPerSecond { get; } + /// /// Changes the selected profile by its . /// @@ -61,7 +60,14 @@ public interface IProfileEditorService : IArtemisSharedUIService 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. + /// Changes the current pixels per second + /// + /// The new pixels per second. + void ChangePixelsPerSecond(int pixelsPerSecond); + + /// + /// 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. diff --git a/src/Avalonia/Artemis.UI.Shared/Services/ProfileEditor/ProfileEditorService.cs b/src/Avalonia/Artemis.UI.Shared/Services/ProfileEditor/ProfileEditorService.cs index bd3663872..54de02885 100644 --- a/src/Avalonia/Artemis.UI.Shared/Services/ProfileEditor/ProfileEditorService.cs +++ b/src/Avalonia/Artemis.UI.Shared/Services/ProfileEditor/ProfileEditorService.cs @@ -19,7 +19,7 @@ internal class ProfileEditorService : IProfileEditorService private readonly BehaviorSubject _timeSubject = new(TimeSpan.Zero); private readonly BehaviorSubject _playingSubject = new(false); private readonly BehaviorSubject _suspendedEditingSubject = new(false); - private readonly BehaviorSubject _pixelsPerSecondSubject = new(300); + private readonly BehaviorSubject _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 Time { get; } public IObservable Playing { get; } public IObservable SuspendedEditing { get; } - public IObservable PixelsPerSecond { get; } + public IObservable 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); } diff --git a/src/Avalonia/Artemis.UI/Controls/TimelineHeader.cs b/src/Avalonia/Artemis.UI/Controls/TimelineHeader.cs new file mode 100644 index 000000000..4c17ef671 --- /dev/null +++ b/src/Avalonia/Artemis.UI/Controls/TimelineHeader.cs @@ -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 ForegroundProperty = AvaloniaProperty.Register(nameof(Foreground), new SolidColorBrush(Colors.Black)); + public static readonly StyledProperty BackgroundProperty = AvaloniaProperty.Register(nameof(Background), new SolidColorBrush(Colors.Transparent)); + public static readonly StyledProperty FontFamilyProperty = AvaloniaProperty.Register(nameof(FontFamily), FontFamily.Default); + public static readonly StyledProperty PixelsPerSecondProperty = AvaloniaProperty.Register(nameof(PixelsPerSecond)); + public static readonly StyledProperty HorizontalOffsetProperty = AvaloniaProperty.Register(nameof(HorizontalOffset)); + public static readonly StyledProperty VisibleWidthProperty = AvaloniaProperty.Register(nameof(VisibleWidth)); + public static readonly StyledProperty OffsetFirstValueProperty = AvaloniaProperty.Register(nameof(OffsetFirstValue)); + + /// + static TimelineHeader() + { + AffectsRender( + 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 + } +} \ No newline at end of file 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 8e22176a4..bd8454752 100644 --- a/src/Avalonia/Artemis.UI/Screens/ProfileEditor/Panels/ProfileElementProperties/ProfileElementPropertiesView.axaml +++ b/src/Avalonia/Artemis.UI/Screens/ProfileEditor/Panels/ProfileElementProperties/ProfileElementPropertiesView.axaml @@ -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"> @@ -26,12 +27,24 @@ + Background="Transparent" + Margin="0 0 -5 0" /> + + @@ -39,18 +52,6 @@ - - - - - - - - - - - - { - private Polygon _timelineCaret; - private Line _timelineLine; + private readonly Polygon _timelineCaret; + private readonly Line _timelineLine; public ProfileElementPropertiesView() { InitializeComponent(); + _timelineCaret = this.Get("TimelineCaret"); + _timelineLine = this.Get("TimelineLine"); } private void InitializeComponent() { AvaloniaXamlLoader.Load(this); - _timelineCaret = this.Get("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 - { + // 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? _profileElement; - private ObservableAsPropertyHelper? _pixelsPerSecond; + private ObservableAsPropertyHelper? _pixelsPerSecond; private ObservableCollection _propertyGroupViewModels; /// @@ -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 Playing => _profileEditorService.Playing; public ObservableCollection PropertyGroupViewModels diff --git a/src/Avalonia/Artemis.UI/Screens/ProfileEditor/Panels/StatusBar/StatusBarView.axaml b/src/Avalonia/Artemis.UI/Screens/ProfileEditor/Panels/StatusBar/StatusBarView.axaml index 7c0f57b06..92ab2bf54 100644 --- a/src/Avalonia/Artemis.UI/Screens/ProfileEditor/Panels/StatusBar/StatusBarView.axaml +++ b/src/Avalonia/Artemis.UI/Screens/ProfileEditor/Panels/StatusBar/StatusBarView.axaml @@ -7,7 +7,7 @@ mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="23" x:Class="Artemis.UI.Screens.ProfileEditor.StatusBar.StatusBarView"> - - + @@ -40,5 +40,13 @@ + + \ No newline at end of file diff --git a/src/Avalonia/Artemis.UI/Screens/ProfileEditor/Panels/StatusBar/StatusBarViewModel.cs b/src/Avalonia/Artemis.UI/Screens/ProfileEditor/Panels/StatusBar/StatusBarViewModel.cs index 752d6b36a..ea8d621ab 100644 --- a/src/Avalonia/Artemis.UI/Screens/ProfileEditor/Panels/StatusBar/StatusBarViewModel.cs +++ b/src/Avalonia/Artemis.UI/Screens/ProfileEditor/Panels/StatusBar/StatusBarViewModel.cs @@ -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? _history; + private ObservableAsPropertyHelper? _pixelsPerSecond; + private ObservableAsPropertyHelper? _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