From 34452e4517442c87a6f137737c2c71dfc5fa7b2d Mon Sep 17 00:00:00 2001 From: Simon Cropp Date: Sun, 23 Aug 2026 09:04:32 +1000 Subject: [PATCH] Keep what a wheel message leaves over The canvas divided each wheel message by 120 and dropped the remainder. A wheel click sends exactly 120 so that worked, but a precision touchpad sends a fraction of one per message - ten to sixty for ordinary two finger movement - and every one of those divided to zero. So scrolling the panes with a touchpad did nothing at all, while the docked scrollbar beside them worked, that one accumulating internally. WheelNotches keeps the remainder between messages, and is its own type for the reason QueueTips is: the thing that matters is an accumulation across messages, and that can be tested where a window cannot. The Windows head only. The same fault is in the Mac and Linux heads, in native/swift and native/src, and fixing those means rebuilding and committing the binaries, which is a change of its own. --- .../WheelNotchesTests.cs | 83 +++++++++++++++++++ src/DiffEngineViewer.Windows/ViewerCanvas.cs | 8 +- src/DiffEngineViewer.Windows/WheelNotches.cs | 30 +++++++ 3 files changed, 118 insertions(+), 3 deletions(-) create mode 100644 src/DiffEngineViewer.Windows.Tests/WheelNotchesTests.cs create mode 100644 src/DiffEngineViewer.Windows/WheelNotches.cs 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; + } +}