From 64c0f042eb69624122da6741b3794b4c094de0ab Mon Sep 17 00:00:00 2001 From: omartelo Date: Mon, 26 Oct 2026 14:32:31 -0300 Subject: [PATCH] fix(native): scroll the terminal by wheel notch, eased over frames A Wayland wheel notch reaches Gio as one 150-pixel delta, which the terminal turned into some eight rows in a single frame, and into eight wheel steps for a program tracking the mouse. A notch now moves three rows of scrollback or arrow keys, or one wheel step to a tracking program, and the rows ease out over a few frames. Touchpad deltas, which are not whole notches, keep scrolling a row per cell height. --- CHANGELOG.md | 4 + native/cmd/lich-native/terminal_mouse.go | 60 ++++++++- native/cmd/lich-native/terminal_mouse_test.go | 124 +++++++++++++++--- native/cmd/lich-native/terminals.go | 10 ++ native/cmd/lich-native/wheel_notch_darwin.go | 5 + native/cmd/lich-native/wheel_notch_unix.go | 16 +++ native/cmd/lich-native/wheel_notch_windows.go | 5 + 7 files changed, 206 insertions(+), 18 deletions(-) create mode 100644 native/cmd/lich-native/wheel_notch_darwin.go create mode 100644 native/cmd/lich-native/wheel_notch_unix.go create mode 100644 native/cmd/lich-native/wheel_notch_windows.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 501f53074..29ce72d05 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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] diff --git a/native/cmd/lich-native/terminal_mouse.go b/native/cmd/lich-native/terminal_mouse.go index ab2ccb2ed..8d60d3c90 100644 --- a/native/cmd/lich-native/terminal_mouse.go +++ b/native/cmd/lich-native/terminal_mouse.go @@ -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) @@ -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 { diff --git a/native/cmd/lich-native/terminal_mouse_test.go b/native/cmd/lich-native/terminal_mouse_test.go index f0f5aa6fc..03c90d1e4 100644 --- a/native/cmd/lich-native/terminal_mouse_test.go +++ b/native/cmd/lich-native/terminal_mouse_test.go @@ -1,7 +1,9 @@ package main import ( + "fmt" "image" + "strings" "testing" "time" @@ -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) } @@ -115,13 +123,13 @@ 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) } }) @@ -129,16 +137,102 @@ func TestWheelMovesTheCursorOnTheAlternateScreen(t *testing.T) { } 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) + } + }) } } diff --git a/native/cmd/lich-native/terminals.go b/native/cmd/lich-native/terminals.go index 9ec5ee0d4..36c421d8b 100644 --- a/native/cmd/lich-native/terminals.go +++ b/native/cmd/lich-native/terminals.go @@ -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 @@ -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 diff --git a/native/cmd/lich-native/wheel_notch_darwin.go b/native/cmd/lich-native/wheel_notch_darwin.go new file mode 100644 index 000000000..b0f27ca23 --- /dev/null +++ b/native/cmd/lich-native/wheel_notch_darwin.go @@ -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 diff --git a/native/cmd/lich-native/wheel_notch_unix.go b/native/cmd/lich-native/wheel_notch_unix.go new file mode 100644 index 000000000..610d29ca6 --- /dev/null +++ b/native/cmd/lich-native/wheel_notch_unix.go @@ -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 +}() diff --git a/native/cmd/lich-native/wheel_notch_windows.go b/native/cmd/lich-native/wheel_notch_windows.go new file mode 100644 index 000000000..80cb87366 --- /dev/null +++ b/native/cmd/lich-native/wheel_notch_windows.go @@ -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