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. /// 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 WiredProperty = AvaloniaProperty.RegisterAttached("TroughPagingWired", typeof(DataGridScrollBarBehavior)); + // Non-null only while the left button is held on that scroll bar's trough. + private static readonly AttachedProperty HoldStateProperty = + AvaloniaProperty.RegisterAttached("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().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; } + /// + /// 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. + /// + 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().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; 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; + +/// +/// 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. +/// +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().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() + .First(s => s.Name == "PART_VerticalScrollbar"); + return (window, grid, scrollBar); + } + + private static Track TrackOf(ScrollBar scrollBar) => scrollBar.GetVisualDescendants().OfType().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); + } +}