Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
224 changes: 200 additions & 24 deletions src/ui/Logic/DataGridScrollBarBehavior.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
using Avalonia.Controls.Primitives;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Threading;
using Avalonia.VisualTree;

namespace Nikse.SubtitleEdit.Logic;
Expand All @@ -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);
Expand All @@ -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);
Expand Down Expand Up @@ -89,68 +103,230 @@ 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);
}
Comment on lines +209 to 214
}

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);

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;
Expand Down
Loading