From 161cbad85b5c404212dc66aa539bca14c16c3306 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sun, 18 Oct 2026 23:23:59 -0400 Subject: [PATCH] fix(desktop): disable animated wheel scrolling on linux Chromium animates each mouse-wheel notch on Linux, unlike native Linux apps. Append disable-smooth-scrolling to the desktop app on Linux so wheel deltas apply immediately. Make ScrollView's viewport wheel listener and the session panel's scrollbar-reveal wheel listener passive, so the compositor no longer waits on the main thread before scrolling. Remove the home route's viewport wheel handler, which could never cancel because its target was always inside the viewport. Attach the desktop window wheel listener only while pinch zoom is enabled; main's zoom-changed handler covers Ctrl+wheel otherwise. ScrollView keyboard scrolling (PageUp/Down, Home/End, arrows, Space) now jumps instantly instead of animating. --- packages/app/src/home/route.tsx | 1 - packages/app/src/home/scroll.ts | 6 ---- packages/app/src/session/screen.tsx | 2 +- .../desktop/src/main/lifecycle/configure.ts | 3 ++ packages/desktop/src/renderer/window/zoom.ts | 29 ++++++++++++------- packages/ui/src/components/scroll-view.tsx | 29 ++++++++++--------- 6 files changed, 39 insertions(+), 31 deletions(-) diff --git a/packages/app/src/home/route.tsx b/packages/app/src/home/route.tsx index 188210be169..6626e90f8f7 100644 --- a/packages/app/src/home/route.tsx +++ b/packages/app/src/home/route.tsx @@ -37,7 +37,6 @@ export function Home() { thumbHoverTarget={scroll.viewport.hoverTarget()} viewportRef={scroll.viewport.setViewport} onScroll={(event) => scroll.viewport.update(event.currentTarget.scrollTop)} - onWheel={scroll.viewport.containOuterWheel} >
) }, update, containWheel, - containOuterWheel: (event: WheelEvent) => { - if (!viewport) return - - if (event.target instanceof Node && viewport.contains(event.target)) return - containWheel(event) - }, }, header: { setContent: (element: HTMLDivElement) => { diff --git a/packages/app/src/session/screen.tsx b/packages/app/src/session/screen.tsx index d5916634f61..4e0993781c5 100644 --- a/packages/app/src/session/screen.tsx +++ b/packages/app/src/session/screen.tsx @@ -355,7 +355,7 @@ function SessionScreenContent(props: { data-scrollbar-hidden={store.timelineScrollbarHidden || store.sideWidthMotion} onPointerMove={revealTimelineScrollbar} onPointerDown={revealTimelineScrollbar} - onWheel={revealTimelineScrollbar} + on:wheel={{ passive: true, handleEvent: revealTimelineScrollbar }} onKeyDown={revealTimelineScrollbar} onTransitionRun={trackSideWidthMotion} onTransitionEnd={trackSideWidthMotion} diff --git a/packages/desktop/src/main/lifecycle/configure.ts b/packages/desktop/src/main/lifecycle/configure.ts index 878d184a4f0..488eca0fe3a 100644 --- a/packages/desktop/src/main/lifecycle/configure.ts +++ b/packages/desktop/src/main/lifecycle/configure.ts @@ -26,6 +26,9 @@ export function configureApplication() { const features = app.commandLine.getSwitchValue("enable-features") app.commandLine.appendSwitch("enable-features", features ? `${jsCallStackFeature},${features}` : jsCallStackFeature) + // Chromium animates each mouse-wheel notch on Linux; native Linux apps scroll by the full delta immediately. + if (process.platform === "linux") app.commandLine.appendSwitch("disable-smooth-scrolling") + if (!app.isPackaged) app.commandLine.appendSwitch("remote-debugging-port", process.env.OPENCODE_DESKTOP_REMOTE_DEBUGGING_PORT ?? "9222") diff --git a/packages/desktop/src/renderer/window/zoom.ts b/packages/desktop/src/renderer/window/zoom.ts index 1a21863d820..28f1a2b7623 100644 --- a/packages/desktop/src/renderer/window/zoom.ts +++ b/packages/desktop/src/renderer/window/zoom.ts @@ -70,16 +70,19 @@ api.onZoomFactorChanged((factor) => { void api.getPinchZoomEnabled().then((enabled) => { pinchZoomEnabled = enabled + syncWheelPinchListener() }) api.onPinchZoomEnabledChanged((enabled) => { pinchZoomEnabled = enabled resetWheelPinch() + syncWheelPinchListener() }) const setPinchZoomEnabled = (enabled: boolean) => { pinchZoomEnabled = enabled resetWheelPinch() + syncWheelPinchListener() return api.setPinchZoomEnabled(enabled) } @@ -129,18 +132,24 @@ const updateWheelPinch = (event: WheelEvent) => { applyZoom(clamp(wheelPinch.startZoom - (wheelPinch.totalDelta / WHEEL_PINCH_THRESHOLD) * WHEEL_PINCH_STEP)) } -window.addEventListener( - "wheel", - (event) => { - if (!pinchZoomEnabled) return +const onWheelPinch = (event: WheelEvent) => { + if (!event.ctrlKey) return - if (!event.ctrlKey) return + event.preventDefault() + updateWheelPinch(event) +} - event.preventDefault() - updateWheelPinch(event) - }, - { passive: false }, -) +// A cancelable window wheel listener makes every scroll gesture wait for the main thread, so it +// is only attached while pinch zoom is on. Main's zoom-changed handler covers Ctrl+wheel otherwise. +const syncWheelPinchListener = () => { + if (pinchZoomEnabled) { + window.addEventListener("wheel", onWheelPinch, { passive: false }) + + return + } + + window.removeEventListener("wheel", onWheelPinch) +} window.addEventListener("keydown", (event) => { if (!(OS_NAME === "macos" ? event.metaKey : event.ctrlKey)) return diff --git a/packages/ui/src/components/scroll-view.tsx b/packages/ui/src/components/scroll-view.tsx index 8d92074637a..e46cec7af82 100644 --- a/packages/ui/src/components/scroll-view.tsx +++ b/packages/ui/src/components/scroll-view.tsx @@ -397,8 +397,7 @@ export function ScrollView(props: ScrollViewProps) { // Keybinds implementation // We ensure the viewport has a tabindex so it can receive focus - // We can also explicitly catch PageUp/Down if we want smooth scroll or specific behavior, - // but native usually handles this perfectly. Let's explicitly ensure it behaves well. + // Keyboard scrolling jumps immediately, like the wheel, instead of animating. const onKeyDown = (e: KeyboardEvent) => { // If user is focused on an input inside the scroll view, don't hijack keys if (document.activeElement && ["INPUT", "TEXTAREA", "SELECT"].includes(document.activeElement.tagName)) { @@ -434,27 +433,27 @@ export function ScrollView(props: ScrollViewProps) { switch (next) { case "page-down": e.preventDefault() - viewportRef.scrollBy({ top: scrollAmount, behavior: "smooth" }) + viewportRef.scrollBy({ top: scrollAmount }) break case "page-up": e.preventDefault() - viewportRef.scrollBy({ top: -scrollAmount, behavior: "smooth" }) + viewportRef.scrollBy({ top: -scrollAmount }) break case "home": e.preventDefault() - viewportRef.scrollTo({ top: 0, behavior: "smooth" }) + viewportRef.scrollTo({ top: 0 }) break case "end": e.preventDefault() - viewportRef.scrollTo({ top: viewportRef.scrollHeight, behavior: "smooth" }) + viewportRef.scrollTo({ top: viewportRef.scrollHeight }) break case "up": e.preventDefault() - viewportRef.scrollBy({ top: -lineAmount, behavior: "smooth" }) + viewportRef.scrollBy({ top: -lineAmount }) break case "down": e.preventDefault() - viewportRef.scrollBy({ top: lineAmount, behavior: "smooth" }) + viewportRef.scrollBy({ top: lineAmount }) break } } @@ -484,13 +483,17 @@ export function ScrollView(props: ScrollViewProps) { if (typeof events.onScroll === "function") events.onScroll(e as any) }} - onWheel={(e) => { - markScrolling() - const handler = events.onWheel + // Passive so the compositor never waits on the main thread to scroll; onWheel cannot cancel. + on:wheel={{ + passive: true, + handleEvent: (e) => { + markScrolling() + const handler = events.onWheel - if (typeof handler === "function") handler(e as any) + if (typeof handler === "function") handler(e as any) - if (Array.isArray(handler)) handler[0](handler[1], e as any) + if (Array.isArray(handler)) handler[0](handler[1], e as any) + }, }} onTouchStart={events.onTouchStart as any} onTouchMove={events.onTouchMove as any}