Skip to content

Commit 460dd1b

Browse files
committed
fix: Autoscroll
1 parent 9a0041a commit 460dd1b

3 files changed

Lines changed: 83 additions & 10 deletions

File tree

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
using PostCodeSerialMonitor.Utils;
2+
using Xunit;
3+
4+
namespace PostCodeSerialMonitor.Tests;
5+
6+
public class AutoScrollTrackerTests
7+
{
8+
[Fact]
9+
public void OnScrollChanged_ContentGrowsFasterThanScrollToEndCanCatchUp_StaysAutoScrolled()
10+
{
11+
var tracker = new AutoScrollTracker();
12+
13+
// Simulates a burst of fast-arriving log lines: new content arrives, so the caller asks
14+
// whether it should scroll to the end (as MainWindow does on every ItemsRepeater layout
15+
// update) and does so, which is what will trigger the ScrollChanged below.
16+
Assert.True(tracker.ShouldScrollToEnd());
17+
18+
// Before that resulting ScrollChanged is processed, more lines already grew the extent
19+
// further (104 -> 130) while the offset still reflects the smaller extent it was set
20+
// against (54). The user never touched the scrollbar, so auto-scroll should not detach.
21+
tracker.OnScrollChanged(offsetY: 54, extentHeight: 130, viewportHeight: 50);
22+
23+
Assert.True(tracker.AutoScroll);
24+
}
25+
26+
[Fact]
27+
public void OnScrollChanged_UserScrollsAwayWithoutScrollToEnd_Detaches()
28+
{
29+
var tracker = new AutoScrollTracker();
30+
31+
// No ShouldScrollToEnd() call precedes this - the user dragged the scrollbar up themselves.
32+
tracker.OnScrollChanged(offsetY: 0, extentHeight: 130, viewportHeight: 50);
33+
34+
Assert.False(tracker.AutoScroll);
35+
}
36+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
namespace PostCodeSerialMonitor.Utils;
2+
3+
// Decides whether a log view should keep auto-scrolling to the bottom as new
4+
// entries arrive, versus staying put because the user scrolled away.
5+
public class AutoScrollTracker
6+
{
7+
// Distance from the bottom (in pixels) still considered "at the bottom", to absorb layout jitter
8+
// from item virtualization/resizing so autoscroll doesn't flicker on/off during normal updates.
9+
private const double BottomThreshold = 4;
10+
11+
// Set right before we call ScrollToEnd() ourselves, so the ScrollChanged it triggers isn't
12+
// mistaken for the user scrolling away (which happens if content keeps growing between our
13+
// call and that event, leaving the offset behind a moving extent).
14+
private bool _isProgrammaticScroll;
15+
16+
public bool AutoScroll { get; private set; } = true;
17+
18+
public bool ShouldScrollToEnd()
19+
{
20+
if (!AutoScroll)
21+
return false;
22+
23+
_isProgrammaticScroll = true;
24+
return true;
25+
}
26+
27+
public void OnScrollChanged(double offsetY, double extentHeight, double viewportHeight)
28+
{
29+
if (_isProgrammaticScroll)
30+
{
31+
_isProgrammaticScroll = false;
32+
return;
33+
}
34+
35+
AutoScroll = offsetY >= extentHeight - viewportHeight - BottomThreshold;
36+
}
37+
38+
public void Reset()
39+
{
40+
AutoScroll = true;
41+
}
42+
}

PostCodeSerialMonitor/Views/MainWindow.axaml.cs

Lines changed: 5 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ namespace PostCodeSerialMonitor.Views;
99

1010
public partial class MainWindow : Window
1111
{
12-
private bool _autoScroll = true;
12+
private readonly AutoScrollTracker _autoScroll = new();
1313
private ScrollViewer? _scrollViewer;
1414
private ItemsRepeater? _itemsRepeater;
1515

@@ -38,33 +38,28 @@ protected override void OnLoaded(RoutedEventArgs e)
3838
}
3939
}
4040

41-
// Distance from the bottom (in pixels) still considered "at the bottom", to absorb layout jitter
42-
// from item virtualization/resizing so autoscroll doesn't flicker on/off during normal updates.
43-
private const double BottomThreshold = 4;
44-
4541
private void OnScrollChanged(object? sender, ScrollChangedEventArgs e)
4642
{
4743
if (_scrollViewer == null) return;
4844

49-
var atBottom = _scrollViewer.Offset.Y >= _scrollViewer.Extent.Height - _scrollViewer.Viewport.Height - BottomThreshold;
50-
_autoScroll = atBottom;
45+
_autoScroll.OnScrollChanged(_scrollViewer.Offset.Y, _scrollViewer.Extent.Height, _scrollViewer.Viewport.Height);
5146
if (AutoScrollButton != null)
5247
{
53-
AutoScrollButton.IsVisible = !atBottom;
48+
AutoScrollButton.IsVisible = !_autoScroll.AutoScroll;
5449
}
5550
}
5651

5752
private void OnItemsRepeaterLayoutUpdated(object? sender, EventArgs e)
5853
{
59-
if (_autoScroll && _scrollViewer != null)
54+
if (_autoScroll.ShouldScrollToEnd() && _scrollViewer != null)
6055
{
6156
_scrollViewer.ScrollToEnd();
6257
}
6358
}
6459

6560
private void OnAutoScrollButtonClick(object? sender, RoutedEventArgs e)
6661
{
67-
_autoScroll = true;
62+
_autoScroll.Reset();
6863
if (AutoScrollButton != null)
6964
{
7065
AutoScrollButton.IsVisible = false;

0 commit comments

Comments
 (0)