Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Fixed

- The mouse wheel in a terminal scrolls smoothly and as far as in other terminals. A notch moves three lines of the shell's history, or a full-screen program such as `less`, eased over a few frames instead of jumping some eight lines at once. A program that takes the mouse, such as nvim, Claude Code or Codex, gets one wheel step a notch and scrolls its own distance, where it used to get about eight and race through the conversation.

## [0.79.0] - 2026-10-24

> [!IMPORTANT]
Expand Down
60 changes: 57 additions & 3 deletions native/cmd/lich-native/terminal_mouse.go
Original file line number Diff line number Diff line change
Expand Up @@ -100,11 +100,64 @@ func (t *termView) report(e pointer.Event) []byte {
return nil
}

// wheel scrolls a row per cell height scrolled: as wheel steps for a program
// wheel owes the rows e scrolled, for the coming frames to scroll
// (scrollStep). A turn of the wheel the other way drops what was still owed.
func (t *termView) wheel(e pointer.Event) []byte {
rows := t.wheelRows(e.Scroll.Y)
if rows*t.wheelPending < 0 {
t.wheelPending = 0
}
t.wheelPending += rows
t.wheelAt = e
return nil
}

// wheelRows is the rows a scroll of dy pixels moves, negative is up. Gio
// reports a wheel and a touchpad alike, so the size tells them apart: a wheel
// scrolls whole notches, one wheel step a notch to a program tracking the
// mouse, which scrolls what it likes, wheelNotchRows otherwise. A touchpad
// scrolls a few pixels at a time, a row per cell height.
func (t *termView) wheelRows(dy float32) int {
notches := dy / wheelNotch
if notches != float32(int(notches)) {
return t.vt.WheelRows(dy)
}
if t.vt.MouseTracking() {
return int(notches)
}
return int(notches) * wheelNotchRows
}

// wheelNotchRows is the rows a notch scrolls through scrollback or as arrow
// keys, as Ghostty and xterm scroll.
const wheelNotchRows = 3

// wheelEase is the share of the rows still owed that a frame scrolls, one
// over it: the rows of a few notches ease out over a few frames instead of
// jumping in one.
const wheelEase = 3

// scrollStep scrolls the frame's share of wheelPending, and says whether rows
// are still owed for the next frame. send is what goes to the program.
func (t *termView) scrollStep() (send []byte, more bool) {
if t.wheelPending == 0 {
return nil, false
}
step := t.wheelPending / wheelEase
if step == 0 {
step = 1
if t.wheelPending < 0 {
step = -1
}
}
t.wheelPending -= step
return t.scrollRows(step), t.wheelPending != 0
}

// scrollRows scrolls rows, negative is up: as wheel steps for a program
// tracking the mouse, as arrow keys for a full-screen one that does not,
// through scrollback otherwise.
func (t *termView) wheel(e pointer.Event) []byte {
rows := t.vt.WheelRows(e.Scroll.Y)
func (t *termView) scrollRows(rows int) []byte {
tracking := t.vt.MouseTracking()
if !tracking && !t.vt.AlternateScroll() {
t.vt.Scroll(rows)
Expand All @@ -114,6 +167,7 @@ func (t *termView) wheel(e pointer.Event) []byte {
if rows < 0 {
button, arrow, rows = gioterm.MouseWheelUp, key.NameUpArrow, -rows
}
e := t.wheelAt
var out []byte
for range rows {
if tracking {
Expand Down
124 changes: 109 additions & 15 deletions native/cmd/lich-native/terminal_mouse_test.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
package main

import (
"fmt"
"image"
"strings"
"testing"
"time"

Expand Down Expand Up @@ -84,12 +86,18 @@ func TestMouseGoesToTheProgramThatTracksIt(t *testing.T) {
{"release", ptr(pointer.Release, at(3, 1), 0, 0), "\x1b[<0;4;2m"},
{"right press", ptr(pointer.Press, at(3, 1), pointer.ButtonSecondary, 0), "\x1b[<2;4;2M"},
{"right release", ptr(pointer.Release, at(3, 1), 0, 0), "\x1b[<2;4;2m"},
{"wheel down two rows", pointer.Event{Kind: pointer.Scroll, Position: at(3, 1), Scroll: f32.Pt(0, 2*mouseTestCellH)}, "\x1b[<65;4;2M\x1b[<65;4;2M"},
{"wheel up", pointer.Event{Kind: pointer.Scroll, Position: at(3, 1), Scroll: f32.Pt(0, -mouseTestCellH)}, "\x1b[<64;4;2M"},
{"wheel down two notches", pointer.Event{Kind: pointer.Scroll, Position: at(3, 1), Scroll: f32.Pt(0, 2*wheelNotch)}, "\x1b[<65;4;2M\x1b[<65;4;2M"},
{"wheel up a notch", pointer.Event{Kind: pointer.Scroll, Position: at(3, 1), Scroll: f32.Pt(0, -wheelNotch)}, "\x1b[<64;4;2M"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
send, copied := v.pointer(c.e)
// A wheel's rows reach the program over the frames after it.
for more := true; more; {
var b []byte
b, more = v.scrollStep()
send = append(send, b...)
}
if string(send) != c.want || copied != "" {
t.Fatalf("sent %q, copied %q; want %q sent", send, copied, c.want)
}
Expand All @@ -115,30 +123,116 @@ func TestWheelMovesTheCursorOnTheAlternateScreen(t *testing.T) {
scroll float32
want string
}{
{"down two rows", 2 * mouseTestCellH, "\x1b[B\x1b[B"},
{"up one row", -mouseTestCellH, "\x1b[A"},
{"down two notches", 2 * wheelNotch, strings.Repeat("\x1b[B", 6)},
{"up a notch", -wheelNotch, "\x1b[A\x1b[A\x1b[A"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
e := pointer.Event{Kind: pointer.Scroll, Position: at(0, 0), Scroll: f32.Pt(0, c.scroll)}
if send, _ := v.pointer(e); string(send) != c.want {
if send := wheelSent(v, e); send != c.want {
t.Fatalf("sent %q, want %q", send, c.want)
}
})
}
}

func TestTouchpadDeltasUnderACellStillScroll(t *testing.T) {
v := newMouseTestView(t, "\x1b[?1049h")
// A touchpad sends a stream of deltas a few pixels each, never a whole row.
var sent string
for range 4 {
e := pointer.Event{Kind: pointer.Scroll, Position: at(0, 0), Scroll: f32.Pt(0, mouseTestCellH/4)}
send, _ := v.pointer(e)
sent += string(send)
}
if sent != "\x1b[B" {
t.Fatalf("four quarter-row deltas sent %q, want one row down", sent)
// A touchpad sends a stream of deltas a few pixels each, never a whole row:
// a row per cell height, as arrow keys or as wheel steps.
cases := []struct {
name, output, want string
}{
{"arrow keys", "\x1b[?1049h", "\x1b[B"},
{"mouse tracking", "\x1b[?1000h\x1b[?1006h", "\x1b[<65;1;1M"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
v := newMouseTestView(t, c.output)
var sent string
for range 4 {
e := pointer.Event{Kind: pointer.Scroll, Position: at(0, 0), Scroll: f32.Pt(0, mouseTestCellH/4)}
sent += wheelSent(v, e)
}
if sent != c.want {
t.Fatalf("four quarter-row deltas sent %q, want %q", sent, c.want)
}
})
}
}

// wheelSent is what the program is sent for e over the frames that scroll it.
func wheelSent(v *termView, e pointer.Event) string {
sent, _ := v.pointer(e)
for more := true; more; {
var b []byte
b, more = v.scrollStep()
sent = append(sent, b...)
}
return string(sent)
}

// topRow is the text of the viewport's first row, read the way a drag copies
// it. Each read presses a minute after the last, at n minutes, so no two count
// as a double click.
func topRow(v *termView, n int) string {
press := ptr(pointer.Press, from(0, 0), pointer.ButtonPrimary, 0)
press.Time = time.Duration(n) * time.Minute
v.pointer(press)
v.pointer(ptr(pointer.Drag, at(2, 0), pointer.ButtonPrimary, 0))
_, copied := v.pointer(ptr(pointer.Release, at(2, 0), 0, 0))
return copied
}

func TestWheelNotchesScrollTheScrollbackOverSeveralFrames(t *testing.T) {
lines := make([]string, 100)
for i := range lines {
lines[i] = fmt.Sprintf("L%02d", i)
}
v := newMouseTestView(t, strings.Join(lines, "\r\n"))
// Two notches up are six rows of scrollback.
v.pointer(pointer.Event{Kind: pointer.Scroll, Position: at(0, 0), Scroll: f32.Pt(0, -2*wheelNotch)})
if got := topRow(v, 1); got != "L76" {
t.Fatalf("the notches moved the viewport before a frame: top row %q, want L76", got)
}
if _, more := v.scrollStep(); !more {
t.Fatal("the first frame moved both notches")
}
if got := topRow(v, 2); got == "L76" || got == "L70" {
t.Fatalf("after one frame the top row is %q, want part of the way", got)
}
for more := true; more; {
_, more = v.scrollStep()
}
if got := topRow(v, 3); got != "L70" {
t.Fatalf("after the notches drained the top row is %q, want L70", got)
}
}

func TestWheelNotchesReachAFullScreenProgramOverSeveralFrames(t *testing.T) {
// Three notches: nine arrow keys, or three wheel steps to a program that
// tracks the mouse and scrolls what it likes a step.
cases := []struct {
name, output, row string
first, rest int
}{
{"arrow keys", "\x1b[?1049h", "\x1b[B", 3, 6},
{"mouse tracking", "\x1b[?1000h\x1b[?1006h", "\x1b[<65;1;1M", 1, 2},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
v := newMouseTestView(t, c.output)
e := pointer.Event{Kind: pointer.Scroll, Position: at(0, 0), Scroll: f32.Pt(0, 3*wheelNotch)}
if send, _ := v.pointer(e); send != nil {
t.Fatalf("the notches sent %q before a frame", send)
}
first, more := v.scrollStep()
if !more || string(first) != strings.Repeat(c.row, c.first) {
t.Fatalf("the first frame sent %q (more %v), want %d rows and more to come", first, more, c.first)
}
if rest := wheelSent(v, pointer.Event{Kind: pointer.Scroll, Position: at(0, 0)}); rest != strings.Repeat(c.row, c.rest) {
t.Fatalf("the frames after sent %q, want the other %d rows", rest, c.rest)
}
})
}
}

Expand Down
10 changes: 10 additions & 0 deletions native/cmd/lich-native/terminals.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,11 @@ type termView struct {
held gioterm.MouseButton
// linkHeld is a press that opened a link, whose release is kept too.
linkHeld bool
// wheelPending is the rows the wheel scrolled that the coming frames
// still owe, negative is up; wheelAt is the wheel event they came from,
// where a tracking program is told they happened.
wheelPending int
wheelAt pointer.Event
// pointerAt is where the pointer is over the grid, pointerIn false once
// it left: the link under it is underlined and takes a hand cursor.
pointerAt f32.Point
Expand Down Expand Up @@ -448,6 +453,11 @@ func (ts *terminals) layout(gtx layout.Context, t *termView, focus bool) error {
}
}
ts.input(gtx, t, focus)
send, more := t.scrollStep()
ts.send(t.id, send)
if more {
gtx.Execute(op.InvalidateCmd{})
}
snap, err := t.vt.Update()
if err != nil {
return err
Expand Down
5 changes: 5 additions & 0 deletions native/cmd/lich-native/wheel_notch_darwin.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package main

// wheelNotch is the pixels Gio reports for one notch of a mouse wheel: AppKit
// counts a wheel without precise deltas in lines, which Gio scales by 10.
const wheelNotch float32 = 10
16 changes: 16 additions & 0 deletions native/cmd/lich-native/wheel_notch_unix.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
//go:build !windows && !darwin

package main

import "os"

// wheelNotch is the pixels Gio reports for one notch of a mouse wheel: on
// Wayland the compositor's 15 times Gio's discrete scale of 10, on X11 Gio's
// scroll scale of 10 a button press. Gio opens Wayland whenever
// WAYLAND_DISPLAY is set, so the same check picks the size.
var wheelNotch = func() float32 {
if os.Getenv("WAYLAND_DISPLAY") != "" {
return 150
}
return 10
}()
5 changes: 5 additions & 0 deletions native/cmd/lich-native/wheel_notch_windows.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package main

// wheelNotch is the pixels Gio reports for one notch of a mouse wheel:
// WM_MOUSEWHEEL's WHEEL_DELTA, passed through as is.
const wheelNotch float32 = 120
Loading