Skip to content

renderer: a selection drag that leaves the renderable cannot scroll it, so a selection cannot span more than one screen #1930

Open

Description

@mleivaDesarrollos

Summary

While a selection drag is active, the renderer clamps the drag endpoint to the owning renderable's visible grid and delivers drag events to whatever renderable is under the pointer. It never scrolls the renderable that owns currentSelection. So a drag that leaves the renderable's bounds — the normal way to select more than one screen — cannot extend the selection beyond the visible area.

Confirmed with a terminal pane in opencode; the same applies to any scrollable, selectable renderable such as ScrollBox.

Evidence

Raw mouse capture of the real gesture (WezTerm, opencode 2.2.5): press at the bottom row, drag upward.

 43  Cb=0   x=230 y=52 M  press
 44  Cb=32  x=230 y=51 M  drag ┐
 …                            │ y descends 52 → 1
 66  Cb=32  x=212 y=1  M  drag ┘  ← clamped at row 1, outside the pane
 90  Cb=0   x=215 y=9  m  release

No scroll occurs at any point. Control case: wheel with the button held and the pointer inside the pane scrolls normally, so local scroll in the embedded terminal and the renderer's scroll dispatch both work during a drag. The only missing behaviour is the out-of-bounds case.

Where it is

  • packages/core/src/renderables/EmbeddedTerminal.ts, onSelectionChanged clamps every endpoint:

    const point = (x: number, y: number) => {
      if (y < 0) return { x: 0, y: 0 }
      if (y >= this.height) return { x: this.width - 1, y: this.height - 1 }
      return { x: Math.max(0, Math.min(this.width - 1, x)), y }
    }
  • packages/core/src/renderer.ts, processSingleMouseEvent: a drag calls updateSelection(maybeRenderable, x, y) and then sendMouseEvent(maybeRenderable, …) — the event goes to the hit target, not to the selection owner, so the owner is never told that the pointer left it.

  • _latestPointer is private, so a host cannot read the pointer position and implement autoscroll itself.

Proposal

Either of these makes the gesture work; the first also fixes scrollboxes:

  1. Renderer autoscroll. While currentSelection.isDragging and the pointer is outside the owner's bounds, scroll the owner on a timer (roughly every 50–100 ms, proportional to distance past the edge) through a minimal contract on the renderable — for example scrollBy(delta). EmbeddedTerminalRenderable already has the operation internally (contextEmbeddedTerminalScroll), and scrollable containers have equivalents. Keep the selection endpoint pinned to the edge row while the content moves under it.
  2. Expose the gesture. Dispatch drag events to the selection owner as well as the hit target, with unclamped coordinates (and/or expose the pointer publicly), so a host can implement autoscroll itself.

Notes

Activity

  1. mleivaDesarrollos commented on Nov 9, 2026

    @mleivaDesarrollos
    Author

    Checked whether a host could implement this itself today, so the scope here is not over-stated: it cannot, and #1716 does not change that.

    processSingleMouseEvent handles an active selection drag in its own branch, before the pressed-renderable path:

    if (mouseEvent.type === "drag" && this.currentSelection?.isDragging) {
      this.updateSelection(maybeRenderable, mouseEvent.x, mouseEvent.y)
      if (maybeRenderable) this.sendMouseEvent(maybeRenderable, { ...mouseEvent, isDragging: true })
      return true
    }

    maybeRenderable is hitTest(x, y), so once the pointer leaves the owner the owner receives nothing, and _latestPointer is private — there is no way for a host to notice the pointer and autoscroll on its own.

    One implementation detail worth pinning down while this is open: an autoscroll tick has to scroll and re-apply the selection. Scrolling alone moves the content under a fixed screen-space selection without re-running onSelectionChanged, so the native selection keeps addressing the old cells and the range does not grow. Scroll, then re-apply with the current pointer, and the selected range extends by the scrolled amount — which is the behaviour users expect.

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