detached 07d7b79

fix(input): scroll only the entered surface, accumulate wheel steps for old clients

commit
07d7b79217bf4f3d0f63f65e9cae04136bb9280b
author
brice · · 2026-09-25 19:54 UTC
parent
37d8478
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +79 -12
M internal/adapters/libinput/libinput.go +1 -1
@@ -312,7 +312,7 @@ func translate(ev uintptr, opts Options, p *pointer) (ports.InputEvent, error) {
312312 s.Stop = s.Value == 0
313313 }
314314 }
315- log.Debug().Float64("v", a.Vertical.Value).Float64("h", a.Horizontal.Value).Int32("v120", a.Vertical.V120).Msg("scroll")
315+ log.Debug().Float64("v", a.Vertical.Value).Float64("h", a.Horizontal.Value).Int32("v120", a.Vertical.V120).Int32("hv120", a.Horizontal.V120).Uint8("source", uint8(a.Source)).Msg("scroll")
316316 return a, nil
317317 }
318318 return nil, nil
@@ -312,7 +312,7 @@ func translate(ev uintptr, opts Options, p *pointer) (ports.InputEvent, error) {
312 s.Stop = s.Value == 0312 s.Stop = s.Value == 0
313 }313 }
314 }314 }
315- log.Debug().Float64("v", a.Vertical.Value).Float64("h", a.Horizontal.Value).Int32("v120", a.Vertical.V120).Msg("scroll")315+ log.Debug().Float64("v", a.Vertical.Value).Float64("h", a.Horizontal.Value).Int32("v120", a.Vertical.V120).Int32("hv120", a.Horizontal.V120).Uint8("source", uint8(a.Source)).Msg("scroll")
316 return a, nil316 return a, nil
317 }317 }
318 return nil, nil318 return nil, nil
A internal/adapters/wayland/axis_test.go +38 -0
@@ -0,0 +1,38 @@
1+package wayland
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/nefertty/internal/ports"
7+)
8+
9+// High-resolution wheels send fractions of a detent: pre-v8 clients get a
10+// discrete step once 120 builds up; a direction change drops the rest.
11+func TestWheelSteps(t *testing.T) {
12+ s := &Server{}
13+ wheel := func(v, h int32) [2]int32 {
14+ return s.wheelSteps(ports.PointerAxis{
15+ Vertical: ports.ScrollAxis{Set: v != 0, V120: v},
16+ Horizontal: ports.ScrollAxis{Set: h != 0, V120: h},
17+ })
18+ }
19+ for i, tc := range []struct {
20+ v, h int32
21+ want [2]int32
22+ }{
23+ {60, 0, [2]int32{}},
24+ {60, 0, [2]int32{1, 0}},
25+ {180, 0, [2]int32{1, 0}},
26+ {60, 0, [2]int32{1, 0}},
27+ {-60, 240, [2]int32{0, 2}},
28+ {-60, 0, [2]int32{-1, 0}},
29+ {30, -30, [2]int32{}},
30+ } {
31+ if got := wheel(tc.v, tc.h); got != tc.want {
32+ t.Fatalf("step %d: got %v, want %v", i, got, tc.want)
33+ }
34+ }
35+ if s.wheelSteps(ports.PointerAxis{Source: ports.AxisFinger, Vertical: ports.ScrollAxis{Set: true, V120: 240}}) != [2]int32{} {
36+ t.Fatal("finger scroll made discrete steps")
37+ }
38+}
@@ -0,0 +1,38 @@
1+package wayland
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/nefertty/internal/ports"
7+)
8+
9+// High-resolution wheels send fractions of a detent: pre-v8 clients get a
10+// discrete step once 120 builds up; a direction change drops the rest.
11+func TestWheelSteps(t *testing.T) {
12+ s := &Server{}
13+ wheel := func(v, h int32) [2]int32 {
14+ return s.wheelSteps(ports.PointerAxis{
15+ Vertical: ports.ScrollAxis{Set: v != 0, V120: v},
16+ Horizontal: ports.ScrollAxis{Set: h != 0, V120: h},
17+ })
18+ }
19+ for i, tc := range []struct {
20+ v, h int32
21+ want [2]int32
22+ }{
23+ {60, 0, [2]int32{}},
24+ {60, 0, [2]int32{1, 0}},
25+ {180, 0, [2]int32{1, 0}},
26+ {60, 0, [2]int32{1, 0}},
27+ {-60, 240, [2]int32{0, 2}},
28+ {-60, 0, [2]int32{-1, 0}},
29+ {30, -30, [2]int32{}},
30+ } {
31+ if got := wheel(tc.v, tc.h); got != tc.want {
32+ t.Fatalf("step %d: got %v, want %v", i, got, tc.want)
33+ }
34+ }
35+ if s.wheelSteps(ports.PointerAxis{Source: ports.AxisFinger, Vertical: ports.ScrollAxis{Set: true, V120: 240}}) != [2]int32{} {
36+ t.Fatal("finger scroll made discrete steps")
37+ }
38+}
M internal/adapters/wayland/server.go +33 -4
@@ -82,6 +82,7 @@ type Server struct {
8282 nextPool uint64
8383 focused ports.WindowID
8484 pointerFocus ports.WindowID
85+ wheelRest [2]int32 // v120 not yet sent as axis_discrete, per axis
8586 pointerX, pointerY float64
8687 pointers map[server.Client][]*wayland.Pointer
8788 modState ports.ModState
@@ -397,8 +398,13 @@ func (s *Server) apply(cmd ports.ClientCommand) {
397398 }
398399 }
399400 case ports.PointerAxisTo:
401+ // Scroll applies to the entered surface only.
402+ if c.ID != s.pointerFocus {
403+ return
404+ }
405+ steps := s.wheelSteps(c.Axis)
400406 for _, p := range s.windowPointers(s.windows[c.ID]) {
401- sendAxis(p, c.Axis)
407+ sendAxis(p, c.Axis, steps)
402408 }
403409 case ports.SetKeymap:
404410 s.setKeymap(c)
@@ -610,8 +616,30 @@ func (s *Server) changeFocus(id ports.WindowID) {
610616 s.updateConstraint()
611617 }
612618
619+// wheelSteps adds wheel v120 to the rest per axis and returns the whole
620+// detents for axis_discrete (seat before v8). High-resolution wheels send
621+// fractions of 120; a direction change drops the rest, as wlroots does.
622+func (s *Server) wheelSteps(a ports.PointerAxis) (steps [2]int32) {
623+ if a.Source != ports.AxisWheel {
624+ return steps
625+ }
626+ for i, ax := range [2]ports.ScrollAxis{a.Vertical, a.Horizontal} {
627+ if !ax.Set || ax.V120 == 0 {
628+ continue
629+ }
630+ if (s.wheelRest[i] < 0) != (ax.V120 < 0) {
631+ s.wheelRest[i] = 0
632+ }
633+ s.wheelRest[i] += ax.V120
634+ steps[i] = s.wheelRest[i] / 120
635+ s.wheelRest[i] -= steps[i] * 120
636+ }
637+ return steps
638+}
639+
613640 // sendAxis sends one scroll frame, each event gated by the pointer version.
614-func sendAxis(p *wayland.Pointer, a ports.PointerAxis) {
641+// steps are whole wheel detents for axis_discrete.
642+func sendAxis(p *wayland.Pointer, a ports.PointerAxis, steps [2]int32) {
615643 v := p.Version()
616644 if v >= 5 {
617645 p.SendAxisSource(uint32(a.Source))
@@ -629,8 +657,8 @@ func sendAxis(p *wayland.Pointer, a ports.PointerAxis) {
629657 if a.Source == ports.AxisWheel && s.V120 != 0 {
630658 if v >= 8 {
631659 p.SendAxisValue120(uint32(axis), s.V120)
632- } else if v >= 5 && s.V120/120 != 0 {
633- p.SendAxisDiscrete(uint32(axis), s.V120/120)
660+ } else if v >= 5 && steps[axis] != 0 {
661+ p.SendAxisDiscrete(uint32(axis), steps[axis])
634662 }
635663 }
636664 p.SendAxis(a.TimeMsec, uint32(axis), server.FixedFromFloat(s.Value))
@@ -659,6 +687,7 @@ func (s *Server) changePointerFocus(id ports.WindowID, x, y float64) {
659687 if id == s.pointerFocus {
660688 return
661689 }
690+ s.wheelRest = [2]int32{}
662691 // The new client sets its own cursor on enter; until then, the arrow.
663692 s.cursorSurface = nil
664693 s.setCursor(ports.CursorChange{})
@@ -82,6 +82,7 @@ type Server struct {
82 nextPool uint6482 nextPool uint64
83 focused ports.WindowID83 focused ports.WindowID
84 pointerFocus ports.WindowID84 pointerFocus ports.WindowID
85+ wheelRest [2]int32 // v120 not yet sent as axis_discrete, per axis
85 pointerX, pointerY float6486 pointerX, pointerY float64
86 pointers map[server.Client][]*wayland.Pointer87 pointers map[server.Client][]*wayland.Pointer
87 modState ports.ModState88 modState ports.ModState
@@ -397,8 +398,13 @@ func (s *Server) apply(cmd ports.ClientCommand) {
397 }398 }
398 }399 }
399 case ports.PointerAxisTo:400 case ports.PointerAxisTo:
401+ // Scroll applies to the entered surface only.
402+ if c.ID != s.pointerFocus {
403+ return
404+ }
405+ steps := s.wheelSteps(c.Axis)
400 for _, p := range s.windowPointers(s.windows[c.ID]) {406 for _, p := range s.windowPointers(s.windows[c.ID]) {
401- sendAxis(p, c.Axis)407+ sendAxis(p, c.Axis, steps)
402 }408 }
403 case ports.SetKeymap:409 case ports.SetKeymap:
404 s.setKeymap(c)410 s.setKeymap(c)
@@ -610,8 +616,30 @@ func (s *Server) changeFocus(id ports.WindowID) {
610 s.updateConstraint()616 s.updateConstraint()
611 }617 }
612 618
619+// wheelSteps adds wheel v120 to the rest per axis and returns the whole
620+// detents for axis_discrete (seat before v8). High-resolution wheels send
621+// fractions of 120; a direction change drops the rest, as wlroots does.
622+func (s *Server) wheelSteps(a ports.PointerAxis) (steps [2]int32) {
623+ if a.Source != ports.AxisWheel {
624+ return steps
625+ }
626+ for i, ax := range [2]ports.ScrollAxis{a.Vertical, a.Horizontal} {
627+ if !ax.Set || ax.V120 == 0 {
628+ continue
629+ }
630+ if (s.wheelRest[i] < 0) != (ax.V120 < 0) {
631+ s.wheelRest[i] = 0
632+ }
633+ s.wheelRest[i] += ax.V120
634+ steps[i] = s.wheelRest[i] / 120
635+ s.wheelRest[i] -= steps[i] * 120
636+ }
637+ return steps
638+}
639+
613 // sendAxis sends one scroll frame, each event gated by the pointer version.640 // sendAxis sends one scroll frame, each event gated by the pointer version.
614-func sendAxis(p *wayland.Pointer, a ports.PointerAxis) {641+// steps are whole wheel detents for axis_discrete.
642+func sendAxis(p *wayland.Pointer, a ports.PointerAxis, steps [2]int32) {
615 v := p.Version()643 v := p.Version()
616 if v >= 5 {644 if v >= 5 {
617 p.SendAxisSource(uint32(a.Source))645 p.SendAxisSource(uint32(a.Source))
@@ -629,8 +657,8 @@ func sendAxis(p *wayland.Pointer, a ports.PointerAxis) {
629 if a.Source == ports.AxisWheel && s.V120 != 0 {657 if a.Source == ports.AxisWheel && s.V120 != 0 {
630 if v >= 8 {658 if v >= 8 {
631 p.SendAxisValue120(uint32(axis), s.V120)659 p.SendAxisValue120(uint32(axis), s.V120)
632- } else if v >= 5 && s.V120/120 != 0 {660+ } else if v >= 5 && steps[axis] != 0 {
633- p.SendAxisDiscrete(uint32(axis), s.V120/120)661+ p.SendAxisDiscrete(uint32(axis), steps[axis])
634 }662 }
635 }663 }
636 p.SendAxis(a.TimeMsec, uint32(axis), server.FixedFromFloat(s.Value))664 p.SendAxis(a.TimeMsec, uint32(axis), server.FixedFromFloat(s.Value))
@@ -659,6 +687,7 @@ func (s *Server) changePointerFocus(id ports.WindowID, x, y float64) {
659 if id == s.pointerFocus {687 if id == s.pointerFocus {
660 return688 return
661 }689 }
690+ s.wheelRest = [2]int32{}
662 // The new client sets its own cursor on enter; until then, the arrow.691 // The new client sets its own cursor on enter; until then, the arrow.
663 s.cursorSurface = nil692 s.cursorSurface = nil
664 s.setCursor(ports.CursorChange{})693 s.setCursor(ports.CursorChange{})
M internal/core/core.go +4 -7
@@ -740,13 +740,10 @@ func (c *Core) Run(ctx context.Context) error {
740740 }
741741 continue
742742 case ports.PointerAxis:
743- // Scroll goes where a button would: the grab, else under the pointer.
744- id := c.pointer
745- if c.grab != 0 {
746- id = c.grab
747- }
748- if id != 0 {
749- if err := c.command(ctx, ports.PointerAxisTo{ID: id, Axis: v}); err != nil {
743+ // Scroll goes to the window under the pointer, which has the
744+ // pointer focus even mid-drag.
745+ if c.pointer != 0 {
746+ if err := c.command(ctx, ports.PointerAxisTo{ID: c.pointer, Axis: v}); err != nil {
750747 return nil
751748 }
752749 }
@@ -740,13 +740,10 @@ func (c *Core) Run(ctx context.Context) error {
740 }740 }
741 continue741 continue
742 case ports.PointerAxis:742 case ports.PointerAxis:
743- // Scroll goes where a button would: the grab, else under the pointer.743+ // Scroll goes to the window under the pointer, which has the
744- id := c.pointer744+ // pointer focus even mid-drag.
745- if c.grab != 0 {745+ if c.pointer != 0 {
746- id = c.grab746+ if err := c.command(ctx, ports.PointerAxisTo{ID: c.pointer, Axis: v}); err != nil {
747- }
748- if id != 0 {
749- if err := c.command(ctx, ports.PointerAxisTo{ID: id, Axis: v}); err != nil {
750 return nil747 return nil
751 }748 }
752 }749 }
M internal/core/core_test.go +3 -0
@@ -269,6 +269,9 @@ func TestPointerFocusAndGrab(t *testing.T) {
269269 if v := command(t, commands); v != (ports.PointerFocus{}) {
270270 t.Fatal(v)
271271 }
272+ // Mid-drag off the window, scroll goes to what is under the pointer:
273+ // nothing here, not the grab window that got a leave.
274+ input <- ports.PointerAxis{Vertical: ports.ScrollAxis{Set: true, Value: 1}}
272275 input <- ports.PointerButton{Button: 0x110}
273276 if v := command(t, commands); v != (ports.PointerButtonTo{ID: 1, Button: 0x110}) {
274277 t.Fatal(v)
@@ -269,6 +269,9 @@ func TestPointerFocusAndGrab(t *testing.T) {
269 if v := command(t, commands); v != (ports.PointerFocus{}) {269 if v := command(t, commands); v != (ports.PointerFocus{}) {
270 t.Fatal(v)270 t.Fatal(v)
271 }271 }
272+ // Mid-drag off the window, scroll goes to what is under the pointer:
273+ // nothing here, not the grab window that got a leave.
274+ input <- ports.PointerAxis{Vertical: ports.ScrollAxis{Set: true, Value: 1}}
272 input <- ports.PointerButton{Button: 0x110}275 input <- ports.PointerButton{Button: 0x110}
273 if v := command(t, commands); v != (ports.PointerButtonTo{ID: 1, Button: 0x110}) {276 if v := command(t, commands); v != (ports.PointerButtonTo{ID: 1, Button: 0x110}) {
274 t.Fatal(v)277 t.Fatal(v)

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 23

Tags 10

↑↓ moveenter openesc close