diff --git a/OpenUtau.Core/Commands/MoveTempoChangeCommand.cs b/OpenUtau.Core/Commands/MoveTempoChangeCommand.cs new file mode 100644 index 000000000..d49c1b2e2 --- /dev/null +++ b/OpenUtau.Core/Commands/MoveTempoChangeCommand.cs @@ -0,0 +1,58 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using OpenUtau.Core.Ustx; + +namespace OpenUtau.Core { + public class MoveTempoChangeCommand : AddTempoChangeCommand { + private readonly UTempo tempo; + private readonly int oldTick; + private readonly int newTick; + + public MoveTempoChangeCommand(UProject project, UTempo tempo, int newTick) + : this(project, tempo, tempo.position, newTick) { } + + private MoveTempoChangeCommand( + UProject project, + UTempo tempo, + int oldTick, + int newTick) : base(project) { + this.tempo = tempo; + this.oldTick = oldTick; + this.newTick = newTick; + } + + public override void Execute() => MoveTempo(oldTick, newTick); + + public override void Unexecute() => MoveTempo(newTick, oldTick); + + private void MoveTempo(int fromTick, int toTick) { + var currentTempo = project.tempos.Contains(tempo) && tempo.position == fromTick + ? tempo + : project.tempos.FirstOrDefault(candidate => candidate.position == fromTick); + if (currentTempo == null) { + throw new InvalidOperationException( + $"Cannot find tempo change at {fromTick} to move to {toTick}."); + } + currentTempo.position = toTick; + } + + public override bool CanMerge(IList commands) { + var moves = commands.OfType().ToList(); + return moves.Count == commands.Count && + moves.All(command => command.tempo == tempo); + } + + public override UCommand Merge(IList commands) { + var moves = commands.Cast().ToList(); + return new MoveTempoChangeCommand( + project, + tempo, + moves.First().oldTick, + moves.Last().newTick); + } + + public override string ToString() => + $"Move tempo change {tempo.bpm} from {oldTick} to {newTick}"; + } +} diff --git a/OpenUtau.Test/Core/Commands/MoveTempoChangeCommandTest.cs b/OpenUtau.Test/Core/Commands/MoveTempoChangeCommandTest.cs new file mode 100644 index 000000000..62869969e --- /dev/null +++ b/OpenUtau.Test/Core/Commands/MoveTempoChangeCommandTest.cs @@ -0,0 +1,64 @@ +using System.Collections.Generic; +using OpenUtau.Core.Ustx; +using Xunit; + +namespace OpenUtau.Core { + public class MoveTempoChangeCommandTest { + [Fact] + public void ExecuteAndUnexecute() { + var project = new UProject(); + var tempo = new UTempo(480, 140); + project.tempos.Add(tempo); + var command = new MoveTempoChangeCommand(project, tempo, 720); + + command.Execute(); + Assert.Equal(720, tempo.position); + + command.Unexecute(); + Assert.Equal(480, tempo.position); + + command.Execute(); + Assert.Equal(720, tempo.position); + } + + [Fact] + public void UnexecuteAfterTempoIsRecreated() { + var project = new UProject(); + var tempo = new UTempo(480, 140); + project.tempos.Add(tempo); + var command = new MoveTempoChangeCommand(project, tempo, 720); + command.Execute(); + + project.tempos.Remove(tempo); + var recreatedTempo = new UTempo(720, 140); + project.tempos.Add(recreatedTempo); + + command.Unexecute(); + Assert.Equal(480, recreatedTempo.position); + + command.Execute(); + Assert.Equal(720, recreatedTempo.position); + } + + [Fact] + public void MergeKeepsFirstAndLastPositions() { + var project = new UProject(); + var tempo = new UTempo(480, 140); + project.tempos.Add(tempo); + var firstMove = new MoveTempoChangeCommand(project, tempo, 600); + firstMove.Execute(); + var secondMove = new MoveTempoChangeCommand(project, tempo, 720); + secondMove.Execute(); + var commands = new List { firstMove, secondMove }; + + Assert.True(secondMove.CanMerge(commands)); + var merged = secondMove.Merge(commands); + + merged.Unexecute(); + Assert.Equal(480, tempo.position); + + merged.Execute(); + Assert.Equal(720, tempo.position); + } + } +} diff --git a/OpenUtau/Controls/TickBackground.cs b/OpenUtau/Controls/TickBackground.cs index f20130cca..2b2e16245 100644 --- a/OpenUtau/Controls/TickBackground.cs +++ b/OpenUtau/Controls/TickBackground.cs @@ -5,9 +5,20 @@ using Avalonia.Media; using Avalonia.Media.Immutable; using OpenUtau.App.ViewModels; +using OpenUtau.Core.Ustx; using ReactiveUI; namespace OpenUtau.App.Controls { + class TempoMarkerHighlightEvent { + public object DataContext { get; } + public UTempo? Tempo { get; } + + public TempoMarkerHighlightEvent(object dataContext, UTempo? tempo) { + DataContext = dataContext; + Tempo = tempo; + } + } + class TickBackground : TemplatedControl { private static readonly IDashStyle DashStyle = new ImmutableDashStyle(new double[] { 2, 4 }, 0); @@ -46,6 +57,11 @@ class TickBackground : TemplatedControl { nameof(ShowBar), o => o.ShowBar, (o, v) => o.ShowBar = v); + public static readonly DirectProperty HighlightedTempoProperty = + AvaloniaProperty.RegisterDirect( + nameof(HighlightedTempo), + o => o.HighlightedTempo, + (o, v) => o.HighlightedTempo = v); public int Resolution { get => _resolution; @@ -76,6 +92,13 @@ public bool ShowBar { get => _showBar; set => SetAndRaise(ShowBarProperty, ref _showBar, value); } + public UTempo? HighlightedTempo { + get => _highlightedTempo; + private set => SetAndRaise( + HighlightedTempoProperty, + ref _highlightedTempo, + value); + } private int _resolution = 480; private double _tickWidth; @@ -84,6 +107,7 @@ public bool ShowBar { private int _snapDiv; private ObservableCollection? _snapTicks; private bool _showBar = true; + private UTempo? _highlightedTempo; private Pen penBar; private Pen penBeatUnit; @@ -99,6 +123,12 @@ public TickBackground() { .Subscribe(e => InvalidateVisual()); MessageBus.Current.Listen() .Subscribe(e => InvalidateVisual()); + MessageBus.Current.Listen() + .Subscribe(e => { + if (ReferenceEquals(e.DataContext, DataContext)) { + HighlightedTempo = e.Tempo; + } + }); } protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) { @@ -117,7 +147,8 @@ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs chang change.Property == TickWidthProperty || change.Property == TickOffsetProperty || change.Property == SnapDivProperty || - change.Property == ShowBarProperty) { + change.Property == ShowBarProperty || + change.Property == HighlightedTempoProperty) { InvalidateVisual(); } } @@ -184,9 +215,16 @@ public override void Render(DrawingContext context) { if (ShowBar) { foreach (var tempo in project.tempos) { + bool highlighted = ReferenceEquals(tempo, HighlightedTempo); double x = Math.Round(tempo.position * TickWidth - pixelOffset) + 0.5; - context.DrawLine(penDanshed, new Point(x, 0), new Point(x, 24)); - var textLayout = TextLayoutCache.Get(tempo.bpm.ToString("#0.00"), ThemeManager.BarNumberBrush, 10); + var markerPen = highlighted + ? ThemeManager.AccentPen2Thickness2 + : penDanshed; + var textBrush = highlighted + ? ThemeManager.AccentBrush2 + : ThemeManager.BarNumberBrush; + context.DrawLine(markerPen, new Point(x, 0), new Point(x, 24)); + var textLayout = TextLayoutCache.Get(tempo.bpm.ToString("#0.00"), textBrush, 10); using (var state = context.PushTransform(Matrix.CreateTranslation(x + 3, 0))) { textLayout.Draw(context, new Point()); } diff --git a/OpenUtau/Views/MainWindow.TempoMarkers.cs b/OpenUtau/Views/MainWindow.TempoMarkers.cs new file mode 100644 index 000000000..22f62988f --- /dev/null +++ b/OpenUtau/Views/MainWindow.TempoMarkers.cs @@ -0,0 +1,266 @@ +using System; +using System.Linq; +using Avalonia; +using Avalonia.Input; +using Avalonia.Interactivity; +using OpenUtau.App.Controls; +using OpenUtau.App.ViewModels; +using OpenUtau.Core; +using OpenUtau.Core.Ustx; +using ReactiveUI; + +namespace OpenUtau.App.Views { + public partial class MainWindow { + private const double TempoMarkerHitPadding = 5; + private const double TempoMarkerDragThreshold = 4; + + private bool tempoMarkerHandlersAttached; + private UTempo? draggedTempo; + private Point tempoDragStartPoint; + private int tempoDragStartTick; + private double tempoDragPointerOffset; + private int tempoDragMinTick; + private int tempoDragMaxTick; + private bool tempoDragHasMoved; + private UTempo? highlightedTempo; + + protected override void OnOpened(EventArgs e) { + base.OnOpened(e); + if (tempoMarkerHandlersAttached) { + return; + } + tempoMarkerHandlersAttached = true; + TimelineCanvas.AddHandler( + PointerPressedEvent, + TempoMarkerPointerPressed, + RoutingStrategies.Tunnel, + true); + TimelineCanvas.AddHandler( + PointerMovedEvent, + TempoMarkerPointerMoved, + RoutingStrategies.Tunnel, + true); + TimelineCanvas.AddHandler( + PointerReleasedEvent, + TempoMarkerPointerReleased, + RoutingStrategies.Tunnel, + true); + TimelineCanvas.AddHandler( + PointerExitedEvent, + TempoMarkerPointerExited, + RoutingStrategies.Direct, + true); + TimelineCanvas.AddHandler( + PointerCaptureLostEvent, + TempoMarkerPointerCaptureLost, + RoutingStrategies.Direct, + true); + } + + private void TempoMarkerPointerPressed( + object? sender, + PointerPressedEventArgs args) { + var point = args.GetCurrentPoint(TimelineCanvas); + if (!point.Properties.IsLeftButtonPressed || draggedTempo != null) { + return; + } + + var tempo = HitTestTempoMarker(point.Position); + if (tempo == null) { + return; + } + + var project = DocManager.Inst.Project; + int index = project.tempos.IndexOf(tempo); + if (index <= 0) { + return; + } + + draggedTempo = tempo; + tempoDragStartPoint = point.Position; + tempoDragStartTick = tempo.position; + tempoDragPointerOffset = point.Position.X - TempoMarkerX(tempo); + tempoDragMinTick = project.tempos[index - 1].position + 1; + tempoDragMaxTick = index + 1 < project.tempos.Count + ? project.tempos[index + 1].position - 1 + : int.MaxValue; + if (tempoDragMinTick > tempoDragMaxTick) { + tempoDragMinTick = tempo.position; + tempoDragMaxTick = tempo.position; + } + tempoDragHasMoved = false; + HighlightTempoMarker(draggedTempo); + + args.Pointer.Capture(TimelineCanvas); + DocManager.Inst.StartUndoGroup( + "command.project.tempo", + deferValidate: true); + Cursor = ViewConstants.cursorSizeWE; + args.Handled = true; + } + + private void TempoMarkerPointerMoved( + object? sender, + PointerEventArgs args) { + var point = args.GetCurrentPoint(TimelineCanvas); + if (draggedTempo != null) { + if (!tempoDragHasMoved) { + double distance = Math.Abs(point.Position.X - tempoDragStartPoint.X); + if (distance < TempoMarkerDragThreshold) { + HighlightTempoMarker(draggedTempo); + Cursor = ViewConstants.cursorSizeWE; + args.Handled = true; + return; + } + tempoDragHasMoved = true; + } + + var tracksVm = viewModel.TracksViewModel; + var markerPoint = new Point( + point.Position.X - tempoDragPointerOffset, + point.Position.Y); + int rawTick = tracksVm.PointToTick(markerPoint); + int newTick = NearestValidTempoSnap(tracksVm, rawTick); + if (newTick != draggedTempo.position) { + DocManager.Inst.ExecuteCmd(new MoveTempoChangeCommand( + DocManager.Inst.Project, + draggedTempo, + newTick)); + } + + HighlightTempoMarker(draggedTempo); + Cursor = ViewConstants.cursorSizeWE; + args.Handled = true; + return; + } + + bool pointerIdle = + !point.Properties.IsLeftButtonPressed && + !point.Properties.IsRightButtonPressed && + !point.Properties.IsMiddleButtonPressed; + var hoveredTempo = pointerIdle + ? HitTestTempoMarker(point.Position) + : null; + HighlightTempoMarker(hoveredTempo); + if (hoveredTempo != null) { + Cursor = ViewConstants.cursorSizeWE; + args.Handled = true; + } + } + + private void TempoMarkerPointerReleased( + object? sender, + PointerReleasedEventArgs args) { + if (draggedTempo == null) { + return; + } + + if (!tempoDragHasMoved) { + viewModel.TracksViewModel.PointToLineTick( + tempoDragStartPoint, + out int left, + out _); + viewModel.PlaybackViewModel.MovePlayPos(left); + } + FinishTempoMarkerUndoGroup(); + draggedTempo = null; + tempoDragHasMoved = false; + args.Pointer.Capture(null); + var hoveredTempo = HitTestTempoMarker(args.GetPosition(TimelineCanvas)); + HighlightTempoMarker(hoveredTempo); + Cursor = hoveredTempo != null + ? ViewConstants.cursorSizeWE + : null; + args.Handled = true; + } + + private void TempoMarkerPointerExited( + object? sender, + PointerEventArgs args) { + if (draggedTempo == null) { + HighlightTempoMarker(null); + Cursor = null; + } + } + + private void TempoMarkerPointerCaptureLost( + object? sender, + PointerCaptureLostEventArgs args) { + if (draggedTempo == null) { + return; + } + FinishTempoMarkerUndoGroup(); + draggedTempo = null; + tempoDragHasMoved = false; + HighlightTempoMarker(null); + Cursor = null; + } + + private int NearestValidTempoSnap(TracksViewModel tracksVm, int rawTick) { + int boundedTick = Math.Clamp(rawTick, tempoDragMinTick, tempoDragMaxTick); + tracksVm.TickToLineTick(boundedTick, out int left, out int right); + bool leftValid = left >= tempoDragMinTick && left <= tempoDragMaxTick; + bool rightValid = right >= tempoDragMinTick && right <= tempoDragMaxTick; + if (leftValid && rightValid) { + return Math.Abs((long)boundedTick - left) <= Math.Abs((long)right - boundedTick) + ? left + : right; + } + if (leftValid) { + return left; + } + if (rightValid) { + return right; + } + return draggedTempo?.position ?? tempoDragStartTick; + } + + private void FinishTempoMarkerUndoGroup() { + if (!DocManager.Inst.HasOpenUndoGroup) { + return; + } + if (draggedTempo != null && + tempoDragHasMoved && + draggedTempo.position == tempoDragStartTick) { + DocManager.Inst.RollBackUndoGroup(); + } + DocManager.Inst.EndUndoGroup(); + } + + private void HighlightTempoMarker(UTempo? tempo) { + if (ReferenceEquals(tempo, highlightedTempo)) { + return; + } + highlightedTempo = tempo; + MessageBus.Current.SendMessage( + new TempoMarkerHighlightEvent(viewModel, tempo)); + } + + private UTempo? HitTestTempoMarker(Point point) { + if (point.X < 0 || point.X > TimelineCanvas.Bounds.Width || + point.Y < 0 || point.Y > TimelineCanvas.Bounds.Height) { + return null; + } + + return DocManager.Inst.Project.tempos + .Skip(1) + .Select(tempo => new { + Tempo = tempo, + X = TempoMarkerX(tempo), + Width = Math.Max( + TempoMarkerHitPadding * 2, + tempo.bpm.ToString("#0.00").Length * 6 + 6), + }) + .Where(marker => + point.X >= marker.X - TempoMarkerHitPadding && + point.X <= marker.X + marker.Width) + .OrderBy(marker => Math.Abs(point.X - marker.X)) + .Select(marker => marker.Tempo) + .FirstOrDefault(); + } + + private double TempoMarkerX(UTempo tempo) { + return viewModel.TracksViewModel.TickTrackToPoint(tempo.position, 0).X; + } + } +}