From cc7ca822d045cb65c22df3edb17b4f90b62f8cd6 Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Sat, 18 Apr 2026 08:30:21 -0400 Subject: [PATCH] fix(scroll): stop Shift+wheel vertical leak --- CHANGELOG.md | 1 + src/ui/AppHost.interactions.test.tsx | 34 ++++++++++++++++++++++++++++ src/ui/components/panes/DiffPane.tsx | 15 ++++++++++-- 3 files changed, 48 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 550d6df..05c46ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ All notable user-visible changes to Hunk are documented in this file. ### Fixed - Smoothed mouse-wheel review scrolling so small diffs stay precise while sustained wheel gestures still speed up. +- Fixed Shift+mouse-wheel horizontal scrolling so it no longer leaks a one-line vertical scroll in some terminals. ## [0.9.4] - 2026-04-14 diff --git a/src/ui/AppHost.interactions.test.tsx b/src/ui/AppHost.interactions.test.tsx index a426868..1104b42 100644 --- a/src/ui/AppHost.interactions.test.tsx +++ b/src/ui/AppHost.interactions.test.tsx @@ -701,6 +701,40 @@ describe("App interactions", () => { } }); + test("shift plus native horizontal wheel events do not move the vertical review position", async () => { + const setup = await testRender(, { + width: 92, + height: 20, + }); + + try { + await flush(setup); + + let frame = setup.captureCharFrame(); + const initialTopLine = firstVisibleAddedLineNumber(frame); + expect(initialTopLine).toBeTruthy(); + expect(frame).not.toContain("viewport anchoring"); + + for (let index = 0; index < 8; index += 1) { + await act(async () => { + await setup.mockMouse.scroll(60, 10, "right", { modifiers: { shift: true } }); + }); + await flush(setup); + frame = setup.captureCharFrame(); + if (frame.includes("viewport anchoring")) { + break; + } + } + + expect(frame).toContain("viewport anchoring"); + expect(firstVisibleAddedLineNumber(frame)).toBe(initialTopLine); + } finally { + await act(async () => { + setup.renderer.destroy(); + }); + } + }); + test("wrap toggles reset the horizontal code offset", async () => { const setup = await testRender(, { width: 92, diff --git a/src/ui/components/panes/DiffPane.tsx b/src/ui/components/panes/DiffPane.tsx index 9c1b6b4..d93f7ef 100644 --- a/src/ui/components/panes/DiffPane.tsx +++ b/src/ui/components/panes/DiffPane.tsx @@ -204,6 +204,7 @@ export function DiffPane({ const preservedScrollTop = scrollBox.scrollTop; const preservedScrollLeft = scrollBox.scrollLeft; + const scrollInfo = event.scroll; if (direction === "left") { onScrollCodeHorizontally(-1); @@ -217,8 +218,14 @@ export function DiffPane({ return; } - // OpenTUI runs ScrollBox's own wheel handler after this listener and it does not honor - // preventDefault(), so restore the pre-event viewport position on the next microtask. + // OpenTUI runs ScrollBox's own wheel handler after this listener and it ignores + // preventDefault(). Zero the wheel delta first so native Shift+Wheel left/right events + // cannot be remapped back into vertical scroll, then restore the viewport and clear any + // residual fractional state on the next microtask as a final guard. + if (scrollInfo) { + scrollInfo.delta = 0; + } + queueMicrotask(() => { const currentScrollBox = scrollRef.current; if (!currentScrollBox) { @@ -226,6 +233,10 @@ export function DiffPane({ } currentScrollBox.scrollTo({ x: preservedScrollLeft, y: preservedScrollTop }); + currentScrollBox.scrollAcceleration.reset(); + ( + currentScrollBox as unknown as { resetScrollAccumulators?: () => void } + ).resetScrollAccumulators?.(); }); event.preventDefault();