Skip to content

input: wheel events are decoded as drag while a mouse button is held, so nothing scrolls during a drag #1911

Closed as not planned

Description

@mleivaDesarrollos

Summary

A wheel event that arrives while a mouse button is held is decoded as drag (SGR) or move (legacy), with scroll: undefined. Any gesture of the form "hold the button and scroll" — extending a selection, dragging a scrollbar, drag-scrolling a viewport — therefore produces no scroll event at all, so the host never scrolls. The wheel is not ignored: it is reported as pointer motion.

Where

packages/core/src/lib/parse.mouse.ts, decodeSgrEvent:

const button = rawButtonCode & 3
const isScroll = (rawButtonCode & 64) !== 0
const scrollDirection = !isScroll ? undefined : MouseParser.SCROLL_DIRECTIONS[button]

const isMotion = (rawButtonCode & 32) !== 0
…
let type: MouseEventType
let scrollInfo: ScrollInfo | undefined

if (isMotion) {
  const isDragging = this.mouseButtonsPressed.size > 0
  if (button === 3) {
    type = "move"
  } else if (isDragging) {
    type = "drag"
  } else {
    type = "move"
  }
} else if (isScroll && pressRelease === "M") {
  type = "scroll"
  scrollInfo = { direction: scrollDirection!, delta: 1 }
}

The motion branch is tested first, so the scroll branch is only reachable when the motion bit is clear — i.e. only when no button is held.

SGR encodes the wheel in bits 6 (wheel) and the held button plus bit 5 (motion) in the same Cb byte:

Gesture Cb isScroll isMotion decoded
wheel up, nothing held 64 true false scroll, direction up ✔
wheel up, left button held 96 true true drag, scroll: undefined ✘

decodeBasicEvent has the same ordering (if (isMotion) → "move" before else if (isScroll) → "scroll"), so the legacy path drops the wheel as well.

Expected Behavior

A wheel event reports type: "scroll" with a direction whether or not a button is held. Held buttons are already tracked separately in mouseButtonsPressed, and the scroll branch deliberately does not add to it, so reordering does not disturb drag tracking.

Reproduction

Unit level, no terminal needed — mirror the table above through MouseParser:

const parser = new MouseParser()
// wheel up, no button held
parser.parse("\x1b[<64;10;5M")   // → { type: "scroll", scroll: { direction: "up" } }
// wheel up, left button held (Cb = 64 | 32)
parser.parse("\x1b[<96;10;5M")   // → { type: "drag", scroll: undefined }   ← expected "scroll"
// wheel down, left button held
parser.parse("\x1b[<97;10;5M")   // → { type: "drag", scroll: undefined }   ← expected "scroll", down

End to end, in a host with a scrollable embedded terminal: hold the left button on the pane's text and scroll the wheel. The viewport does not move, where a plain wheel with no button held scrolls it (opencode TUI terminal pane reproduces this; reported there as anomalyco/opencode#61053, where the visible effect is a selection that can never extend past one screen).

Fix

Test the scroll bit before motion in both decoders:

if (isScroll && pressRelease === "M") {
  type = "scroll"
  scrollInfo = { direction: scrollDirection!, delta: 1 }
} else if (isMotion) {
  …
}

That restores wheel events during a drag. Whether a drag should also autoscroll at the viewport edges is a separate, host-level concern; this issue is only the dropped event.

Impact

Any OpenTUI host that scrolls on wheel — embedded terminals, scrollboxes, anything with onMouseScroll — cannot be scrolled while the pointer is dragging, which is exactly when a user tries to extend a selection past the visible area. Not specific to one app.

Activity

  1. mleivaDesarrollos commented on Nov 2, 2026

    @mleivaDesarrollos
    Author

    Correction to my own proposal, before anyone spends time on it: "test isScroll before isMotion" would regress #830 / #661.

    git log on parse.mouse.ts:

    2026-03-19 ca4a407 fix(core): prefer motion over wheel bits in mouse parsing (#830)
    

    That change deliberately prefers motion when both bits are set, because URxvt sends packets with the motion and wheel bits together while the pointer moves during a scroll — treating them as wheel input turned small movements into extra scroll and made the screen jump (#661). The ordering is intentional, so a plain swap is not the fix.

    One signal separates the two cases: whether a button is currently held.

    const dragging = this.mouseButtonsPressed.size > 0
    
    if (isScroll && (dragging || !isMotion)) {
      type = "scroll"
      scrollInfo = { direction: scrollDirection!, delta: 1 }
    } else if (isMotion) {
      …
    }
    • URxvt scroll with the pointer moving, no button held → dragging false, isMotion true → motion wins, Mouse moves translate to mouse scrolling under URxvt #661 stays fixed.
    • Wheel while dragging (Cb = 64 | 32 | held button) → dragging true → scroll, so a host can move its viewport mid-drag.

    The same shape applies to decodeBasicEvent, which drops the wheel for the same reason (if (isMotion) → "move" before else if (isScroll)); it has no drag/move distinction, so there the test is simply whether a button bit is set on the packet.

    Trade-off that is yours to make: URxvt scrolling while a button is held would then report scroll instead of motion. I would defend that — the user is dragging and scrolling at once — but it is a deliberate narrowing of the #830 heuristic rather than a pure reorder, and it is fair to want the wheel to win only when a button is held.

    The repro table in the body still stands; only the proposed fix changes.

  2. mleivaDesarrollos commented on Nov 2, 2026

    @mleivaDesarrollos
    Author

    Confirmed against a released build rather than a source reading: opencode 2.2.5 (upgraded from 2.1.4) reproduces both ways — wheel during a drag produces no scroll, and dragging to the pane's top edge does not scroll the viewport. The packaged binary still carries this exact branch (mouseButtonsPressed.size > 0 appears in the shipped bundle), so the code path described above is the one in use.

    The edge-autoscroll half is host-level and tracked separately (anomalyco/opencode#61053); this issue is only the dropped wheel event.

  3. mleivaDesarrollos commented on Nov 4, 2026

    @mleivaDesarrollos
    Author

    Retracting the diagnosis in this issue, on evidence rather than argument. I captured the raw stdin of the failing gesture (OTUI_STDIN_LOG / script -I) in WezTerm on opencode 2.2.5, and the TypeScript parser is not dropping the wheel.

    The gesture was: press at the bottom row of the pane, drag upward past the top edge, then wheel while still holding. On the wire:

     43  Cb=0   x=230 y=52 M   press
     44  Cb=32  x=230 y=51 M   drag ┐
     …                             │ 28 drags, y descends 52 → 1
     66  Cb=32  x=212 y=1  M   drag ┘
     67  Cb=32  x=216 y=6  M   drag
     68  Cb=65  x=216 y=6  M   wheel down
     69  Cb=64  x=216 y=6  M   wheel up        (×7 here, 18 in total)
     89  Cb=32  x=215 y=9  M   drag
     90  Cb=0   x=215 y=9  m   release
    

    120 mouse packets, 18 with the wheel bit, zero with both the wheel and motion bits set. So isMotion is false on every wheel tick, decodeSgrEvent takes the isScroll && pressRelease === "M" branch and returns scroll — the branch order from #830 is not implicated here, and no change to parse.mouse.ts would alter the outcome for this terminal.

    What the capture does show is that the gesture leaves the renderable: the drag's y is clamped at 1 (over the tab strip, outside the pane) and the wheel ticks arrive at y=6–8, at the pane's top edge. That points at two host-side defects rather than a decoder one:

    1. No edge autoscroll — dragging past the first row does not move the viewport, so a selection cannot grow beyond one screen.
    2. Scroll routing — renderer.ts dispatches a scroll to hitTest(x, y), not to the renderable that owns currentSelection. During a selection drag the pointer is at or outside the pane's edge, so the wheel does not reach the pane that owns the selection.

    Both are tracked in anomalyco/opencode#61053. #1917's native-parser fix still stands on its own for the URxvt case (motion-with-button 96 being decoded as a buttonless move); it just is not the cause of my report, and I would rather it not stay attributed to it. Happy to close this once that's agreed.

  4. simonklee commented on Nov 9, 2026

    @simonklee
    Member

    Terminals send wheel events during a button drag as SGR codes 64 through 67 without the motion bit (32). Both MouseParser and the native input parser decode codes 64 through 67 as scroll events during a drag. URxvt sends SGR codes 96 and 97 for pointer motion after a wheel event. OpenTUI tests the motion bit before the wheel bit so URxvt pointer motion does not produce extra scroll events. #1917 updates the native input parser to report drag for codes 96 and 97 during a drag. Issue #1930 tracks selection scroll behavior when a drag leaves a renderable. Close this because the input parsers do not drop wheel events during a drag.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions