Repository: SubtitleEdit/subtitleedit (2026-07-28T00:34:34Z)

Issue:
Scroll bar trough click-and-hold keeps paging past the cursor in the subtitle grid
**Bug**

Click and hold on the vertical scroll bar trough (the empty area above/below the thumb) in the main window's subtitle grid: instead of paging toward the mouse and stopping when the thumb reaches the cursor (standard Windows scroll bar behavior), it keeps paging right past the cursor all the way to the end of the list.

Applies to every DataGrid, since trough paging comes from the app-wide style (Styles.axaml), e.g. also Options -> Shortcuts.

**Steps to reproduce**

1. Load a subtitle long enough that the grid scrolls (a few hundred lines).
2. Press and hold the left mouse button on the scroll bar trough below the thumb, keep the mouse still.
3. The view pages down and does not stop when the thumb reaches the cursor - it continues to the very end.

**Expected**

Paging pauses once the thumb reaches the cursor, and resumes only if the pointer is moved further along (like the native Windows scroll bar).

**Cause**

The trough of Avalonia's Fluent ScrollBar is a RepeatButton that keeps firing while IsPressed. Button only re-evaluates IsPressed on PointerMoved, so with a stationary cursor no event arrives as the thumb slides under it and the repeat never stops. This was invisible before #12051 because the DataGrid scroll bar's LargeChange defaulted to 1 (a trough click moved 1 pixel); now that a trough click pages a full viewport (#12051/#12438), the runaway repeat is obvious.


Discussion:

Patch:
diff --git a/src/ui/Logic/DataGridScrollBarBehavior.cs b/src/ui/Logic/DataGridScrollBarBehavior.cs
index 0a71cd1ff4..8e094d35ff 100644
--- a/src/ui/Logic/DataGridScrollBarBehavior.cs
+++ b/src/ui/Logic/DataGridScrollBarBehavior.cs
@@ -6,6 +6,7 @@
 using Avalonia.Controls.Primitives;
 using Avalonia.Input;
 using Avalonia.Interactivity;
+using Avalonia.Threading;
 using Avalonia.VisualTree;
 
 namespace Nikse.SubtitleEdit.Logic;
@@ -26,11 +27,20 @@ namespace Nikse.SubtitleEdit.Logic;
 ///
 /// The DataGrid hooks the scroll bar's Scroll event (not ValueChanged) to refresh its
 /// rows, so a programmatic jump also invokes the grid's internal ProcessVerticalScroll.
+///
+/// Press-and-hold is handled here too: the theme's trough RepeatButton repeats while
+/// IsPressed, and Button only re-evaluates IsPressed on PointerMoved, so with a stationary
+/// cursor it pages past the cursor to the end (issue #12894). This pages toward the cursor
+/// and pauses when the thumb reaches it.
 /// </summary>
 public static class DataGridScrollBarBehavior
 {
     private const string VerticalScrollBarPartName = "PART_VerticalScrollbar";
 
+    // Avalonia's RepeatButton defaults, so the trough repeats like every other scroll bar.
+    private static readonly TimeSpan RepeatDelay = TimeSpan.FromMilliseconds(300);
+    private static readonly TimeSpan RepeatInterval = TimeSpan.FromMilliseconds(100);
+
     private static readonly MethodInfo? ProcessVerticalScrollMethod = typeof(DataGrid).GetMethod(
         "ProcessVerticalScroll",
         BindingFlags.NonPublic | BindingFlags.Instance);
@@ -50,6 +60,10 @@ public static class DataGridScrollBarBehavior
     private static readonly AttachedProperty<bool> WiredProperty =
         AvaloniaProperty.RegisterAttached<DataGrid, bool>("TroughPagingWired", typeof(DataGridScrollBarBehavior));
 
+    // Non-null only while the left button is held on that scroll bar's trough.
+    private static readonly AttachedProperty<TroughHoldState?> HoldStateProperty =
+        AvaloniaProperty.RegisterAttached<ScrollBar, TroughHoldState?>("TroughHoldState", typeof(DataGridScrollBarBehavior));
+
     public static void SetEnableTroughPaging(DataGrid grid, bool value) => grid.SetValue(EnableTroughPagingProperty, value);
 
     public static bool GetEnableTroughPaging(DataGrid grid) => grid.GetValue(EnableTroughPagingProperty);
@@ -89,57 +103,169 @@ public static void EnableTroughPageScroll(DataGrid grid)
                 }
             };
 
-            // Shift + trough click jumps to the click position, matching the Windows scroll
-            // bar. Tunnel so this runs before the trough repeat button starts paging.
+            // Tunnel so this runs before the theme's trough repeat button can engage.
             verticalScrollBar.AddHandler(
                 InputElement.PointerPressedEvent,
-                (_, args) => JumpToClickPosition(grid, verticalScrollBar, args),
+                (_, args) =>
+                {
+                    if ((args.KeyModifiers & KeyModifiers.Shift) != 0)
+                    {
+                        JumpToClickPosition(grid, verticalScrollBar, args);
+                    }
+                    else
+                    {
+                        StartTroughHoldPaging(grid, verticalScrollBar, args);
+                    }
+                },
                 RoutingStrategies.Tunnel);
+
+            verticalScrollBar.PointerMoved += (_, args) =>
+            {
+                if (GetHoldState(verticalScrollBar) is { } state)
+                {
+                    state.PointerPosition = args.GetPosition(verticalScrollBar);
+                }
+            };
+
+            verticalScrollBar.PointerReleased += (_, args) =>
+            {
+                if (GetHoldState(verticalScrollBar) != null && args.InitialPressMouseButton == MouseButton.Left)
+                {
+                    StopTroughHoldPaging(verticalScrollBar);
+                    args.Pointer.Capture(null);
+                    args.Handled = true;
+                }
+            };
+
+            // Covers a release outside the window, window deactivation and a re-template mid-hold.
+            verticalScrollBar.PointerCaptureLost += (_, _) => StopTroughHoldPaging(verticalScrollBar);
         };
     }
 
-    private static void JumpToClickPosition(DataGrid grid, ScrollBar verticalScrollBar, PointerPressedEventArgs e)
+    // State for a press-and-hold on one scroll bar's trough. The direction is latched at press
+    // time (Windows-style): re-deciding it per tick would ping-pong once a page overshoots.
+    private sealed class TroughHoldState
     {
-        if ((e.KeyModifiers & KeyModifiers.Shift) == 0 ||
-            !e.GetCurrentPoint(verticalScrollBar).Properties.IsLeftButtonPressed)
+        public required DispatcherTimer Timer { get; init; }
+        public required IPointer Pointer { get; init; }
+        public required Track Track { get; init; }
+        public required bool PageDown { get; init; }
+        public Point PointerPosition { get; set; } // in scroll bar coordinates
+    }
+
+    private static void StartTroughHoldPaging(DataGrid grid, ScrollBar verticalScrollBar, PointerPressedEventArgs e)
+    {
+        // Without ProcessVerticalScroll a page would move the thumb but not the rows, so leave
+        // the press to the theme's repeat button instead of swallowing it.
+        if (ProcessVerticalScrollMethod == null ||
+            GetHoldState(verticalScrollBar) != null ||
+            !e.GetCurrentPoint(verticalScrollBar).Properties.IsLeftButtonPressed ||
+            !TryGetTroughPress(verticalScrollBar, e, out var track, out _, out var isBelowThumb))
         {
             return;
         }
 
-        var track = verticalScrollBar.GetVisualDescendants().OfType<Track>().FirstOrDefault();
-        if (track == null || track.Bounds.Height <= 0)
+        var timer = new DispatcherTimer { Interval = RepeatDelay };
+        var state = new TroughHoldState
         {
+            Timer = timer,
+            Pointer = e.Pointer,
+            Track = track,
+            PageDown = isBelowThumb,
+            PointerPosition = e.GetPosition(verticalScrollBar),
+        };
+
+        timer.Tick += (_, _) =>
+        {
+            timer.Interval = RepeatInterval;
+            TickTroughHoldPaging(grid, verticalScrollBar, state);
+        };
+
+        verticalScrollBar.SetValue(HoldStateProperty, state);
+        e.Pointer.Capture(verticalScrollBar);
+        PageOnce(grid, verticalScrollBar, state.PageDown);
+        timer.Start();
+
+        // Keep the trough repeat button from setting IsPressed and starting its own repeat.
+        e.Handled = true;
+    }
+
+    private static void TickTroughHoldPaging(DataGrid grid, ScrollBar verticalScrollBar, TroughHoldState state)
+    {
+        // A missed release, or a capture taken elsewhere, must not leave the timer paging.
+        if (state.Pointer.Captured != verticalScrollBar)
+        {
+            StopTroughHoldPaging(verticalScrollBar);
             return;
         }
 
-        var range = verticalScrollBar.Maximum - verticalScrollBar.Minimum;
-        if (double.IsNaN(range) || range <= 0)
+        var thumb = state.Track.Thumb;
+        var posY = verticalScrollBar.TranslatePoint(state.PointerPosition, state.Track)?.Y;
+        if (thumb == null || posY == null)
         {
             return;
         }
 
-        // Only a trough click should jump. Ignore clicks that land outside the track (the line
-        // step arrows) or on the thumb itself (which begins a normal drag); without this guard a
-        // Shift+click on an arrow or the thumb would fling the view to min or max. (#12438 review)
-        var posY = e.GetPosition(track).Y;
-        if (posY < 0 || posY > track.Bounds.Height)
+        // Pause (not stop) once the thumb has reached the pointer: paging resumes if the
+        // pointer moves further in the latched direction, like the Windows scroll bar.
+        if (ShouldPage(posY.Value, thumb.Bounds, state.PageDown))
+        {
+            PageOnce(grid, verticalScrollBar, state.PageDown);
+        }
+    }
+
+    private static bool ShouldPage(double posY, Rect thumbBounds, bool pageDown)
+    {
+        return pageDown ? posY > thumbBounds.Bottom : posY < thumbBounds.Top;
+    }
+
+    private static void PageOnce(DataGrid grid, ScrollBar verticalScrollBar, bool pageDown)
+    {
+        var delta = pageDown ? verticalScrollBar.LargeChange : -verticalScrollBar.LargeChange;
+        var newValue = Math.Clamp(verticalScrollBar.Value + delta, verticalScrollBar.Minimum, verticalScrollBar.Maximum);
+        if (newValue == verticalScrollBar.Value)
         {
             return;
         }
 
-        var thumb = track.Thumb;
-        if (thumb != null)
+        verticalScrollBar.Value = newValue;
+        ProcessVerticalScrollMethod?.Invoke(grid, new object[] { ScrollEventType.EndScroll });
+    }
+
+    private static TroughHoldState? GetHoldState(ScrollBar verticalScrollBar) => verticalScrollBar.GetValue(HoldStateProperty);
+
+    private static void StopTroughHoldPaging(ScrollBar verticalScrollBar)
+    {
+        if (GetHoldState(verticalScrollBar) is { } state)
         {
-            var thumbTop = thumb.Bounds.Y;
-            if (posY >= thumbTop && posY <= thumbTop + thumb.Bounds.Height)
-            {
-                return;
-            }
+            state.Timer.Stop();
+            verticalScrollBar.SetValue(HoldStateProperty, null);
+        }
+    }
+
+    // The headless test dispatcher never fires a DispatcherTimer, so the tests step the repeat
+    // by hand (DataGridScrollBarTroughPagingTests).
+    internal static void TickTroughHoldPagingForTest(DataGrid grid, ScrollBar verticalScrollBar)
+    {
+        if (GetHoldState(verticalScrollBar) is { } state)
+        {
+            TickTroughHoldPaging(grid, verticalScrollBar, state);
+        }
+    }
+
+    private static void JumpToClickPosition(DataGrid grid, ScrollBar verticalScrollBar, PointerPressedEventArgs e)
+    {
+        if (ProcessVerticalScrollMethod == null ||
+            !e.GetCurrentPoint(verticalScrollBar).Properties.IsLeftButtonPressed ||
+            !TryGetTroughPress(verticalScrollBar, e, out var track, out var posY, out _))
+        {
+            return;
         }
 
         // Center the thumb on the cursor: subtract half the thumb length and scale by the
         // travel the thumb actually has (track height minus thumb length).
-        var thumbLength = thumb?.Bounds.Height ?? 0;
+        var range = verticalScrollBar.Maximum - verticalScrollBar.Minimum;
+        var thumbLength = track.Thumb?.Bounds.Height ?? 0;
         var travel = Math.Max(1, track.Bounds.Height - thumbLength);
         var offset = posY - (thumbLength / 2.0);
         var fraction = Math.Clamp(offset / travel, 0.0, 1.0);
@@ -147,10 +273,60 @@ private static void JumpToClickPosition(DataGrid grid, ScrollBar verticalScrollB
         var newValue = verticalScrollBar.Minimum + (fraction * range);
         verticalScrollBar.Value = Math.Clamp(newValue, verticalScrollBar.Minimum, verticalScrollBar.Maximum);
 
-        ProcessVerticalScrollMethod?.Invoke(grid, new object[] { ScrollEventType.EndScroll });
+        ProcessVerticalScrollMethod.Invoke(grid, new object[] { ScrollEventType.EndScroll });
         e.Handled = true;
     }
 
+    /// <summary>
+    /// Returns the track and the press's track-relative Y for a genuine trough press, or false
+    /// for presses outside the track (the line step arrows), on the thumb itself (which begins a
+    /// normal drag), or when there is nothing to scroll (#12438 review). Shared by the shift+click
+    /// jump and the hold paging so both agree on what counts as the trough.
+    /// </summary>
+    private static bool TryGetTroughPress(ScrollBar verticalScrollBar, PointerEventArgs e, out Track track, out double posY, out bool isBelowThumb)
+    {
+        track = null!;
+        posY = 0;
+        isBelowThumb = false;
+
+        var foundTrack = verticalScrollBar.GetVisualDescendants().OfType<Track>().FirstOrDefault();
+        if (foundTrack == null || foundTrack.Bounds.Height <= 0)
+        {
+            return false;
+        }
+
+        var range = verticalScrollBar.Maximum - verticalScrollBar.Minimum;
+        if (double.IsNaN(range) || range <= 0)
+        {
+            return false;
+        }
+
+        var y = e.GetPosition(foundTrack).Y;
+        if (y < 0 || y > foundTrack.Bounds.Height)
+        {
+            return false;
+        }
+
+        var thumb = foundTrack.Thumb;
+        if (thumb != null)
+        {
+            if (y >= thumb.Bounds.Top && y <= thumb.Bounds.Bottom)
+            {
+                return false;
+            }
+
+            isBelowThumb = y > thumb.Bounds.Bottom;
+        }
+        else
+        {
+            isBelowThumb = true;
+        }
+
+        track = foundTrack;
+        posY = y;
+        return true;
+    }
+
     private static void SyncLargeChange(ScrollBar verticalScrollBar)
     {
         var viewport = verticalScrollBar.ViewportSize;


Test patch:
diff --git a/tests/UI/Logic/DataGridScrollBarTroughPagingTests.cs b/tests/UI/Logic/DataGridScrollBarTroughPagingTests.cs
new file mode 100644
index 0000000000..d56d63a529
--- /dev/null
+++ b/tests/UI/Logic/DataGridScrollBarTroughPagingTests.cs
@@ -0,0 +1,195 @@
+using System;
+using System.Linq;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Controls.Primitives;
+using Avalonia.Headless;
+using Avalonia.Headless.XUnit;
+using Avalonia.Input;
+using Avalonia.Markup.Xaml.Styling;
+using Avalonia.VisualTree;
+using Nikse.SubtitleEdit.Logic;
+
+namespace UITests.Logic;
+
+/// <summary>
+/// A press on the trough of a DataGrid's vertical scroll bar pages toward the cursor and pauses
+/// when the thumb reaches it, instead of running to the end of the list like the theme's trough
+/// RepeatButton did (#12894). The headless dispatcher never fires a DispatcherTimer, so the
+/// repeats are stepped by hand through TickTroughHoldPagingForTest.
+/// </summary>
+public class DataGridScrollBarTroughPagingTests
+{
+    private sealed record Row(int Number, string Text);
+
+    private static (Window window, DataGrid grid, ScrollBar scrollBar) BuildShownGrid()
+    {
+        // The TestApp only loads the base Fluent theme; the DataGrid template lives in its
+        // own package theme, loaded the same way Program.cs does for the real app. Must be
+        // in place before the grid attaches to a window - control themes resolve on attach.
+        if (Application.Current!.Styles.OfType<StyleInclude>().All(s => s.Source?.ToString().Contains("DataGrid") != true))
+        {
+            Application.Current.Styles.Add(new StyleInclude(new Uri("avares://Avalonia.Controls.DataGrid/Themes/Fluent.xaml", UriKind.Absolute))
+            {
+                Source = new Uri("avares://Avalonia.Controls.DataGrid/Themes/Fluent.xaml")
+            });
+        }
+
+        var grid = new DataGrid
+        {
+            ItemsSource = Enumerable.Range(1, 200).Select(i => new Row(i, $"Line {i}")).ToList(),
+            Columns =
+            {
+                new DataGridTextColumn { Header = "#", Binding = new Avalonia.Data.Binding(nameof(Row.Number)) },
+                new DataGridTextColumn { Header = "Text", Binding = new Avalonia.Data.Binding(nameof(Row.Text)) },
+            },
+        };
+        DataGridScrollBarBehavior.SetEnableTroughPaging(grid, true);
+
+        var window = new Window { Content = grid, Width = 400, Height = 300 };
+        window.Show();
+        window.UpdateLayout();
+        AvaloniaHeadlessPlatform.ForceRenderTimerTick();
+        Avalonia.Threading.Dispatcher.UIThread.RunJobs();
+
+        var scrollBar = grid.GetVisualDescendants().OfType<ScrollBar>()
+            .First(s => s.Name == "PART_VerticalScrollbar");
+        return (window, grid, scrollBar);
+    }
+
+    private static Track TrackOf(ScrollBar scrollBar) => scrollBar.GetVisualDescendants().OfType<Track>().First();
+
+    // A point centered horizontally, at the given fraction down the track, in window coordinates.
+    private static Point TrackPoint(Window window, ScrollBar scrollBar, double fraction)
+    {
+        var track = TrackOf(scrollBar);
+        return track.TranslatePoint(new Point(track.Bounds.Width / 2, track.Bounds.Height * fraction), window)!.Value;
+    }
+
+    private static Point TroughPointBelowThumb(Window window, ScrollBar scrollBar)
+    {
+        var track = TrackOf(scrollBar);
+
+        // Well below the thumb, which sits at the top while Value is 0.
+        var yInTrack = (track.Thumb!.Bounds.Bottom + track.Bounds.Height) / 2;
+        return track.TranslatePoint(new Point(track.Bounds.Width / 2, yInTrack), window)!.Value;
+    }
+
+    private static void Repeat(Window window, DataGrid grid, ScrollBar scrollBar, int ticks)
+    {
+        for (var i = 0; i < ticks; i++)
+        {
+            DataGridScrollBarBehavior.TickTroughHoldPagingForTest(grid, scrollBar);
+            window.UpdateLayout();
+        }
+    }
+
+    // True once the thumb has caught up with the pointer, so paging should have paused.
+    private static bool ThumbReached(Window window, ScrollBar scrollBar, Point windowPoint)
+    {
+        var track = TrackOf(scrollBar);
+        return track.Thumb!.Bounds.Bottom >= window.TranslatePoint(windowPoint, track)!.Value.Y;
+    }
+
+    [AvaloniaFact]
+    public void TroughPress_PagesExactlyOneViewport()
+    {
+        var (window, _, scrollBar) = BuildShownGrid();
+        Assert.True(scrollBar.Maximum > 0, "grid should have enough rows to scroll");
+        Assert.True(scrollBar.LargeChange > 1, "LargeChange should follow the viewport, not RangeBase's default");
+        Assert.Equal(0, scrollBar.Value);
+
+        var point = TroughPointBelowThumb(window, scrollBar);
+        window.MouseDown(point, MouseButton.Left);
+
+        // One immediate page of one viewport; the repeat has not been stepped yet.
+        Assert.Equal(scrollBar.LargeChange, scrollBar.Value, 3);
+
+        window.MouseUp(point, MouseButton.Left);
+    }
+
+    [AvaloniaFact]
+    public void TroughHold_PausesWhenThumbReachesPointer()
+    {
+        var (window, grid, scrollBar) = BuildShownGrid();
+
+        var point = TroughPointBelowThumb(window, scrollBar);
+        window.MouseDown(point, MouseButton.Left);
+        Repeat(window, grid, scrollBar, 50);
+
+        Assert.True(ThumbReached(window, scrollBar, point), "the thumb should have reached the pointer");
+        Assert.True(scrollBar.Value > 0, "the hold should have paged");
+        Assert.True(scrollBar.Value < scrollBar.Maximum, $"paging ran past the pointer to the end ({scrollBar.Value} of {scrollBar.Maximum})");
+
+        window.MouseUp(point, MouseButton.Left);
+    }
+
+    [AvaloniaFact]
+    public void TroughHold_ResumesWhenPointerMovesFurther()
+    {
+        var (window, grid, scrollBar) = BuildShownGrid();
+
+        var point = TroughPointBelowThumb(window, scrollBar);
+        window.MouseDown(point, MouseButton.Left);
+        Repeat(window, grid, scrollBar, 50);
+        var pausedValue = scrollBar.Value;
+
+        var lower = TrackPoint(window, scrollBar, 0.95);
+        window.MouseMove(lower);
+        Repeat(window, grid, scrollBar, 50);
+
+        Assert.True(scrollBar.Value > pausedValue, "paging should resume when the pointer moves further down");
+
+        window.MouseUp(lower, MouseButton.Left);
+    }
+
+    [AvaloniaFact]
+    public void TroughHold_DoesNotReverseWhenPointerMovesBack()
+    {
+        var (window, grid, scrollBar) = BuildShownGrid();
+
+        var point = TroughPointBelowThumb(window, scrollBar);
+        window.MouseDown(point, MouseButton.Left);
+        Repeat(window, grid, scrollBar, 50);
+        var pausedValue = scrollBar.Value;
+
+        window.MouseMove(TrackPoint(window, scrollBar, 0.0));
+        Repeat(window, grid, scrollBar, 10);
+
+        Assert.Equal(pausedValue, scrollBar.Value);
+
+        window.MouseUp(point, MouseButton.Left);
+    }
+
+    [AvaloniaFact]
+    public void TroughRelease_StopsPaging()
+    {
+        var (window, grid, scrollBar) = BuildShownGrid();
+
+        var point = TroughPointBelowThumb(window, scrollBar);
+        window.MouseDown(point, MouseButton.Left);
+        window.MouseUp(point, MouseButton.Left);
+
+        var valueAfterRelease = scrollBar.Value;
+        Repeat(window, grid, scrollBar, 10);
+
+        Assert.Equal(valueAfterRelease, scrollBar.Value);
+    }
+
+    [AvaloniaFact]
+    public void ThumbPress_DoesNotPage()
+    {
+        var (window, grid, scrollBar) = BuildShownGrid();
+
+        var thumb = TrackOf(scrollBar).Thumb!;
+        var thumbCenter = thumb.TranslatePoint(
+            new Point(thumb.Bounds.Width / 2, thumb.Bounds.Height / 2), window)!.Value;
+
+        window.MouseDown(thumbCenter, MouseButton.Left);
+        Repeat(window, grid, scrollBar, 10);
+
+        Assert.Equal(0, scrollBar.Value);
+
+        window.MouseUp(thumbCenter, MouseButton.Left);
+    }
+}