detached 6ea6bb9

fix(input): hold wheel axis value until a full detent for pre-v8 clients

commit
6ea6bb9c059aa64e50b798a188b4e6f90da32abf
author
brice · · 2026-09-25 19:56 UTC
parent
07d7b79
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +36 -11
M internal/adapters/wayland/axis_test.go +10 -1
@@ -12,7 +12,7 @@ func TestWheelSteps(t *testing.T) {
1212 s := &Server{}
1313 wheel := func(v, h int32) [2]int32 {
1414 return s.wheelSteps(ports.PointerAxis{
15- Vertical: ports.ScrollAxis{Set: v != 0, V120: v},
15+ Vertical: ports.ScrollAxis{Set: v != 0, V120: v, Value: float64(v) / 8},
1616 Horizontal: ports.ScrollAxis{Set: h != 0, V120: h},
1717 })
1818 }
@@ -32,6 +32,15 @@ func TestWheelSteps(t *testing.T) {
3232 t.Fatalf("step %d: got %v, want %v", i, got, tc.want)
3333 }
3434 }
35+ // The held value sums the frames since the last step.
36+ *s = Server{}
37+ wheel(60, 0)
38+ if s.wheelHeld[0] != 7.5 {
39+ t.Fatal("held", s.wheelHeld[0])
40+ }
41+ if wheel(60, 0) != [2]int32{1, 0} || s.wheelHeld[0] != 0 {
42+ t.Fatal("held after step", s.wheelHeld[0])
43+ }
3544 if s.wheelSteps(ports.PointerAxis{Source: ports.AxisFinger, Vertical: ports.ScrollAxis{Set: true, V120: 240}}) != [2]int32{} {
3645 t.Fatal("finger scroll made discrete steps")
3746 }
@@ -12,7 +12,7 @@ func TestWheelSteps(t *testing.T) {
12 s := &Server{}12 s := &Server{}
13 wheel := func(v, h int32) [2]int32 {13 wheel := func(v, h int32) [2]int32 {
14 return s.wheelSteps(ports.PointerAxis{14 return s.wheelSteps(ports.PointerAxis{
15- Vertical: ports.ScrollAxis{Set: v != 0, V120: v},15+ Vertical: ports.ScrollAxis{Set: v != 0, V120: v, Value: float64(v) / 8},
16 Horizontal: ports.ScrollAxis{Set: h != 0, V120: h},16 Horizontal: ports.ScrollAxis{Set: h != 0, V120: h},
17 })17 })
18 }18 }
@@ -32,6 +32,15 @@ func TestWheelSteps(t *testing.T) {
32 t.Fatalf("step %d: got %v, want %v", i, got, tc.want)32 t.Fatalf("step %d: got %v, want %v", i, got, tc.want)
33 }33 }
34 }34 }
35+ // The held value sums the frames since the last step.
36+ *s = Server{}
37+ wheel(60, 0)
38+ if s.wheelHeld[0] != 7.5 {
39+ t.Fatal("held", s.wheelHeld[0])
40+ }
41+ if wheel(60, 0) != [2]int32{1, 0} || s.wheelHeld[0] != 0 {
42+ t.Fatal("held after step", s.wheelHeld[0])
43+ }
35 if s.wheelSteps(ports.PointerAxis{Source: ports.AxisFinger, Vertical: ports.ScrollAxis{Set: true, V120: 240}}) != [2]int32{} {44 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")45 t.Fatal("finger scroll made discrete steps")
37 }46 }
M internal/adapters/wayland/server.go +26 -10
@@ -82,7 +82,8 @@ 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
85+ wheelRest [2]int32 // v120 not yet sent as axis_discrete, per axis
86+ wheelHeld [2]float64 // axis value held back with it for pre-v8 clients
8687 pointerX, pointerY float64
8788 pointers map[server.Client][]*wayland.Pointer
8889 modState ports.ModState
@@ -402,9 +403,10 @@ func (s *Server) apply(cmd ports.ClientCommand) {
402403 if c.ID != s.pointerFocus {
403404 return
404405 }
406+ held := s.wheelHeld
405407 steps := s.wheelSteps(c.Axis)
406408 for _, p := range s.windowPointers(s.windows[c.ID]) {
407- sendAxis(p, c.Axis, steps)
409+ sendAxis(p, c.Axis, steps, held)
408410 }
409411 case ports.SetKeymap:
410412 s.setKeymap(c)
@@ -619,6 +621,7 @@ func (s *Server) changeFocus(id ports.WindowID) {
619621 // wheelSteps adds wheel v120 to the rest per axis and returns the whole
620622 // detents for axis_discrete (seat before v8). High-resolution wheels send
621623 // fractions of 120; a direction change drops the rest, as wlroots does.
624+// wheelHeld keeps the axis value of the frames without a step.
622625 func (s *Server) wheelSteps(a ports.PointerAxis) (steps [2]int32) {
623626 if a.Source != ports.AxisWheel {
624627 return steps
@@ -628,18 +631,24 @@ func (s *Server) wheelSteps(a ports.PointerAxis) (steps [2]int32) {
628631 continue
629632 }
630633 if (s.wheelRest[i] < 0) != (ax.V120 < 0) {
631- s.wheelRest[i] = 0
634+ s.wheelRest[i], s.wheelHeld[i] = 0, 0
632635 }
633636 s.wheelRest[i] += ax.V120
637+ s.wheelHeld[i] += ax.Value
634638 steps[i] = s.wheelRest[i] / 120
635639 s.wheelRest[i] -= steps[i] * 120
640+ if steps[i] != 0 {
641+ s.wheelHeld[i] = 0
642+ }
636643 }
637644 return steps
638645 }
639646
640647 // sendAxis sends one scroll frame, each event gated by the pointer version.
641-// steps are whole wheel detents for axis_discrete.
642-func sendAxis(p *wayland.Pointer, a ports.PointerAxis, steps [2]int32) {
648+// steps are whole wheel detents for axis_discrete. A pre-v8 client gets a
649+// wheel frame only with a step, carrying the value held since the last one
650+// (held, before this frame), so it never counts smooth and discrete scroll.
651+func sendAxis(p *wayland.Pointer, a ports.PointerAxis, steps [2]int32, held [2]float64) {
643652 v := p.Version()
644653 if v >= 5 {
645654 p.SendAxisSource(uint32(a.Source))
@@ -654,14 +663,21 @@ func sendAxis(p *wayland.Pointer, a ports.PointerAxis, steps [2]int32) {
654663 }
655664 continue
656665 }
666+ value := s.Value
657667 if a.Source == ports.AxisWheel && s.V120 != 0 {
658- if v >= 8 {
668+ switch {
669+ case v >= 8:
659670 p.SendAxisValue120(uint32(axis), s.V120)
660- } else if v >= 5 && steps[axis] != 0 {
661- p.SendAxisDiscrete(uint32(axis), steps[axis])
671+ case steps[axis] == 0:
672+ continue
673+ default:
674+ value += held[axis]
675+ if v >= 5 {
676+ p.SendAxisDiscrete(uint32(axis), steps[axis])
677+ }
662678 }
663679 }
664- p.SendAxis(a.TimeMsec, uint32(axis), server.FixedFromFloat(s.Value))
680+ p.SendAxis(a.TimeMsec, uint32(axis), server.FixedFromFloat(value))
665681 }
666682 pointerFrame(p)
667683 }
@@ -687,7 +703,7 @@ func (s *Server) changePointerFocus(id ports.WindowID, x, y float64) {
687703 if id == s.pointerFocus {
688704 return
689705 }
690- s.wheelRest = [2]int32{}
706+ s.wheelRest, s.wheelHeld = [2]int32{}, [2]float64{}
691707 // The new client sets its own cursor on enter; until then, the arrow.
692708 s.cursorSurface = nil
693709 s.setCursor(ports.CursorChange{})
@@ -82,7 +82,8 @@ 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 axis85+ wheelRest [2]int32 // v120 not yet sent as axis_discrete, per axis
86+ wheelHeld [2]float64 // axis value held back with it for pre-v8 clients
86 pointerX, pointerY float6487 pointerX, pointerY float64
87 pointers map[server.Client][]*wayland.Pointer88 pointers map[server.Client][]*wayland.Pointer
88 modState ports.ModState89 modState ports.ModState
@@ -402,9 +403,10 @@ func (s *Server) apply(cmd ports.ClientCommand) {
402 if c.ID != s.pointerFocus {403 if c.ID != s.pointerFocus {
403 return404 return
404 }405 }
406+ held := s.wheelHeld
405 steps := s.wheelSteps(c.Axis)407 steps := s.wheelSteps(c.Axis)
406 for _, p := range s.windowPointers(s.windows[c.ID]) {408 for _, p := range s.windowPointers(s.windows[c.ID]) {
407- sendAxis(p, c.Axis, steps)409+ sendAxis(p, c.Axis, steps, held)
408 }410 }
409 case ports.SetKeymap:411 case ports.SetKeymap:
410 s.setKeymap(c)412 s.setKeymap(c)
@@ -619,6 +621,7 @@ func (s *Server) changeFocus(id ports.WindowID) {
619 // wheelSteps adds wheel v120 to the rest per axis and returns the whole621 // wheelSteps adds wheel v120 to the rest per axis and returns the whole
620 // detents for axis_discrete (seat before v8). High-resolution wheels send622 // detents for axis_discrete (seat before v8). High-resolution wheels send
621 // fractions of 120; a direction change drops the rest, as wlroots does.623 // fractions of 120; a direction change drops the rest, as wlroots does.
624+// wheelHeld keeps the axis value of the frames without a step.
622 func (s *Server) wheelSteps(a ports.PointerAxis) (steps [2]int32) {625 func (s *Server) wheelSteps(a ports.PointerAxis) (steps [2]int32) {
623 if a.Source != ports.AxisWheel {626 if a.Source != ports.AxisWheel {
624 return steps627 return steps
@@ -628,18 +631,24 @@ func (s *Server) wheelSteps(a ports.PointerAxis) (steps [2]int32) {
628 continue631 continue
629 }632 }
630 if (s.wheelRest[i] < 0) != (ax.V120 < 0) {633 if (s.wheelRest[i] < 0) != (ax.V120 < 0) {
631- s.wheelRest[i] = 0634+ s.wheelRest[i], s.wheelHeld[i] = 0, 0
632 }635 }
633 s.wheelRest[i] += ax.V120636 s.wheelRest[i] += ax.V120
637+ s.wheelHeld[i] += ax.Value
634 steps[i] = s.wheelRest[i] / 120638 steps[i] = s.wheelRest[i] / 120
635 s.wheelRest[i] -= steps[i] * 120639 s.wheelRest[i] -= steps[i] * 120
640+ if steps[i] != 0 {
641+ s.wheelHeld[i] = 0
642+ }
636 }643 }
637 return steps644 return steps
638 }645 }
639 646
640 // sendAxis sends one scroll frame, each event gated by the pointer version.647 // sendAxis sends one scroll frame, each event gated by the pointer version.
641-// steps are whole wheel detents for axis_discrete.648+// steps are whole wheel detents for axis_discrete. A pre-v8 client gets a
642-func sendAxis(p *wayland.Pointer, a ports.PointerAxis, steps [2]int32) {649+// wheel frame only with a step, carrying the value held since the last one
650+// (held, before this frame), so it never counts smooth and discrete scroll.
651+func sendAxis(p *wayland.Pointer, a ports.PointerAxis, steps [2]int32, held [2]float64) {
643 v := p.Version()652 v := p.Version()
644 if v >= 5 {653 if v >= 5 {
645 p.SendAxisSource(uint32(a.Source))654 p.SendAxisSource(uint32(a.Source))
@@ -654,14 +663,21 @@ func sendAxis(p *wayland.Pointer, a ports.PointerAxis, steps [2]int32) {
654 }663 }
655 continue664 continue
656 }665 }
666+ value := s.Value
657 if a.Source == ports.AxisWheel && s.V120 != 0 {667 if a.Source == ports.AxisWheel && s.V120 != 0 {
658- if v >= 8 {668+ switch {
669+ case v >= 8:
659 p.SendAxisValue120(uint32(axis), s.V120)670 p.SendAxisValue120(uint32(axis), s.V120)
660- } else if v >= 5 && steps[axis] != 0 {671+ case steps[axis] == 0:
661- p.SendAxisDiscrete(uint32(axis), steps[axis])672+ continue
673+ default:
674+ value += held[axis]
675+ if v >= 5 {
676+ p.SendAxisDiscrete(uint32(axis), steps[axis])
677+ }
662 }678 }
663 }679 }
664- p.SendAxis(a.TimeMsec, uint32(axis), server.FixedFromFloat(s.Value))680+ p.SendAxis(a.TimeMsec, uint32(axis), server.FixedFromFloat(value))
665 }681 }
666 pointerFrame(p)682 pointerFrame(p)
667 }683 }
@@ -687,7 +703,7 @@ func (s *Server) changePointerFocus(id ports.WindowID, x, y float64) {
687 if id == s.pointerFocus {703 if id == s.pointerFocus {
688 return704 return
689 }705 }
690- s.wheelRest = [2]int32{}706+ s.wheelRest, s.wheelHeld = [2]int32{}, [2]float64{}
691 // The new client sets its own cursor on enter; until then, the arrow.707 // The new client sets its own cursor on enter; until then, the arrow.
692 s.cursorSurface = nil708 s.cursorSurface = nil
693 s.setCursor(ports.CursorChange{})709 s.setCursor(ports.CursorChange{})

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 26

Tags 10

↑↓ moveenter openesc close