detached 37d8478

feat(input): forward scroll wheel and finger scroll to clients

commit
37d8478507b5936d3985616cbf795c07e69c1790
author
brice · · 2026-09-25 19:50 UTC
parent
9bc7abf
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
7 files · +148 -11
M internal/adapters/libinput/libinput.go +28 -0
@@ -27,6 +27,9 @@ const (
2727 evPointerMotion = 400
2828 evPointerAbs = 401
2929 evPointerButton = 402
30+ evScrollWheel = 404
31+ evScrollFinger = 405
32+ evScrollCont = 406
3033 )
3134
3235 var (
@@ -60,6 +63,9 @@ var (
6063 pointerAbsY func(pev uintptr, height uint32) float64
6164 pointerButton func(pev uintptr) uint32
6265 pointerBtnState func(pev uintptr) int32
66+ pointerHasAxis func(pev uintptr, axis uint32) int32
67+ scrollValue func(pev uintptr, axis uint32) float64
68+ scrollV120 func(pev uintptr, axis uint32) float64
6369 iface [2]uintptr
6470 active ports.Seat
6571 )
@@ -109,6 +115,9 @@ func load() error {
109115 reg(&pointerAbsY, "event_pointer_get_absolute_y_transformed")
110116 reg(&pointerButton, "event_pointer_get_button")
111117 reg(&pointerBtnState, "event_pointer_get_button_state")
118+ reg(&pointerHasAxis, "event_pointer_has_axis")
119+ reg(&scrollValue, "event_pointer_get_scroll_value")
120+ reg(&scrollV120, "event_pointer_get_scroll_value_v120")
112121 iface[0] = purego.NewCallback(func(path *byte, _, _ uintptr) uintptr {
113122 fd, err := active.OpenDevice(goString(path))
114123 if err != nil {
@@ -286,6 +295,25 @@ func translate(ev uintptr, opts Options, p *pointer) (ports.InputEvent, error) {
286295 b, pressed := pointerButton(pe), pointerBtnState(pe) == 1
287296 log.Debug().Uint32("button", b).Bool("pressed", pressed).Msg("button")
288297 return ports.PointerButton{Button: b, Pressed: pressed, TimeMsec: now}, nil
298+ case evScrollWheel, evScrollFinger, evScrollCont:
299+ pe := pointerEvent(ev)
300+ // The three scroll events follow each other as the sources do.
301+ source := ports.AxisSource(eventType(ev) - evScrollWheel)
302+ a := ports.PointerAxis{Source: source, TimeMsec: now}
303+ // libinput axis 0 is vertical, 1 horizontal, as in wl_pointer.
304+ for i, s := range []*ports.ScrollAxis{&a.Vertical, &a.Horizontal} {
305+ if pointerHasAxis(pe, uint32(i)) == 0 {
306+ continue
307+ }
308+ s.Set, s.Value = true, scrollValue(pe, uint32(i))
309+ if source == ports.AxisWheel {
310+ s.V120 = int32(scrollV120(pe, uint32(i)))
311+ } else {
312+ s.Stop = s.Value == 0
313+ }
314+ }
315+ log.Debug().Float64("v", a.Vertical.Value).Float64("h", a.Horizontal.Value).Int32("v120", a.Vertical.V120).Msg("scroll")
316+ return a, nil
289317 }
290318 return nil, nil
291319 }
@@ -27,6 +27,9 @@ const (
27 evPointerMotion = 40027 evPointerMotion = 400
28 evPointerAbs = 40128 evPointerAbs = 401
29 evPointerButton = 40229 evPointerButton = 402
30+ evScrollWheel = 404
31+ evScrollFinger = 405
32+ evScrollCont = 406
30 )33 )
31 34
32 var (35 var (
@@ -60,6 +63,9 @@ var (
60 pointerAbsY func(pev uintptr, height uint32) float6463 pointerAbsY func(pev uintptr, height uint32) float64
61 pointerButton func(pev uintptr) uint3264 pointerButton func(pev uintptr) uint32
62 pointerBtnState func(pev uintptr) int3265 pointerBtnState func(pev uintptr) int32
66+ pointerHasAxis func(pev uintptr, axis uint32) int32
67+ scrollValue func(pev uintptr, axis uint32) float64
68+ scrollV120 func(pev uintptr, axis uint32) float64
63 iface [2]uintptr69 iface [2]uintptr
64 active ports.Seat70 active ports.Seat
65 )71 )
@@ -109,6 +115,9 @@ func load() error {
109 reg(&pointerAbsY, "event_pointer_get_absolute_y_transformed")115 reg(&pointerAbsY, "event_pointer_get_absolute_y_transformed")
110 reg(&pointerButton, "event_pointer_get_button")116 reg(&pointerButton, "event_pointer_get_button")
111 reg(&pointerBtnState, "event_pointer_get_button_state")117 reg(&pointerBtnState, "event_pointer_get_button_state")
118+ reg(&pointerHasAxis, "event_pointer_has_axis")
119+ reg(&scrollValue, "event_pointer_get_scroll_value")
120+ reg(&scrollV120, "event_pointer_get_scroll_value_v120")
112 iface[0] = purego.NewCallback(func(path *byte, _, _ uintptr) uintptr {121 iface[0] = purego.NewCallback(func(path *byte, _, _ uintptr) uintptr {
113 fd, err := active.OpenDevice(goString(path))122 fd, err := active.OpenDevice(goString(path))
114 if err != nil {123 if err != nil {
@@ -286,6 +295,25 @@ func translate(ev uintptr, opts Options, p *pointer) (ports.InputEvent, error) {
286 b, pressed := pointerButton(pe), pointerBtnState(pe) == 1295 b, pressed := pointerButton(pe), pointerBtnState(pe) == 1
287 log.Debug().Uint32("button", b).Bool("pressed", pressed).Msg("button")296 log.Debug().Uint32("button", b).Bool("pressed", pressed).Msg("button")
288 return ports.PointerButton{Button: b, Pressed: pressed, TimeMsec: now}, nil297 return ports.PointerButton{Button: b, Pressed: pressed, TimeMsec: now}, nil
298+ case evScrollWheel, evScrollFinger, evScrollCont:
299+ pe := pointerEvent(ev)
300+ // The three scroll events follow each other as the sources do.
301+ source := ports.AxisSource(eventType(ev) - evScrollWheel)
302+ a := ports.PointerAxis{Source: source, TimeMsec: now}
303+ // libinput axis 0 is vertical, 1 horizontal, as in wl_pointer.
304+ for i, s := range []*ports.ScrollAxis{&a.Vertical, &a.Horizontal} {
305+ if pointerHasAxis(pe, uint32(i)) == 0 {
306+ continue
307+ }
308+ s.Set, s.Value = true, scrollValue(pe, uint32(i))
309+ if source == ports.AxisWheel {
310+ s.V120 = int32(scrollV120(pe, uint32(i)))
311+ } else {
312+ s.Stop = s.Value == 0
313+ }
314+ }
315+ log.Debug().Float64("v", a.Vertical.Value).Float64("h", a.Horizontal.Value).Int32("v120", a.Vertical.V120).Msg("scroll")
316+ return a, nil
289 }317 }
290 return nil, nil318 return nil, nil
291 }319 }
M internal/adapters/wayland/globals.go +1 -1
@@ -38,7 +38,7 @@ func registerGlobals(d *server.Display, o Options, s *Server) error {
3838 })
3939 },
4040 func() error {
41- return wayland.NewSeatGlobal(d, 7, func(c server.Client, v, id uint32) {
41+ return wayland.NewSeatGlobal(d, 8, func(c server.Client, v, id uint32) {
4242 r, e := wayland.NewSeat(c, int32(v), id, seat{s})
4343 if e == nil {
4444 capabilities := uint32(wayland.SeatCapabilityPointer)
@@ -38,7 +38,7 @@ func registerGlobals(d *server.Display, o Options, s *Server) error {
38 })38 })
39 },39 },
40 func() error {40 func() error {
41- return wayland.NewSeatGlobal(d, 7, func(c server.Client, v, id uint32) {41+ return wayland.NewSeatGlobal(d, 8, func(c server.Client, v, id uint32) {
42 r, e := wayland.NewSeat(c, int32(v), id, seat{s})42 r, e := wayland.NewSeat(c, int32(v), id, seat{s})
43 if e == nil {43 if e == nil {
44 capabilities := uint32(wayland.SeatCapabilityPointer)44 capabilities := uint32(wayland.SeatCapabilityPointer)
M internal/adapters/wayland/protocol_test.go +35 -10
@@ -3,6 +3,7 @@ package wayland
33 import (
44 "bytes"
55 "errors"
6+ "fmt"
67 "github.com/bnema/nefertty/internal/ports"
78 "os"
89 "path/filepath"
@@ -29,12 +30,17 @@ func protocolClient(t *testing.T, s *Server, dir string) *wlturbo.Display {
2930 }
3031
3132 func bindProtocol(t *testing.T, c *wlturbo.Display, iface string) uint32 {
33+ t.Helper()
34+ return bindVersion(t, c, iface, 1)
35+}
36+
37+func bindVersion(t *testing.T, c *wlturbo.Display, iface string, version uint32) uint32 {
3238 t.Helper()
3339 g, ok := c.Registry().FindGlobal(iface)
3440 if !ok {
3541 t.Fatalf("missing %s", iface)
3642 }
37- id, err := c.Registry().BindID(g.Name, g.Interface, 1)
43+ id, err := c.Registry().BindID(g.Name, g.Interface, version)
3844 if err != nil {
3945 t.Fatal(err)
4046 }
@@ -425,6 +431,7 @@ type pointerEvents struct {
425431 wlturbo.BaseProxy
426432 enters chan [2]float64
427433 buttons chan uint32
434+ axes chan string
428435 }
429436
430437 func (p *pointerEvents) Dispatch(e *wlturbo.Event) {
@@ -437,16 +444,26 @@ func (p *pointerEvents) Dispatch(e *wlturbo.Event) {
437444 _ = e.Uint32()
438445 _ = e.Uint32()
439446 p.buttons <- e.Uint32()
447+ case uint16(wayland.PointerEventAxisSource):
448+ p.axes <- fmt.Sprint("source ", e.Uint32())
449+ case uint16(wayland.PointerEventAxisValue120):
450+ p.axes <- fmt.Sprint("v120 ", e.Uint32(), " ", e.Int32())
451+ case uint16(wayland.PointerEventAxis):
452+ _ = e.Uint32()
453+ p.axes <- fmt.Sprint("axis ", e.Uint32(), " ", e.Fixed().Float64())
454+ case uint16(wayland.PointerEventAxisStop):
455+ _ = e.Uint32()
456+ p.axes <- fmt.Sprint("stop ", e.Uint32())
440457 }
441458 }
442459
443460 func TestPointerProtocol(t *testing.T) {
444461 s, events, commands, dir := lifecycleServer(t)
445462 c := protocolClient(t, s, dir)
446- seat := bindProtocol(t, c, "wl_seat")
463+ seat := bindVersion(t, c, "wl_seat", 8)
447464 registerProtocol(t, c, seat)
448465 pointer := c.AllocateID()
449- p := &pointerEvents{enters: make(chan [2]float64, 1), buttons: make(chan uint32, 1)}
466+ p := &pointerEvents{enters: make(chan [2]float64, 1), buttons: make(chan uint32, 8), axes: make(chan string, 16)}
450467 p.SetID(pointer)
451468 c.Context().Register(p)
452469 requestProtocol(t, c, seat, wayland.SeatRequestGetPointer, pointer)
@@ -493,6 +510,12 @@ func TestPointerProtocol(t *testing.T) {
493510 w := mapped(t, events, 2*time.Second)
494511 commands <- ports.PointerFocus{ID: w.ID, X: 10, Y: 20}
495512 commands <- ports.PointerButtonTo{ID: w.ID, Button: 0x110, Pressed: true, TimeMsec: 1}
513+ // Middle, side and extra buttons pass through as evdev codes.
514+ for _, b := range []uint32{0x112, 0x113, 0x114} {
515+ commands <- ports.PointerButtonTo{ID: w.ID, Button: b, Pressed: true, TimeMsec: 1}
516+ }
517+ commands <- ports.PointerAxisTo{ID: w.ID, Axis: ports.PointerAxis{Source: ports.AxisWheel, Vertical: ports.ScrollAxis{Set: true, Value: 15, V120: 120}, TimeMsec: 2}}
518+ commands <- ports.PointerAxisTo{ID: w.ID, Axis: ports.PointerAxis{Source: ports.AxisFinger, Horizontal: ports.ScrollAxis{Set: true, Stop: true}, TimeMsec: 3}}
496519 deadline := time.After(2 * time.Second)
497520 dispatched := make(chan error, 1)
498521 go func() {
@@ -503,7 +526,7 @@ func TestPointerProtocol(t *testing.T) {
503526 }
504527 }
505528 }()
506- for len(p.enters) == 0 || len(p.buttons) == 0 {
529+ for len(p.enters) == 0 || len(p.buttons) < 4 || len(p.axes) < 5 {
507530 select {
508531 case err := <-dispatched:
509532 t.Fatal(err)
@@ -520,13 +543,15 @@ func TestPointerProtocol(t *testing.T) {
520543 default:
521544 t.Fatal("missing pointer enter")
522545 }
523- select {
524- case button := <-p.buttons:
525- if button != 0x110 {
526- t.Fatalf("button: %d", button)
546+ for _, want := range []uint32{0x110, 0x112, 0x113, 0x114} {
547+ if b := <-p.buttons; b != want {
548+ t.Fatalf("button: %#x, want %#x", b, want)
549+ }
550+ }
551+ for _, want := range []string{"source 0", "v120 0 120", "axis 0 15", "source 1", "stop 1"} {
552+ if got := <-p.axes; got != want {
553+ t.Fatalf("axis event %q, want %q", got, want)
527554 }
528- default:
529- t.Fatal("missing pointer button")
530555 }
531556 }
532557
@@ -3,6 +3,7 @@ package wayland
3 import (3 import (
4 "bytes"4 "bytes"
5 "errors"5 "errors"
6+ "fmt"
6 "github.com/bnema/nefertty/internal/ports"7 "github.com/bnema/nefertty/internal/ports"
7 "os"8 "os"
8 "path/filepath"9 "path/filepath"
@@ -29,12 +30,17 @@ func protocolClient(t *testing.T, s *Server, dir string) *wlturbo.Display {
29 }30 }
30 31
31 func bindProtocol(t *testing.T, c *wlturbo.Display, iface string) uint32 {32 func bindProtocol(t *testing.T, c *wlturbo.Display, iface string) uint32 {
33+ t.Helper()
34+ return bindVersion(t, c, iface, 1)
35+}
36+
37+func bindVersion(t *testing.T, c *wlturbo.Display, iface string, version uint32) uint32 {
32 t.Helper()38 t.Helper()
33 g, ok := c.Registry().FindGlobal(iface)39 g, ok := c.Registry().FindGlobal(iface)
34 if !ok {40 if !ok {
35 t.Fatalf("missing %s", iface)41 t.Fatalf("missing %s", iface)
36 }42 }
37- id, err := c.Registry().BindID(g.Name, g.Interface, 1)43+ id, err := c.Registry().BindID(g.Name, g.Interface, version)
38 if err != nil {44 if err != nil {
39 t.Fatal(err)45 t.Fatal(err)
40 }46 }
@@ -425,6 +431,7 @@ type pointerEvents struct {
425 wlturbo.BaseProxy431 wlturbo.BaseProxy
426 enters chan [2]float64432 enters chan [2]float64
427 buttons chan uint32433 buttons chan uint32
434+ axes chan string
428 }435 }
429 436
430 func (p *pointerEvents) Dispatch(e *wlturbo.Event) {437 func (p *pointerEvents) Dispatch(e *wlturbo.Event) {
@@ -437,16 +444,26 @@ func (p *pointerEvents) Dispatch(e *wlturbo.Event) {
437 _ = e.Uint32()444 _ = e.Uint32()
438 _ = e.Uint32()445 _ = e.Uint32()
439 p.buttons <- e.Uint32()446 p.buttons <- e.Uint32()
447+ case uint16(wayland.PointerEventAxisSource):
448+ p.axes <- fmt.Sprint("source ", e.Uint32())
449+ case uint16(wayland.PointerEventAxisValue120):
450+ p.axes <- fmt.Sprint("v120 ", e.Uint32(), " ", e.Int32())
451+ case uint16(wayland.PointerEventAxis):
452+ _ = e.Uint32()
453+ p.axes <- fmt.Sprint("axis ", e.Uint32(), " ", e.Fixed().Float64())
454+ case uint16(wayland.PointerEventAxisStop):
455+ _ = e.Uint32()
456+ p.axes <- fmt.Sprint("stop ", e.Uint32())
440 }457 }
441 }458 }
442 459
443 func TestPointerProtocol(t *testing.T) {460 func TestPointerProtocol(t *testing.T) {
444 s, events, commands, dir := lifecycleServer(t)461 s, events, commands, dir := lifecycleServer(t)
445 c := protocolClient(t, s, dir)462 c := protocolClient(t, s, dir)
446- seat := bindProtocol(t, c, "wl_seat")463+ seat := bindVersion(t, c, "wl_seat", 8)
447 registerProtocol(t, c, seat)464 registerProtocol(t, c, seat)
448 pointer := c.AllocateID()465 pointer := c.AllocateID()
449- p := &pointerEvents{enters: make(chan [2]float64, 1), buttons: make(chan uint32, 1)}466+ p := &pointerEvents{enters: make(chan [2]float64, 1), buttons: make(chan uint32, 8), axes: make(chan string, 16)}
450 p.SetID(pointer)467 p.SetID(pointer)
451 c.Context().Register(p)468 c.Context().Register(p)
452 requestProtocol(t, c, seat, wayland.SeatRequestGetPointer, pointer)469 requestProtocol(t, c, seat, wayland.SeatRequestGetPointer, pointer)
@@ -493,6 +510,12 @@ func TestPointerProtocol(t *testing.T) {
493 w := mapped(t, events, 2*time.Second)510 w := mapped(t, events, 2*time.Second)
494 commands <- ports.PointerFocus{ID: w.ID, X: 10, Y: 20}511 commands <- ports.PointerFocus{ID: w.ID, X: 10, Y: 20}
495 commands <- ports.PointerButtonTo{ID: w.ID, Button: 0x110, Pressed: true, TimeMsec: 1}512 commands <- ports.PointerButtonTo{ID: w.ID, Button: 0x110, Pressed: true, TimeMsec: 1}
513+ // Middle, side and extra buttons pass through as evdev codes.
514+ for _, b := range []uint32{0x112, 0x113, 0x114} {
515+ commands <- ports.PointerButtonTo{ID: w.ID, Button: b, Pressed: true, TimeMsec: 1}
516+ }
517+ commands <- ports.PointerAxisTo{ID: w.ID, Axis: ports.PointerAxis{Source: ports.AxisWheel, Vertical: ports.ScrollAxis{Set: true, Value: 15, V120: 120}, TimeMsec: 2}}
518+ commands <- ports.PointerAxisTo{ID: w.ID, Axis: ports.PointerAxis{Source: ports.AxisFinger, Horizontal: ports.ScrollAxis{Set: true, Stop: true}, TimeMsec: 3}}
496 deadline := time.After(2 * time.Second)519 deadline := time.After(2 * time.Second)
497 dispatched := make(chan error, 1)520 dispatched := make(chan error, 1)
498 go func() {521 go func() {
@@ -503,7 +526,7 @@ func TestPointerProtocol(t *testing.T) {
503 }526 }
504 }527 }
505 }()528 }()
506- for len(p.enters) == 0 || len(p.buttons) == 0 {529+ for len(p.enters) == 0 || len(p.buttons) < 4 || len(p.axes) < 5 {
507 select {530 select {
508 case err := <-dispatched:531 case err := <-dispatched:
509 t.Fatal(err)532 t.Fatal(err)
@@ -520,13 +543,15 @@ func TestPointerProtocol(t *testing.T) {
520 default:543 default:
521 t.Fatal("missing pointer enter")544 t.Fatal("missing pointer enter")
522 }545 }
523- select {546+ for _, want := range []uint32{0x110, 0x112, 0x113, 0x114} {
524- case button := <-p.buttons:547+ if b := <-p.buttons; b != want {
525- if button != 0x110 {548+ t.Fatalf("button: %#x, want %#x", b, want)
526- t.Fatalf("button: %d", button)549+ }
550+ }
551+ for _, want := range []string{"source 0", "v120 0 120", "axis 0 15", "source 1", "stop 1"} {
552+ if got := <-p.axes; got != want {
553+ t.Fatalf("axis event %q, want %q", got, want)
527 }554 }
528- default:
529- t.Fatal("missing pointer button")
530 }555 }
531 }556 }
532 557
M internal/adapters/wayland/server.go +32 -0
@@ -396,6 +396,10 @@ func (s *Server) apply(cmd ports.ClientCommand) {
396396 pointerFrame(p)
397397 }
398398 }
399+ case ports.PointerAxisTo:
400+ for _, p := range s.windowPointers(s.windows[c.ID]) {
401+ sendAxis(p, c.Axis)
402+ }
399403 case ports.SetKeymap:
400404 s.setKeymap(c)
401405 case ports.FocusWindow:
@@ -606,6 +610,34 @@ func (s *Server) changeFocus(id ports.WindowID) {
606610 s.updateConstraint()
607611 }
608612
613+// sendAxis sends one scroll frame, each event gated by the pointer version.
614+func sendAxis(p *wayland.Pointer, a ports.PointerAxis) {
615+ v := p.Version()
616+ if v >= 5 {
617+ p.SendAxisSource(uint32(a.Source))
618+ }
619+ for axis, s := range [2]ports.ScrollAxis{a.Vertical, a.Horizontal} {
620+ if !s.Set {
621+ continue
622+ }
623+ if s.Stop {
624+ if v >= 5 {
625+ p.SendAxisStop(a.TimeMsec, uint32(axis))
626+ }
627+ continue
628+ }
629+ if a.Source == ports.AxisWheel && s.V120 != 0 {
630+ if v >= 8 {
631+ p.SendAxisValue120(uint32(axis), s.V120)
632+ } else if v >= 5 && s.V120/120 != 0 {
633+ p.SendAxisDiscrete(uint32(axis), s.V120/120)
634+ }
635+ }
636+ p.SendAxis(a.TimeMsec, uint32(axis), server.FixedFromFloat(s.Value))
637+ }
638+ pointerFrame(p)
639+}
640+
609641 func pointerFrame(p *wayland.Pointer) {
610642 if p.Version() >= 5 {
611643 p.SendFrame()
@@ -396,6 +396,10 @@ func (s *Server) apply(cmd ports.ClientCommand) {
396 pointerFrame(p)396 pointerFrame(p)
397 }397 }
398 }398 }
399+ case ports.PointerAxisTo:
400+ for _, p := range s.windowPointers(s.windows[c.ID]) {
401+ sendAxis(p, c.Axis)
402+ }
399 case ports.SetKeymap:403 case ports.SetKeymap:
400 s.setKeymap(c)404 s.setKeymap(c)
401 case ports.FocusWindow:405 case ports.FocusWindow:
@@ -606,6 +610,34 @@ func (s *Server) changeFocus(id ports.WindowID) {
606 s.updateConstraint()610 s.updateConstraint()
607 }611 }
608 612
613+// sendAxis sends one scroll frame, each event gated by the pointer version.
614+func sendAxis(p *wayland.Pointer, a ports.PointerAxis) {
615+ v := p.Version()
616+ if v >= 5 {
617+ p.SendAxisSource(uint32(a.Source))
618+ }
619+ for axis, s := range [2]ports.ScrollAxis{a.Vertical, a.Horizontal} {
620+ if !s.Set {
621+ continue
622+ }
623+ if s.Stop {
624+ if v >= 5 {
625+ p.SendAxisStop(a.TimeMsec, uint32(axis))
626+ }
627+ continue
628+ }
629+ if a.Source == ports.AxisWheel && s.V120 != 0 {
630+ if v >= 8 {
631+ p.SendAxisValue120(uint32(axis), s.V120)
632+ } else if v >= 5 && s.V120/120 != 0 {
633+ p.SendAxisDiscrete(uint32(axis), s.V120/120)
634+ }
635+ }
636+ p.SendAxis(a.TimeMsec, uint32(axis), server.FixedFromFloat(s.Value))
637+ }
638+ pointerFrame(p)
639+}
640+
609 func pointerFrame(p *wayland.Pointer) {641 func pointerFrame(p *wayland.Pointer) {
610 if p.Version() >= 5 {642 if p.Version() >= 5 {
611 p.SendFrame()643 p.SendFrame()
M internal/core/core.go +12 -0
@@ -739,6 +739,18 @@ func (c *Core) Run(ctx context.Context) error {
739739 c.grab = 0
740740 }
741741 continue
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 {
750+ return nil
751+ }
752+ }
753+ continue
742754 }
743755 key, ok := ev.(ports.KeyEvent)
744756 if !ok {
@@ -739,6 +739,18 @@ func (c *Core) Run(ctx context.Context) error {
739 c.grab = 0739 c.grab = 0
740 }740 }
741 continue741 continue
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 {
750+ return nil
751+ }
752+ }
753+ continue
742 }754 }
743 key, ok := ev.(ports.KeyEvent)755 key, ok := ev.(ports.KeyEvent)
744 if !ok {756 if !ok {
M internal/core/core_test.go +4 -0
@@ -247,6 +247,10 @@ func TestPointerFocusAndGrab(t *testing.T) {
247247 if v := command(t, commands); v != (ports.PointerMotionTo{ID: 1, X: 2, Y: 3, TimeMsec: 1}) {
248248 t.Fatal(v)
249249 }
250+ input <- ports.PointerAxis{Vertical: ports.ScrollAxis{Set: true, Value: 15, V120: 120}}
251+ if v := command(t, commands); v != (ports.PointerAxisTo{ID: 1, Axis: ports.PointerAxis{Vertical: ports.ScrollAxis{Set: true, Value: 15, V120: 120}}}) {
252+ t.Fatal(v)
253+ }
250254 input <- ports.PointerButton{Button: 0x110, Pressed: true}
251255 if v := command(t, commands); v != (ports.PointerButtonTo{ID: 1, Button: 0x110, Pressed: true}) {
252256 t.Fatal(v)
@@ -247,6 +247,10 @@ func TestPointerFocusAndGrab(t *testing.T) {
247 if v := command(t, commands); v != (ports.PointerMotionTo{ID: 1, X: 2, Y: 3, TimeMsec: 1}) {247 if v := command(t, commands); v != (ports.PointerMotionTo{ID: 1, X: 2, Y: 3, TimeMsec: 1}) {
248 t.Fatal(v)248 t.Fatal(v)
249 }249 }
250+ input <- ports.PointerAxis{Vertical: ports.ScrollAxis{Set: true, Value: 15, V120: 120}}
251+ if v := command(t, commands); v != (ports.PointerAxisTo{ID: 1, Axis: ports.PointerAxis{Vertical: ports.ScrollAxis{Set: true, Value: 15, V120: 120}}}) {
252+ t.Fatal(v)
253+ }
250 input <- ports.PointerButton{Button: 0x110, Pressed: true}254 input <- ports.PointerButton{Button: 0x110, Pressed: true}
251 if v := command(t, commands); v != (ports.PointerButtonTo{ID: 1, Button: 0x110, Pressed: true}) {255 if v := command(t, commands); v != (ports.PointerButtonTo{ID: 1, Button: 0x110, Pressed: true}) {
252 t.Fatal(v)256 t.Fatal(v)
M internal/ports/messages.go +36 -0
@@ -255,6 +255,34 @@ type PointerButton struct {
255255
256256 func (PointerButton) inputEvent() {}
257257
258+// AxisSource tells how a scroll was made; values match wl_pointer.axis_source.
259+type AxisSource uint8
260+
261+const (
262+ AxisWheel AxisSource = iota
263+ AxisFinger
264+ AxisContinuous
265+)
266+
267+// ScrollAxis is one scroll direction in a pointer frame. Value is in
268+// surface pixels; V120 counts wheel detents in 1/120 steps (wheel only).
269+// Stop ends a finger or continuous scroll on this axis.
270+type ScrollAxis struct {
271+ Set bool
272+ Value float64
273+ V120 int32
274+ Stop bool
275+}
276+
277+// PointerAxis is one scroll frame: Vertical, then Horizontal.
278+type PointerAxis struct {
279+ Source AxisSource
280+ Vertical, Horizontal ScrollAxis
281+ TimeMsec uint32
282+}
283+
284+func (PointerAxis) inputEvent() {}
285+
258286 // OutputEvent carries output → core notifications.
259287 type OutputEvent interface{ outputEvent() }
260288
@@ -440,6 +468,14 @@ type PointerButtonTo struct {
440468
441469 func (PointerButtonTo) clientCommand() {}
442470
471+// PointerAxisTo sends a scroll frame to a window.
472+type PointerAxisTo struct {
473+ ID WindowID
474+ Axis PointerAxis
475+}
476+
477+func (PointerAxisTo) clientCommand() {}
478+
443479 // ForwardKey carries core → wayland unbound keys.
444480 type ForwardKey struct {
445481 ID WindowID
@@ -255,6 +255,34 @@ type PointerButton struct {
255 255
256 func (PointerButton) inputEvent() {}256 func (PointerButton) inputEvent() {}
257 257
258+// AxisSource tells how a scroll was made; values match wl_pointer.axis_source.
259+type AxisSource uint8
260+
261+const (
262+ AxisWheel AxisSource = iota
263+ AxisFinger
264+ AxisContinuous
265+)
266+
267+// ScrollAxis is one scroll direction in a pointer frame. Value is in
268+// surface pixels; V120 counts wheel detents in 1/120 steps (wheel only).
269+// Stop ends a finger or continuous scroll on this axis.
270+type ScrollAxis struct {
271+ Set bool
272+ Value float64
273+ V120 int32
274+ Stop bool
275+}
276+
277+// PointerAxis is one scroll frame: Vertical, then Horizontal.
278+type PointerAxis struct {
279+ Source AxisSource
280+ Vertical, Horizontal ScrollAxis
281+ TimeMsec uint32
282+}
283+
284+func (PointerAxis) inputEvent() {}
285+
258 // OutputEvent carries output → core notifications.286 // OutputEvent carries output → core notifications.
259 type OutputEvent interface{ outputEvent() }287 type OutputEvent interface{ outputEvent() }
260 288
@@ -440,6 +468,14 @@ type PointerButtonTo struct {
440 468
441 func (PointerButtonTo) clientCommand() {}469 func (PointerButtonTo) clientCommand() {}
442 470
471+// PointerAxisTo sends a scroll frame to a window.
472+type PointerAxisTo struct {
473+ ID WindowID
474+ Axis PointerAxis
475+}
476+
477+func (PointerAxisTo) clientCommand() {}
478+
443 // ForwardKey carries core → wayland unbound keys.479 // ForwardKey carries core → wayland unbound keys.
444 type ForwardKey struct {480 type ForwardKey struct {
445 ID WindowID481 ID WindowID

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