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(