From 4854f7051bedff22102a3e9ad0dd0e995709b263 Mon Sep 17 00:00:00 2001 From: keirokeer <83983873+keirokeer@users.noreply.github.com> Date: Mon, 16 Feb 2026 10:45:34 +0200 Subject: [PATCH] Smooth piano roll scroll in Stationary Cursor mode Smooths piano roll auto-scroll when Stationary Cursor is enabled. Scroll position is lerped toward the target instead of jumping, so the view moves smoothly. User scrolling (scrollbar/zoom/pan) is preserved by syncing the target to the current offset. Target is cleared on part change or when switching auto-scroll mode. --- OpenUtau/ViewModels/NotesViewModel.cs | 46 +++++++++++++++++++++++++-- OpenUtau/Views/MainWindow.axaml.cs | 5 ++- 2 files changed, 48 insertions(+), 3 deletions(-) diff --git a/OpenUtau/ViewModels/NotesViewModel.cs b/OpenUtau/ViewModels/NotesViewModel.cs index 551efb319..b9afa5d84 100644 --- a/OpenUtau/ViewModels/NotesViewModel.cs +++ b/OpenUtau/ViewModels/NotesViewModel.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.IO; using System.Linq; @@ -103,6 +103,14 @@ public class NotesViewModel : ViewModelBase, ICmdSubscriber { // See the comments on TracksViewModel.playPosXToTickOffset private double playPosXToTickOffset => Bounds.Width != 0 ? ViewportTicks / Bounds.Width : 0; + // Smooth scroll for Stationary Cursor (PlaybackAutoScroll == 1): lerp toward target instead of jumping. + private const double SmoothScrollLerpFactor = 0.25; + private const double SmoothScrollSnapThreshold = 0.5; + /// Target TickOffset for smooth stationary-cursor scroll; null when not in use. + private double? smoothScrollTargetTickOffset; + /// True while SmoothScrollStep is updating TickOffset, so we don't treat that as user scroll. + private bool _inSmoothScrollStep; + private readonly ObservableAsPropertyHelper viewportTicks; private readonly ObservableAsPropertyHelper viewportTracks; private readonly ObservableAsPropertyHelper smallChangeX; @@ -286,6 +294,16 @@ public NotesViewModel() { Preferences.Default.ShowNoteParams = showNoteParams; Preferences.Save(); }); +// When user scrolls (scrollbar, zoom, pan), sync smooth-scroll target so we don't pull the view back. + this.WhenAnyValue(x => x.TickOffset) + .Skip(1) + .Subscribe(_ => { + if (!_inSmoothScrollStep && Preferences.Default.PlaybackAutoScroll == 1) { + smoothScrollTargetTickOffset = TickOffset; + } + }); + this.WhenAnyValue(x => x.Part) + .Subscribe(_ => smoothScrollTargetTickOffset = null); TickWidth = ViewConstants.PianoRollTickWidthDefault; TrackHeight = ViewConstants.NoteHeightDefault; @@ -1160,7 +1178,31 @@ private void MaybeAutoScroll(double positionX) { private void AutoScroll(double positionX) { double scrollDelta = GetScrollValueDelta(positionX); - TickOffset = Math.Clamp(TickOffset + scrollDelta, 0, HScrollBarMax); + if (Preferences.Default.PlaybackAutoScroll == 1) { + smoothScrollTargetTickOffset = Math.Clamp(TickOffset + scrollDelta, 0, HScrollBarMax); + } else { + smoothScrollTargetTickOffset = null; + TickOffset = Math.Clamp(TickOffset + scrollDelta, 0, HScrollBarMax); + } + } + + /// Called periodically (e.g. from main window timer) to lerp TickOffset toward smoothScrollTargetTickOffset for stationary cursor. + public void SmoothScrollStep() { + if (Part == null || Preferences.Default.PlaybackAutoScroll != 1 || !smoothScrollTargetTickOffset.HasValue) { + return; + } + double target = Math.Clamp(smoothScrollTargetTickOffset.Value, 0, HScrollBarMax); + double diff = target - TickOffset; + _inSmoothScrollStep = true; + try { + if (Math.Abs(diff) < SmoothScrollSnapThreshold) { + TickOffset = target; + } else { + TickOffset = Math.Clamp(TickOffset + diff * SmoothScrollLerpFactor, 0, HScrollBarMax); + } + } finally { + _inSmoothScrollStep = false; + } } private double GetScrollValueDelta(double positionX) { diff --git a/OpenUtau/Views/MainWindow.axaml.cs b/OpenUtau/Views/MainWindow.axaml.cs index e48b956af..d56a746f3 100644 --- a/OpenUtau/Views/MainWindow.axaml.cs +++ b/OpenUtau/Views/MainWindow.axaml.cs @@ -69,7 +69,10 @@ public MainWindow() { timer = new DispatcherTimer( TimeSpan.FromMilliseconds(15), DispatcherPriority.Normal, - (sender, args) => PlaybackManager.Inst.UpdatePlayPos()); + (sender, args) => { + PlaybackManager.Inst.UpdatePlayPos(); + pianoRoll?.ViewModel?.NotesViewModel?.SmoothScrollStep(); + }); timer.Start(); autosaveTimer = new DispatcherTimer(