diff --git a/src/DiffEngineViewer.Windows.Tests/WheelNotchesTests.cs b/src/DiffEngineViewer.Windows.Tests/WheelNotchesTests.cs
new file mode 100644
index 00000000..16532290
--- /dev/null
+++ b/src/DiffEngineViewer.Windows.Tests/WheelNotchesTests.cs
@@ -0,0 +1,83 @@
+///
+/// Wheel messages into notches. A wheel click sends a whole one; a precision touchpad sends a
+/// fraction of one per message, many times a second, for as long as the fingers move.
+///
+public class WheelNotchesTests
+{
+ [Test]
+ public async Task A_wheel_click_is_a_notch()
+ {
+ var notches = new WheelNotches(120);
+
+ await Assert.That(notches.Add(120)).IsEqualTo(1);
+ }
+
+ ///
+ /// The reported bug: each message divided on its own is zero notches, so two finger scrolling
+ /// moved nothing at all.
+ ///
+ [Test]
+ public async Task Small_movements_add_up_to_one()
+ {
+ var notches = new WheelNotches(120);
+
+ var before = new[] { notches.Add(20), notches.Add(20), notches.Add(20), notches.Add(20), notches.Add(20) };
+ var last = notches.Add(20);
+
+ await Assert.That(before).IsEquivalentTo([0, 0, 0, 0, 0]);
+ await Assert.That(last).IsEqualTo(1);
+ }
+
+ [Test]
+ public async Task Small_movements_add_up_the_other_way_too()
+ {
+ var notches = new WheelNotches(120);
+
+ var before = new[] { notches.Add(-40), notches.Add(-40) };
+ var last = notches.Add(-40);
+
+ await Assert.That(before).IsEquivalentTo([0, 0]);
+ await Assert.That(last).IsEqualTo(-1);
+ }
+
+ ///
+ /// What is left over is kept, rather than each notch starting from nothing.
+ ///
+ [Test]
+ public async Task Keeps_the_remainder_across_notches()
+ {
+ var notches = new WheelNotches(120);
+
+ notches.Add(100);
+ var first = notches.Add(100);
+ var second = notches.Add(100);
+
+ await Assert.That(first).IsEqualTo(1);
+ // 300 in, one notch out, 60 held. The third message crosses 240
+ await Assert.That(second).IsEqualTo(1);
+ }
+
+ ///
+ /// A movement back the other way undoes what is held, rather than each direction keeping a
+ /// debt that the next movement has to pay off before anything happens.
+ ///
+ [Test]
+ public async Task A_movement_back_undoes_what_is_held()
+ {
+ var notches = new WheelNotches(120);
+
+ notches.Add(60);
+ notches.Add(-60);
+ var up = new[] { notches.Add(60), notches.Add(60) };
+
+ await Assert.That(up).IsEquivalentTo([0, 1]);
+ }
+
+ [Test]
+ public async Task A_fast_flick_is_several_notches()
+ {
+ var notches = new WheelNotches(120);
+
+ await Assert.That(notches.Add(360)).IsEqualTo(3);
+ }
+}
diff --git a/src/DiffEngineViewer.Windows/ViewerCanvas.cs b/src/DiffEngineViewer.Windows/ViewerCanvas.cs
index 70fb4fe7..fc8b926a 100644
--- a/src/DiffEngineViewer.Windows/ViewerCanvas.cs
+++ b/src/DiffEngineViewer.Windows/ViewerCanvas.cs
@@ -501,13 +501,15 @@ protected override void OnMouseLeave(EventArgs e)
tips.Forget(this);
}
+ readonly WheelNotches notches = new(SystemInformation.MouseWheelScrollDelta);
+
protected override void OnMouseWheel(MouseEventArgs e)
{
base.OnMouseWheel(e);
- var notches = e.Delta / SystemInformation.MouseWheelScrollDelta;
- if (notches != 0)
+ var scrolled = notches.Add(e.Delta);
+ if (scrolled != 0)
{
- Scrolled?.Invoke(notches);
+ Scrolled?.Invoke(scrolled);
}
}
diff --git a/src/DiffEngineViewer.Windows/WheelNotches.cs b/src/DiffEngineViewer.Windows/WheelNotches.cs
new file mode 100644
index 00000000..03aa3bf1
--- /dev/null
+++ b/src/DiffEngineViewer.Windows/WheelNotches.cs
@@ -0,0 +1,30 @@
+///
+/// Turns wheel messages into whole notches, keeping what is left over.
+///
+/// A wheel click sends 120, but a precision touchpad sends a fraction of that per message - ten to
+/// sixty for an ordinary two finger movement. Dividing each message on its own and dropping the
+/// remainder threw all of those away, so the canvas did not move at all under a touchpad while the
+/// docked scrollbar, which accumulates internally, worked.
+///
+///
+/// Its own type for the same reason is: what matters is an accumulation
+/// across messages, and that can be tested where a window cannot.
+///
+///
+sealed class WheelNotches(int perNotch)
+{
+ int remainder;
+
+ ///
+ /// The whole notches this message completes, which is usually none of one.
+ ///
+ public int Add(int delta)
+ {
+ remainder += delta;
+ // Truncates toward zero, so a run of small movements one way accumulates and a movement
+ // back the other way undoes it, rather than each direction keeping a debt of its own.
+ var notches = remainder / perNotch;
+ remainder -= notches * perNotch;
+ return notches;
+ }
+}