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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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) { | |||
| 312 | 312 | 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 | 316 | return a, nil | |
| 317 | 317 | } | |
| 318 | 318 | return nil, nil | |
| @@ -312,7 +312,7 @@ func translate(ev uintptr, opts Options, p *pointer) (ports.InputEvent, error) { | |||||
| 312 | s.Stop = s.Value == 0 | 312 | 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, nil | 316 | return a, nil | ||
| 317 | } | 317 | } | ||
| 318 | return nil, nil | 318 | 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 { | |||
| 82 | 82 | nextPool uint64 | |
| 83 | 83 | focused ports.WindowID | |
| 84 | 84 | pointerFocus ports.WindowID | |
| 85 | + | wheelRest [2]int32 // v120 not yet sent as axis_discrete, per axis | |
| 85 | 86 | pointerX, pointerY float64 | |
| 86 | 87 | pointers map[server.Client][]*wayland.Pointer | |
| 87 | 88 | modState ports.ModState | |
| @@ -397,8 +398,13 @@ func (s *Server) apply(cmd ports.ClientCommand) { | |||
| 397 | 398 | } | |
| 398 | 399 | } | |
| 399 | 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 | 406 | for _, p := range s.windowPointers(s.windows[c.ID]) { | |
| 401 | - | sendAxis(p, c.Axis) | |
| 407 | + | sendAxis(p, c.Axis, steps) | |
| 402 | 408 | } | |
| 403 | 409 | case ports.SetKeymap: | |
| 404 | 410 | s.setKeymap(c) | |
| @@ -610,8 +616,30 @@ func (s *Server) changeFocus(id ports.WindowID) { | |||
| 610 | 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 | 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 | 643 | v := p.Version() | |
| 616 | 644 | if v >= 5 { | |
| 617 | 645 | p.SendAxisSource(uint32(a.Source)) | |
| @@ -629,8 +657,8 @@ func sendAxis(p *wayland.Pointer, a ports.PointerAxis) { | |||
| 629 | 657 | if a.Source == ports.AxisWheel && s.V120 != 0 { | |
| 630 | 658 | if v >= 8 { | |
| 631 | 659 | 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]) | |
| 634 | 662 | } | |
| 635 | 663 | } | |
| 636 | 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 | 687 | if id == s.pointerFocus { | |
| 660 | 688 | return | |
| 661 | 689 | } | |
| 690 | + | s.wheelRest = [2]int32{} | |
| 662 | 691 | // The new client sets its own cursor on enter; until then, the arrow. | |
| 663 | 692 | s.cursorSurface = nil | |
| 664 | 693 | s.setCursor(ports.CursorChange{}) | |
| @@ -82,6 +82,7 @@ type Server struct { | |||||
| 82 | nextPool uint64 | 82 | nextPool uint64 | ||
| 83 | focused ports.WindowID | 83 | focused ports.WindowID | ||
| 84 | pointerFocus ports.WindowID | 84 | pointerFocus ports.WindowID | ||
| 85 | + | wheelRest [2]int32 // v120 not yet sent as axis_discrete, per axis | |||
| 85 | pointerX, pointerY float64 | 86 | pointerX, pointerY float64 | ||
| 86 | pointers map[server.Client][]*wayland.Pointer | 87 | pointers map[server.Client][]*wayland.Pointer | ||
| 87 | modState ports.ModState | 88 | 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 | return | 688 | 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 = nil | 692 | 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 { | |||
| 740 | 740 | } | |
| 741 | 741 | continue | |
| 742 | 742 | 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 { | |
| 750 | 747 | return nil | |
| 751 | 748 | } | |
| 752 | 749 | } | |
| @@ -740,13 +740,10 @@ func (c *Core) Run(ctx context.Context) error { | |||||
| 740 | } | 740 | } | ||
| 741 | continue | 741 | 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.pointer | 744 | + | // pointer focus even mid-drag. |
| 745 | - | if c.grab != 0 { | 745 | + | if c.pointer != 0 { |
| 746 | - | id = c.grab | 746 | + | 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 nil | 747 | return nil | ||
| 751 | } | 748 | } | ||
| 752 | } | 749 | } | ||
M internal/core/core_test.go +3 -0
| @@ -269,6 +269,9 @@ func TestPointerFocusAndGrab(t *testing.T) { | |||
| 269 | 269 | if v := command(t, commands); v != (ports.PointerFocus{}) { | |
| 270 | 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 | 275 | input <- ports.PointerButton{Button: 0x110} | |
| 273 | 276 | if v := command(t, commands); v != (ports.PointerButtonTo{ID: 1, Button: 0x110}) { | |
| 274 | 277 | 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) | ||