Skip to content

Commit 3214793

Browse files
authored
Fix Shift+wheel vertical scroll leak (#207)
1 parent 63a1c32 commit 3214793

3 files changed

Lines changed: 48 additions & 2 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ All notable user-visible changes to Hunk are documented in this file.
1111
### Fixed
1212

1313
- Smoothed mouse-wheel review scrolling so small diffs stay precise while sustained wheel gestures still speed up.
14+
- Fixed Shift+mouse-wheel horizontal scrolling so it no longer leaks a one-line vertical scroll in some terminals.
1415

1516
## [0.9.4] - 2026-04-14
1617

src/ui/AppHost.interactions.test.tsx

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -701,6 +701,40 @@ describe("App interactions", () => {
701701
}
702702
});
703703

704+
test("shift plus native horizontal wheel events do not move the vertical review position", async () => {
705+
const setup = await testRender(<AppHost bootstrap={createWrapScrollBootstrap()} />, {
706+
width: 92,
707+
height: 20,
708+
});
709+
710+
try {
711+
await flush(setup);
712+
713+
let frame = setup.captureCharFrame();
714+
const initialTopLine = firstVisibleAddedLineNumber(frame);
715+
expect(initialTopLine).toBeTruthy();
716+
expect(frame).not.toContain("viewport anchoring");
717+
718+
for (let index = 0; index < 8; index += 1) {
719+
await act(async () => {
720+
await setup.mockMouse.scroll(60, 10, "right", { modifiers: { shift: true } });
721+
});
722+
await flush(setup);
723+
frame = setup.captureCharFrame();
724+
if (frame.includes("viewport anchoring")) {
725+
break;
726+
}
727+
}
728+
729+
expect(frame).toContain("viewport anchoring");
730+
expect(firstVisibleAddedLineNumber(frame)).toBe(initialTopLine);
731+
} finally {
732+
await act(async () => {
733+
setup.renderer.destroy();
734+
});
735+
}
736+
});
737+
704738
test("wrap toggles reset the horizontal code offset", async () => {
705739
const setup = await testRender(<AppHost bootstrap={createWrapBootstrap()} />, {
706740
width: 92,

src/ui/components/panes/DiffPane.tsx

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,7 @@ export function DiffPane({
204204

205205
const preservedScrollTop = scrollBox.scrollTop;
206206
const preservedScrollLeft = scrollBox.scrollLeft;
207+
const scrollInfo = event.scroll;
207208

208209
if (direction === "left") {
209210
onScrollCodeHorizontally(-1);
@@ -217,15 +218,25 @@ export function DiffPane({
217218
return;
218219
}
219220

220-
// OpenTUI runs ScrollBox's own wheel handler after this listener and it does not honor
221-
// preventDefault(), so restore the pre-event viewport position on the next microtask.
221+
// OpenTUI runs ScrollBox's own wheel handler after this listener and it ignores
222+
// preventDefault(). Zero the wheel delta first so native Shift+Wheel left/right events
223+
// cannot be remapped back into vertical scroll, then restore the viewport and clear any
224+
// residual fractional state on the next microtask as a final guard.
225+
if (scrollInfo) {
226+
scrollInfo.delta = 0;
227+
}
228+
222229
queueMicrotask(() => {
223230
const currentScrollBox = scrollRef.current;
224231
if (!currentScrollBox) {
225232
return;
226233
}
227234

228235
currentScrollBox.scrollTo({ x: preservedScrollLeft, y: preservedScrollTop });
236+
currentScrollBox.scrollAcceleration.reset();
237+
(
238+
currentScrollBox as unknown as { resetScrollAccumulators?: () => void }
239+
).resetScrollAccumulators?.();
229240
});
230241

231242
event.preventDefault();

0 commit comments

Comments
 (0)