detached 5cf9134

feat(input): add pointer focus, click-to-focus and scripted pointer input

commit
5cf91344604cc454f37767f7aa7849fdb1a04929
author
brice · · 2026-09-23 16:01 UTC
parent
5928c82
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
12 files · +417 -16
M README.md +3 -1
@@ -28,4 +28,6 @@ Building requires sibling checkouts of purego-libwayland and purego-vulkan until
2828 Run `nefertty --backend=headless --screenshot /tmp/nefertty-shots`, then connect with
2929 `WAYLAND_DISPLAY=<logged name> foot`. `/tmp/nefertty-shots/latest.png` shows the current frame.
3030 Use `--input path` (or `--input -` for stdin) to inject a headless script. Each line is
31-`type text`, `key Super+Return` (also Shift, Ctrl, Alt or a bare key), or `sleep 1s`.
31+`type text`, `key Super+Return` (also Shift, Ctrl, Alt or a bare key), `sleep 1s`, `move x y` (absolute output coordinates), `click [left|right|middle]`,
32+`down button`, or `up button`. Cursor rendering is not implemented yet; pointer
33+focus after layout changes is updated on the next move.
@@ -28,4 +28,6 @@ Building requires sibling checkouts of purego-libwayland and purego-vulkan until
28 Run `nefertty --backend=headless --screenshot /tmp/nefertty-shots`, then connect with28 Run `nefertty --backend=headless --screenshot /tmp/nefertty-shots`, then connect with
29 `WAYLAND_DISPLAY=<logged name> foot`. `/tmp/nefertty-shots/latest.png` shows the current frame.29 `WAYLAND_DISPLAY=<logged name> foot`. `/tmp/nefertty-shots/latest.png` shows the current frame.
30 Use `--input path` (or `--input -` for stdin) to inject a headless script. Each line is30 Use `--input path` (or `--input -` for stdin) to inject a headless script. Each line is
31-`type text`, `key Super+Return` (also Shift, Ctrl, Alt or a bare key), or `sleep 1s`.31+`type text`, `key Super+Return` (also Shift, Ctrl, Alt or a bare key), `sleep 1s`, `move x y` (absolute output coordinates), `click [left|right|middle]`,
32+`down button`, or `up button`. Cursor rendering is not implemented yet; pointer
33+focus after layout changes is updated on the next move.
M internal/adapters/headlessinput/input.go +59 -0
@@ -3,6 +3,8 @@ package headlessinput
33
44 import (
55 "context"
6+ "math"
7+ "strconv"
68 "strings"
79 "time"
810 "unicode"
@@ -67,6 +69,63 @@ func Run(ctx context.Context, km *xkb.Keymap, script <-chan string, input chan<-
6769 if !ok {
6870 return nil
6971 }
72+ fields := strings.Fields(line)
73+ sendPointer := func(ev ports.InputEvent) error {
74+ select {
75+ case <-ctx.Done():
76+ return ctx.Err()
77+ case input <- ev:
78+ return nil
79+ }
80+ }
81+ buttonCode := func(name string) uint32 {
82+ switch name {
83+ case "left":
84+ return 0x110
85+ case "right":
86+ return 0x111
87+ case "middle":
88+ return 0x112
89+ }
90+ return 0
91+ }
92+ now := uint32(time.Now().UnixMilli())
93+ if len(fields) > 0 && fields[0] == "move" {
94+ if len(fields) == 3 {
95+ x, ex := strconv.ParseFloat(fields[1], 64)
96+ y, ey := strconv.ParseFloat(fields[2], 64)
97+ if ex == nil && ey == nil && !math.IsNaN(x) && !math.IsNaN(y) && !math.IsInf(x, 0) && !math.IsInf(y, 0) {
98+ if err := sendPointer(ports.PointerMotion{X: x, Y: y, TimeMsec: now}); err != nil {
99+ return err
100+ }
101+ continue
102+ }
103+ }
104+ log.Warn().Str("line", line).Msg("invalid move")
105+ continue
106+ }
107+ if len(fields) > 0 && (fields[0] == "click" || fields[0] == "down" || fields[0] == "up") {
108+ name := "left"
109+ if len(fields) == 2 {
110+ name = fields[1]
111+ }
112+ code := buttonCode(name)
113+ if len(fields) > 2 || (len(fields) != 2 && fields[0] != "click") || code == 0 {
114+ log.Warn().Str("line", line).Msg("invalid button")
115+ continue
116+ }
117+ if fields[0] != "up" {
118+ if err := sendPointer(ports.PointerButton{Button: code, Pressed: true, TimeMsec: now}); err != nil {
119+ return err
120+ }
121+ }
122+ if fields[0] != "down" {
123+ if err := sendPointer(ports.PointerButton{Button: code, TimeMsec: now}); err != nil {
124+ return err
125+ }
126+ }
127+ continue
128+ }
70129 switch {
71130 case strings.HasPrefix(line, "type "):
72131 for _, r := range strings.TrimPrefix(line, "type ") {
@@ -3,6 +3,8 @@ package headlessinput
3 3
4 import (4 import (
5 "context"5 "context"
6+ "math"
7+ "strconv"
6 "strings"8 "strings"
7 "time"9 "time"
8 "unicode"10 "unicode"
@@ -67,6 +69,63 @@ func Run(ctx context.Context, km *xkb.Keymap, script <-chan string, input chan<-
67 if !ok {69 if !ok {
68 return nil70 return nil
69 }71 }
72+ fields := strings.Fields(line)
73+ sendPointer := func(ev ports.InputEvent) error {
74+ select {
75+ case <-ctx.Done():
76+ return ctx.Err()
77+ case input <- ev:
78+ return nil
79+ }
80+ }
81+ buttonCode := func(name string) uint32 {
82+ switch name {
83+ case "left":
84+ return 0x110
85+ case "right":
86+ return 0x111
87+ case "middle":
88+ return 0x112
89+ }
90+ return 0
91+ }
92+ now := uint32(time.Now().UnixMilli())
93+ if len(fields) > 0 && fields[0] == "move" {
94+ if len(fields) == 3 {
95+ x, ex := strconv.ParseFloat(fields[1], 64)
96+ y, ey := strconv.ParseFloat(fields[2], 64)
97+ if ex == nil && ey == nil && !math.IsNaN(x) && !math.IsNaN(y) && !math.IsInf(x, 0) && !math.IsInf(y, 0) {
98+ if err := sendPointer(ports.PointerMotion{X: x, Y: y, TimeMsec: now}); err != nil {
99+ return err
100+ }
101+ continue
102+ }
103+ }
104+ log.Warn().Str("line", line).Msg("invalid move")
105+ continue
106+ }
107+ if len(fields) > 0 && (fields[0] == "click" || fields[0] == "down" || fields[0] == "up") {
108+ name := "left"
109+ if len(fields) == 2 {
110+ name = fields[1]
111+ }
112+ code := buttonCode(name)
113+ if len(fields) > 2 || (len(fields) != 2 && fields[0] != "click") || code == 0 {
114+ log.Warn().Str("line", line).Msg("invalid button")
115+ continue
116+ }
117+ if fields[0] != "up" {
118+ if err := sendPointer(ports.PointerButton{Button: code, Pressed: true, TimeMsec: now}); err != nil {
119+ return err
120+ }
121+ }
122+ if fields[0] != "down" {
123+ if err := sendPointer(ports.PointerButton{Button: code, TimeMsec: now}); err != nil {
124+ return err
125+ }
126+ }
127+ continue
128+ }
70 switch {129 switch {
71 case strings.HasPrefix(line, "type "):130 case strings.HasPrefix(line, "type "):
72 for _, r := range strings.TrimPrefix(line, "type ") {131 for _, r := range strings.TrimPrefix(line, "type ") {
M internal/adapters/headlessinput/input_test.go +29 -0
@@ -54,3 +54,32 @@ func TestScript(t *testing.T) {
5454 })
5555 }
5656 }
57+
58+func TestPointerScript(t *testing.T) {
59+ km, err := xkb.New(xkb.RMLVO{Layout: "us"})
60+ if err != nil {
61+ t.Skip(err)
62+ }
63+ script := make(chan string, 4)
64+ for _, line := range []string{"move 12.5 45", "click", "down right", "up right"} {
65+ script <- line
66+ }
67+ close(script)
68+ input := make(chan ports.InputEvent, 8)
69+ if err := Run(context.Background(), km, script, input, logging.For(context.Background(), "input")); err != nil {
70+ t.Fatal(err)
71+ }
72+ if len(input) != 5 {
73+ t.Fatalf("events: %d", len(input))
74+ }
75+ m := (<-input).(ports.PointerMotion)
76+ if m.X != 12.5 || m.Y != 45 {
77+ t.Fatal(m)
78+ }
79+ for _, want := range []ports.PointerButton{{Button: 0x110, Pressed: true}, {Button: 0x110}, {Button: 0x111, Pressed: true}, {Button: 0x111}} {
80+ got := (<-input).(ports.PointerButton)
81+ if got.Button != want.Button || got.Pressed != want.Pressed {
82+ t.Fatalf("got %+v want %+v", got, want)
83+ }
84+ }
85+}
@@ -54,3 +54,32 @@ func TestScript(t *testing.T) {
54 })54 })
55 }55 }
56 }56 }
57+
58+func TestPointerScript(t *testing.T) {
59+ km, err := xkb.New(xkb.RMLVO{Layout: "us"})
60+ if err != nil {
61+ t.Skip(err)
62+ }
63+ script := make(chan string, 4)
64+ for _, line := range []string{"move 12.5 45", "click", "down right", "up right"} {
65+ script <- line
66+ }
67+ close(script)
68+ input := make(chan ports.InputEvent, 8)
69+ if err := Run(context.Background(), km, script, input, logging.For(context.Background(), "input")); err != nil {
70+ t.Fatal(err)
71+ }
72+ if len(input) != 5 {
73+ t.Fatalf("events: %d", len(input))
74+ }
75+ m := (<-input).(ports.PointerMotion)
76+ if m.X != 12.5 || m.Y != 45 {
77+ t.Fatal(m)
78+ }
79+ for _, want := range []ports.PointerButton{{Button: 0x110, Pressed: true}, {Button: 0x110}, {Button: 0x111, Pressed: true}, {Button: 0x111}} {
80+ got := (<-input).(ports.PointerButton)
81+ if got.Button != want.Button || got.Pressed != want.Pressed {
82+ t.Fatalf("got %+v want %+v", got, want)
83+ }
84+ }
85+}
M internal/adapters/wayland/globals.go +30 -4
@@ -52,9 +52,9 @@ func registerGlobals(d *server.Display, o Options, s *Server) error {
5252 return wayland.NewSeatGlobal(d, 7, func(c server.Client, v, id uint32) {
5353 r, e := wayland.NewSeat(c, int32(v), id, seat{s})
5454 if e == nil {
55- capabilities := uint32(0)
55+ capabilities := uint32(wayland.SeatCapabilityPointer)
5656 if s.keymapFD >= 0 {
57- capabilities = uint32(wayland.SeatCapabilityKeyboard)
57+ capabilities |= uint32(wayland.SeatCapabilityKeyboard)
5858 }
5959 r.SendCapabilities(capabilities)
6060 if v >= 2 {
@@ -243,8 +243,32 @@ func (output) Release(*wayland.Output) {}
243243
244244 type seat struct{ server *Server }
245245
246-func (seat) GetPointer(r *wayland.Seat, id uint32) {
247- wayland.NewPointer(r.Client(), r.Version(), id, pointer{})
246+func (h seat) GetPointer(r *wayland.Seat, id uint32) {
247+ p, err := wayland.NewPointer(r.Client(), r.Version(), id, pointer{})
248+ if err != nil {
249+ return
250+ }
251+ s := h.server
252+ s.pointers[r.Client()] = append(s.pointers[r.Client()], p)
253+ p.OnDestroy = func() {
254+ list := s.pointers[r.Client()]
255+ for i, item := range list {
256+ if item == p {
257+ list = append(list[:i], list[i+1:]...)
258+ break
259+ }
260+ }
261+ if len(list) == 0 {
262+ delete(s.pointers, r.Client())
263+ } else {
264+ s.pointers[r.Client()] = list
265+ }
266+ }
267+ if w := s.windows[s.pointerFocus]; w != nil && w.mapped && w.xdg.resource.Client() == r.Client() {
268+ s.serial++
269+ p.SendEnter(s.serial, w.xdg.surfaceResource(), server.FixedFromFloat(s.pointerX), server.FixedFromFloat(s.pointerY))
270+ pointerFrame(p)
271+ }
248272 }
249273 func (h seat) GetKeyboard(r *wayland.Seat, id uint32) {
250274 k, err := wayland.NewKeyboard(r.Client(), r.Version(), id, keyboard{})
@@ -287,6 +311,8 @@ func (seat) Release(*wayland.Seat) {}
287311
288312 type pointer struct{}
289313
314+// Cursor surfaces are accepted but rendering cursors is not implemented yet.
315+
290316 func (pointer) SetCursor(*wayland.Pointer, uint32, *wayland.Surface, int32, int32) {}
291317 func (pointer) Release(*wayland.Pointer) {}
292318
@@ -52,9 +52,9 @@ func registerGlobals(d *server.Display, o Options, s *Server) error {
52 return wayland.NewSeatGlobal(d, 7, func(c server.Client, v, id uint32) {52 return wayland.NewSeatGlobal(d, 7, func(c server.Client, v, id uint32) {
53 r, e := wayland.NewSeat(c, int32(v), id, seat{s})53 r, e := wayland.NewSeat(c, int32(v), id, seat{s})
54 if e == nil {54 if e == nil {
55- capabilities := uint32(0)55+ capabilities := uint32(wayland.SeatCapabilityPointer)
56 if s.keymapFD >= 0 {56 if s.keymapFD >= 0 {
57- capabilities = uint32(wayland.SeatCapabilityKeyboard)57+ capabilities |= uint32(wayland.SeatCapabilityKeyboard)
58 }58 }
59 r.SendCapabilities(capabilities)59 r.SendCapabilities(capabilities)
60 if v >= 2 {60 if v >= 2 {
@@ -243,8 +243,32 @@ func (output) Release(*wayland.Output) {}
243 243
244 type seat struct{ server *Server }244 type seat struct{ server *Server }
245 245
246-func (seat) GetPointer(r *wayland.Seat, id uint32) {246+func (h seat) GetPointer(r *wayland.Seat, id uint32) {
247- wayland.NewPointer(r.Client(), r.Version(), id, pointer{})247+ p, err := wayland.NewPointer(r.Client(), r.Version(), id, pointer{})
248+ if err != nil {
249+ return
250+ }
251+ s := h.server
252+ s.pointers[r.Client()] = append(s.pointers[r.Client()], p)
253+ p.OnDestroy = func() {
254+ list := s.pointers[r.Client()]
255+ for i, item := range list {
256+ if item == p {
257+ list = append(list[:i], list[i+1:]...)
258+ break
259+ }
260+ }
261+ if len(list) == 0 {
262+ delete(s.pointers, r.Client())
263+ } else {
264+ s.pointers[r.Client()] = list
265+ }
266+ }
267+ if w := s.windows[s.pointerFocus]; w != nil && w.mapped && w.xdg.resource.Client() == r.Client() {
268+ s.serial++
269+ p.SendEnter(s.serial, w.xdg.surfaceResource(), server.FixedFromFloat(s.pointerX), server.FixedFromFloat(s.pointerY))
270+ pointerFrame(p)
271+ }
248 }272 }
249 func (h seat) GetKeyboard(r *wayland.Seat, id uint32) {273 func (h seat) GetKeyboard(r *wayland.Seat, id uint32) {
250 k, err := wayland.NewKeyboard(r.Client(), r.Version(), id, keyboard{})274 k, err := wayland.NewKeyboard(r.Client(), r.Version(), id, keyboard{})
@@ -287,6 +311,8 @@ func (seat) Release(*wayland.Seat) {}
287 311
288 type pointer struct{}312 type pointer struct{}
289 313
314+// Cursor surfaces are accepted but rendering cursors is not implemented yet.
315+
290 func (pointer) SetCursor(*wayland.Pointer, uint32, *wayland.Surface, int32, int32) {}316 func (pointer) SetCursor(*wayland.Pointer, uint32, *wayland.Surface, int32, int32) {}
291 func (pointer) Release(*wayland.Pointer) {}317 func (pointer) Release(*wayland.Pointer) {}
292 318
M internal/adapters/wayland/keyboard_test.go +1 -1
@@ -65,7 +65,7 @@ func TestKeyboardInfo(t *testing.T) {
6565 if err != nil {
6666 t.Fatalf("wayland-info: %v: %s", err, out)
6767 }
68- for _, want := range []string{"capabilities: keyboard", "keyboard repeat rate: 25", "keyboard repeat delay: 600"} {
68+ for _, want := range []string{"capabilities: pointer keyboard", "keyboard repeat rate: 25", "keyboard repeat delay: 600"} {
6969 if !strings.Contains(string(out), want) {
7070 t.Errorf("missing %q: %s", want, out)
7171 }
@@ -65,7 +65,7 @@ func TestKeyboardInfo(t *testing.T) {
65 if err != nil {65 if err != nil {
66 t.Fatalf("wayland-info: %v: %s", err, out)66 t.Fatalf("wayland-info: %v: %s", err, out)
67 }67 }
68- for _, want := range []string{"capabilities: keyboard", "keyboard repeat rate: 25", "keyboard repeat delay: 600"} {68+ for _, want := range []string{"capabilities: pointer keyboard", "keyboard repeat rate: 25", "keyboard repeat delay: 600"} {
69 if !strings.Contains(string(out), want) {69 if !strings.Contains(string(out), want) {
70 t.Errorf("missing %q: %s", want, out)70 t.Errorf("missing %q: %s", want, out)
71 }71 }
M internal/adapters/wayland/server.go +69 -1
@@ -45,6 +45,9 @@ type Server struct {
4545 windows map[ports.WindowID]*window
4646 nextWindow ports.WindowID
4747 focused ports.WindowID
48+ pointerFocus ports.WindowID
49+ pointerX, pointerY float64
50+ pointers map[server.Client][]*wayland.Pointer
4851 modState ports.ModState
4952 keyboards map[server.Client][]*wayland.Keyboard
5053 keymapFD int
@@ -87,7 +90,7 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) {
8790 d.Close()
8891 return nil, err
8992 }
90- s := &Server{display: d, name: name, cleanup: cleanup, log: log, channels: ch, surfaces: make(map[*server.Resource]*surface), buffers: make(map[*server.Resource]*buffer), windows: make(map[ports.WindowID]*window), nextWindow: 1, eventReady: make(chan struct{}, 1), contents: make(map[ports.WindowID]ports.SurfaceContent), contentSeq: make(map[ports.WindowID]uint64), contentReady: make(chan struct{}, 1), contentNotify: make(chan struct{}), keymapFD: -1, keyboards: make(map[server.Client][]*wayland.Keyboard), repeatRate: opts.RepeatRate, repeatDelay: opts.RepeatDelay}
93+ s := &Server{display: d, name: name, cleanup: cleanup, log: log, channels: ch, surfaces: make(map[*server.Resource]*surface), buffers: make(map[*server.Resource]*buffer), windows: make(map[ports.WindowID]*window), nextWindow: 1, eventReady: make(chan struct{}, 1), contents: make(map[ports.WindowID]ports.SurfaceContent), contentSeq: make(map[ports.WindowID]uint64), contentReady: make(chan struct{}, 1), contentNotify: make(chan struct{}), keymapFD: -1, keyboards: make(map[server.Client][]*wayland.Keyboard), pointers: make(map[server.Client][]*wayland.Pointer), repeatRate: opts.RepeatRate, repeatDelay: opts.RepeatDelay}
9194 if opts.Keymap != "" {
9295 if uint64(len(opts.Keymap))+1 > uint64(^uint32(0)) {
9396 cleanup()
@@ -292,6 +295,28 @@ func (s *Server) forwardContents(ctx context.Context) {
292295
293296 func (s *Server) apply(cmd ports.ClientCommand) {
294297 switch c := cmd.(type) {
298+ case ports.PointerFocus:
299+ s.changePointerFocus(c.ID, c.X, c.Y)
300+ case ports.PointerMotionTo:
301+ if c.ID == s.pointerFocus {
302+ for _, p := range s.windowPointers(s.windows[c.ID]) {
303+ p.SendMotion(c.TimeMsec, server.FixedFromFloat(c.X), server.FixedFromFloat(c.Y))
304+ pointerFrame(p)
305+ }
306+ }
307+ case ports.PointerButtonTo:
308+ // Releases may target the implicit-grab window after focus has moved.
309+ if w := s.windows[c.ID]; w != nil {
310+ for _, p := range s.windowPointers(w) {
311+ s.serial++
312+ state := uint32(0)
313+ if c.Pressed {
314+ state = 1
315+ }
316+ p.SendButton(s.serial, c.TimeMsec, c.Button, state)
317+ pointerFrame(p)
318+ }
319+ }
295320 case ports.FocusWindow:
296321 if s.focused != c.ID {
297322 s.changeFocus(c.ID)
@@ -379,3 +404,46 @@ func (s *Server) changeFocus(id ports.WindowID) {
379404 }
380405 }
381406 }
407+
408+func pointerFrame(p *wayland.Pointer) {
409+ if p.Version() >= 5 {
410+ p.SendFrame()
411+ }
412+}
413+func (s *Server) windowPointers(w *window) []*wayland.Pointer {
414+ if w == nil || !w.mapped || !w.xdg.resource.Resource.Alive() {
415+ return nil
416+ }
417+ var result []*wayland.Pointer
418+ for _, p := range s.pointers[w.xdg.resource.Client()] {
419+ if p.Resource.Alive() {
420+ result = append(result, p)
421+ }
422+ }
423+ return result
424+}
425+func (s *Server) changePointerFocus(id ports.WindowID, x, y float64) {
426+ if id == s.pointerFocus {
427+ return
428+ }
429+ if old := s.windows[s.pointerFocus]; old != nil {
430+ if surface := old.xdg.surfaceResource(); surface != nil && surface.Resource.Alive() {
431+ for _, p := range s.windowPointers(old) {
432+ s.serial++
433+ p.SendLeave(s.serial, surface)
434+ pointerFrame(p)
435+ }
436+ }
437+ }
438+ s.pointerFocus = 0
439+ if w := s.windows[id]; w != nil {
440+ if surface := w.xdg.surfaceResource(); surface != nil && surface.Resource.Alive() {
441+ s.pointerFocus, s.pointerX, s.pointerY = id, x, y
442+ for _, p := range s.windowPointers(w) {
443+ s.serial++
444+ p.SendEnter(s.serial, surface, server.FixedFromFloat(x), server.FixedFromFloat(y))
445+ pointerFrame(p)
446+ }
447+ }
448+ }
449+}
@@ -45,6 +45,9 @@ type Server struct {
45 windows map[ports.WindowID]*window45 windows map[ports.WindowID]*window
46 nextWindow ports.WindowID46 nextWindow ports.WindowID
47 focused ports.WindowID47 focused ports.WindowID
48+ pointerFocus ports.WindowID
49+ pointerX, pointerY float64
50+ pointers map[server.Client][]*wayland.Pointer
48 modState ports.ModState51 modState ports.ModState
49 keyboards map[server.Client][]*wayland.Keyboard52 keyboards map[server.Client][]*wayland.Keyboard
50 keymapFD int53 keymapFD int
@@ -87,7 +90,7 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) {
87 d.Close()90 d.Close()
88 return nil, err91 return nil, err
89 }92 }
90- s := &Server{display: d, name: name, cleanup: cleanup, log: log, channels: ch, surfaces: make(map[*server.Resource]*surface), buffers: make(map[*server.Resource]*buffer), windows: make(map[ports.WindowID]*window), nextWindow: 1, eventReady: make(chan struct{}, 1), contents: make(map[ports.WindowID]ports.SurfaceContent), contentSeq: make(map[ports.WindowID]uint64), contentReady: make(chan struct{}, 1), contentNotify: make(chan struct{}), keymapFD: -1, keyboards: make(map[server.Client][]*wayland.Keyboard), repeatRate: opts.RepeatRate, repeatDelay: opts.RepeatDelay}93+ s := &Server{display: d, name: name, cleanup: cleanup, log: log, channels: ch, surfaces: make(map[*server.Resource]*surface), buffers: make(map[*server.Resource]*buffer), windows: make(map[ports.WindowID]*window), nextWindow: 1, eventReady: make(chan struct{}, 1), contents: make(map[ports.WindowID]ports.SurfaceContent), contentSeq: make(map[ports.WindowID]uint64), contentReady: make(chan struct{}, 1), contentNotify: make(chan struct{}), keymapFD: -1, keyboards: make(map[server.Client][]*wayland.Keyboard), pointers: make(map[server.Client][]*wayland.Pointer), repeatRate: opts.RepeatRate, repeatDelay: opts.RepeatDelay}
91 if opts.Keymap != "" {94 if opts.Keymap != "" {
92 if uint64(len(opts.Keymap))+1 > uint64(^uint32(0)) {95 if uint64(len(opts.Keymap))+1 > uint64(^uint32(0)) {
93 cleanup()96 cleanup()
@@ -292,6 +295,28 @@ func (s *Server) forwardContents(ctx context.Context) {
292 295
293 func (s *Server) apply(cmd ports.ClientCommand) {296 func (s *Server) apply(cmd ports.ClientCommand) {
294 switch c := cmd.(type) {297 switch c := cmd.(type) {
298+ case ports.PointerFocus:
299+ s.changePointerFocus(c.ID, c.X, c.Y)
300+ case ports.PointerMotionTo:
301+ if c.ID == s.pointerFocus {
302+ for _, p := range s.windowPointers(s.windows[c.ID]) {
303+ p.SendMotion(c.TimeMsec, server.FixedFromFloat(c.X), server.FixedFromFloat(c.Y))
304+ pointerFrame(p)
305+ }
306+ }
307+ case ports.PointerButtonTo:
308+ // Releases may target the implicit-grab window after focus has moved.
309+ if w := s.windows[c.ID]; w != nil {
310+ for _, p := range s.windowPointers(w) {
311+ s.serial++
312+ state := uint32(0)
313+ if c.Pressed {
314+ state = 1
315+ }
316+ p.SendButton(s.serial, c.TimeMsec, c.Button, state)
317+ pointerFrame(p)
318+ }
319+ }
295 case ports.FocusWindow:320 case ports.FocusWindow:
296 if s.focused != c.ID {321 if s.focused != c.ID {
297 s.changeFocus(c.ID)322 s.changeFocus(c.ID)
@@ -379,3 +404,46 @@ func (s *Server) changeFocus(id ports.WindowID) {
379 }404 }
380 }405 }
381 }406 }
407+
408+func pointerFrame(p *wayland.Pointer) {
409+ if p.Version() >= 5 {
410+ p.SendFrame()
411+ }
412+}
413+func (s *Server) windowPointers(w *window) []*wayland.Pointer {
414+ if w == nil || !w.mapped || !w.xdg.resource.Resource.Alive() {
415+ return nil
416+ }
417+ var result []*wayland.Pointer
418+ for _, p := range s.pointers[w.xdg.resource.Client()] {
419+ if p.Resource.Alive() {
420+ result = append(result, p)
421+ }
422+ }
423+ return result
424+}
425+func (s *Server) changePointerFocus(id ports.WindowID, x, y float64) {
426+ if id == s.pointerFocus {
427+ return
428+ }
429+ if old := s.windows[s.pointerFocus]; old != nil {
430+ if surface := old.xdg.surfaceResource(); surface != nil && surface.Resource.Alive() {
431+ for _, p := range s.windowPointers(old) {
432+ s.serial++
433+ p.SendLeave(s.serial, surface)
434+ pointerFrame(p)
435+ }
436+ }
437+ }
438+ s.pointerFocus = 0
439+ if w := s.windows[id]; w != nil {
440+ if surface := w.xdg.surfaceResource(); surface != nil && surface.Resource.Alive() {
441+ s.pointerFocus, s.pointerX, s.pointerY = id, x, y
442+ for _, p := range s.windowPointers(w) {
443+ s.serial++
444+ p.SendEnter(s.serial, surface, server.FixedFromFloat(x), server.FixedFromFloat(y))
445+ pointerFrame(p)
446+ }
447+ }
448+ }
449+}
M internal/adapters/wayland/xdg.go +3 -0
@@ -80,6 +80,9 @@ type window struct {
8080 }
8181
8282 func (w *window) unmap() {
83+ if w.xdg.server.pointerFocus == w.id {
84+ w.xdg.server.changePointerFocus(0, 0, 0)
85+ }
8386 if w.xdg.server.focused == w.id {
8487 w.xdg.server.changeFocus(0)
8588 }
@@ -80,6 +80,9 @@ type window struct {
80 }80 }
81 81
82 func (w *window) unmap() {82 func (w *window) unmap() {
83+ if w.xdg.server.pointerFocus == w.id {
84+ w.xdg.server.changePointerFocus(0, 0, 0)
85+ }
83 if w.xdg.server.focused == w.id {86 if w.xdg.server.focused == w.id {
84 w.xdg.server.changeFocus(0)87 w.xdg.server.changeFocus(0)
85 }88 }
M internal/app/app_test.go +34 -0
@@ -140,3 +140,37 @@ func TestHeadlessTyping(t *testing.T) {
140140 }
141141 }
142142 }
143+
144+func TestHeadlessPointerClickFocus(t *testing.T) {
145+ if _, err := exec.LookPath("foot"); err != nil {
146+ t.Skip("foot unavailable")
147+ }
148+ t.Setenv("HOME", t.TempDir())
149+ t.Setenv("XDG_RUNTIME_DIR", t.TempDir())
150+ t.Setenv("XDG_STATE_HOME", t.TempDir())
151+ cfg := config.Defaults()
152+ cfg.Terminal.Command = []string{"foot", "-c", "/dev/null", "sh"}
153+ scenes := make(chan ports.Scene, 128)
154+ err := Run(context.Background(), Options{Backend: "headless", Config: cfg, Script: strings.NewReader("sleep 1s\nkey Super+Return\nsleep 1s\nmove 600 300\nclick\nsleep 500ms\n"), Timeout: 5 * time.Second, testScenes: scenes})
155+ if err != nil {
156+ if strings.Contains(strings.ToLower(err.Error()), "vulkan") {
157+ t.Skipf("Vulkan unavailable: %v", err)
158+ }
159+ t.Fatal(err)
160+ }
161+ two, focused := false, false
162+ for len(scenes) > 0 {
163+ s := <-scenes
164+ if len(s.Windows) == 2 {
165+ two = true
166+ for _, w := range s.Windows {
167+ if w.ID == 1 && w.Focused {
168+ focused = true
169+ }
170+ }
171+ }
172+ }
173+ if !two || !focused {
174+ t.Fatalf("two columns=%v first focused=%v", two, focused)
175+ }
176+}
@@ -140,3 +140,37 @@ func TestHeadlessTyping(t *testing.T) {
140 }140 }
141 }141 }
142 }142 }
143+
144+func TestHeadlessPointerClickFocus(t *testing.T) {
145+ if _, err := exec.LookPath("foot"); err != nil {
146+ t.Skip("foot unavailable")
147+ }
148+ t.Setenv("HOME", t.TempDir())
149+ t.Setenv("XDG_RUNTIME_DIR", t.TempDir())
150+ t.Setenv("XDG_STATE_HOME", t.TempDir())
151+ cfg := config.Defaults()
152+ cfg.Terminal.Command = []string{"foot", "-c", "/dev/null", "sh"}
153+ scenes := make(chan ports.Scene, 128)
154+ err := Run(context.Background(), Options{Backend: "headless", Config: cfg, Script: strings.NewReader("sleep 1s\nkey Super+Return\nsleep 1s\nmove 600 300\nclick\nsleep 500ms\n"), Timeout: 5 * time.Second, testScenes: scenes})
155+ if err != nil {
156+ if strings.Contains(strings.ToLower(err.Error()), "vulkan") {
157+ t.Skipf("Vulkan unavailable: %v", err)
158+ }
159+ t.Fatal(err)
160+ }
161+ two, focused := false, false
162+ for len(scenes) > 0 {
163+ s := <-scenes
164+ if len(s.Windows) == 2 {
165+ two = true
166+ for _, w := range s.Windows {
167+ if w.ID == 1 && w.Focused {
168+ focused = true
169+ }
170+ }
171+ }
172+ }
173+ if !two || !focused {
174+ t.Fatalf("two columns=%v first focused=%v", two, focused)
175+ }
176+}
M internal/core/core.go +78 -9
@@ -28,14 +28,18 @@ type binding struct {
2828 key string
2929 }
3030 type Core struct {
31- ch Channels
32- cfg ports.Config
33- ws Workspace
34- binds map[binding]Action
35- pressed map[string]bool
36- sent map[WindowID]ports.ConfigureWindow
37- focus WindowID
38- seq uint64
31+ ch Channels
32+ cfg ports.Config
33+ ws Workspace
34+ binds map[binding]Action
35+ pressed map[string]bool
36+ sent map[WindowID]ports.ConfigureWindow
37+ focus WindowID
38+ pointer WindowID
39+ grab WindowID
40+ buttons map[uint32]bool
41+ cursorX, cursorY float64
42+ seq uint64
3943 }
4044
4145 func keyName(s string) string {
@@ -124,7 +128,7 @@ func New(cfg ports.Config, ch Channels) (*Core, error) {
124128 if cap(ch.Scenes) != 1 {
125129 return nil, fmt.Errorf("scenes must have capacity 1")
126130 }
127- c := &Core{ch: ch, pressed: map[string]bool{}, sent: map[WindowID]ports.ConfigureWindow{}}
131+ c := &Core{ch: ch, pressed: map[string]bool{}, buttons: map[uint32]bool{}, sent: map[WindowID]ports.ConfigureWindow{}}
128132 if err := c.apply(cfg); err != nil {
129133 return nil, err
130134 }
@@ -205,6 +209,12 @@ func (c *Core) Run(ctx context.Context) error {
205209 c.ws.AddWindow(v.ID)
206210 case ports.WindowUnmapped:
207211 c.ws.RemoveWindow(v.ID)
212+ if c.pointer == v.ID {
213+ c.pointer = 0
214+ if err := c.command(ctx, ports.PointerFocus{}); err != nil {
215+ return nil
216+ }
217+ }
208218 case ports.WindowFullscreenRequest:
209219 c.ws.SetFullscreen(v.ID, v.Fullscreen)
210220 }
@@ -245,6 +255,65 @@ func (c *Core) Run(ctx context.Context) error {
245255 c.ch.Input = nil
246256 continue
247257 }
258+ switch v := ev.(type) {
259+ case ports.PointerMotion:
260+ c.cursorX = min(max(v.X, 0), float64(max(c.ws.Output.W-1, 0)))
261+ c.cursorY = min(max(v.Y, 0), float64(max(c.ws.Output.H-1, 0)))
262+ var id WindowID
263+ var x, y float64
264+ // Fullscreen wins; otherwise the last visible placement is topmost.
265+ for _, p := range c.ws.Layout() {
266+ r := p.Rect
267+ if !p.Hidden && r.W > 0 && r.H > 0 && c.cursorX >= float64(r.X) && c.cursorX < float64(r.X+r.W) && c.cursorY >= float64(r.Y) && c.cursorY < float64(r.Y+r.H) {
268+ if id == 0 || p.Fullscreen {
269+ id, x, y = p.ID, c.cursorX-float64(r.X), c.cursorY-float64(r.Y)
270+ }
271+ if p.Fullscreen {
272+ break
273+ }
274+ }
275+ }
276+ // Layout changes are intentionally re-hit-tested only on motion.
277+ if id != c.pointer {
278+ c.pointer = id
279+ if err := c.command(ctx, ports.PointerFocus{ID: id, X: x, Y: y}); err != nil {
280+ return nil
281+ }
282+ }
283+ if id != 0 {
284+ if err := c.command(ctx, ports.PointerMotionTo{ID: id, X: x, Y: y, TimeMsec: v.TimeMsec}); err != nil {
285+ return nil
286+ }
287+ }
288+ continue
289+ case ports.PointerButton:
290+ id := c.pointer
291+ if c.grab != 0 {
292+ id = c.grab
293+ }
294+ if v.Pressed {
295+ if len(c.buttons) == 0 {
296+ c.grab = id
297+ }
298+ c.buttons[v.Button] = true
299+ } else {
300+ delete(c.buttons, v.Button)
301+ }
302+ if id != 0 {
303+ if err := c.command(ctx, ports.PointerButtonTo{ID: id, Button: v.Button, Pressed: v.Pressed, TimeMsec: v.TimeMsec}); err != nil {
304+ return nil
305+ }
306+ if v.Pressed && c.focus != id && c.ws.FocusID(id) {
307+ if err := c.publish(ctx); err != nil {
308+ return nil
309+ }
310+ }
311+ }
312+ if len(c.buttons) == 0 {
313+ c.grab = 0
314+ }
315+ continue
316+ }
248317 key, ok := ev.(ports.KeyEvent)
249318 if !ok {
250319 continue
@@ -28,14 +28,18 @@ type binding struct {
28 key string28 key string
29 }29 }
30 type Core struct {30 type Core struct {
31- ch Channels31+ ch Channels
32- cfg ports.Config32+ cfg ports.Config
33- ws Workspace33+ ws Workspace
34- binds map[binding]Action34+ binds map[binding]Action
35- pressed map[string]bool35+ pressed map[string]bool
36- sent map[WindowID]ports.ConfigureWindow36+ sent map[WindowID]ports.ConfigureWindow
37- focus WindowID37+ focus WindowID
38- seq uint6438+ pointer WindowID
39+ grab WindowID
40+ buttons map[uint32]bool
41+ cursorX, cursorY float64
42+ seq uint64
39 }43 }
40 44
41 func keyName(s string) string {45 func keyName(s string) string {
@@ -124,7 +128,7 @@ func New(cfg ports.Config, ch Channels) (*Core, error) {
124 if cap(ch.Scenes) != 1 {128 if cap(ch.Scenes) != 1 {
125 return nil, fmt.Errorf("scenes must have capacity 1")129 return nil, fmt.Errorf("scenes must have capacity 1")
126 }130 }
127- c := &Core{ch: ch, pressed: map[string]bool{}, sent: map[WindowID]ports.ConfigureWindow{}}131+ c := &Core{ch: ch, pressed: map[string]bool{}, buttons: map[uint32]bool{}, sent: map[WindowID]ports.ConfigureWindow{}}
128 if err := c.apply(cfg); err != nil {132 if err := c.apply(cfg); err != nil {
129 return nil, err133 return nil, err
130 }134 }
@@ -205,6 +209,12 @@ func (c *Core) Run(ctx context.Context) error {
205 c.ws.AddWindow(v.ID)209 c.ws.AddWindow(v.ID)
206 case ports.WindowUnmapped:210 case ports.WindowUnmapped:
207 c.ws.RemoveWindow(v.ID)211 c.ws.RemoveWindow(v.ID)
212+ if c.pointer == v.ID {
213+ c.pointer = 0
214+ if err := c.command(ctx, ports.PointerFocus{}); err != nil {
215+ return nil
216+ }
217+ }
208 case ports.WindowFullscreenRequest:218 case ports.WindowFullscreenRequest:
209 c.ws.SetFullscreen(v.ID, v.Fullscreen)219 c.ws.SetFullscreen(v.ID, v.Fullscreen)
210 }220 }
@@ -245,6 +255,65 @@ func (c *Core) Run(ctx context.Context) error {
245 c.ch.Input = nil255 c.ch.Input = nil
246 continue256 continue
247 }257 }
258+ switch v := ev.(type) {
259+ case ports.PointerMotion:
260+ c.cursorX = min(max(v.X, 0), float64(max(c.ws.Output.W-1, 0)))
261+ c.cursorY = min(max(v.Y, 0), float64(max(c.ws.Output.H-1, 0)))
262+ var id WindowID
263+ var x, y float64
264+ // Fullscreen wins; otherwise the last visible placement is topmost.
265+ for _, p := range c.ws.Layout() {
266+ r := p.Rect
267+ if !p.Hidden && r.W > 0 && r.H > 0 && c.cursorX >= float64(r.X) && c.cursorX < float64(r.X+r.W) && c.cursorY >= float64(r.Y) && c.cursorY < float64(r.Y+r.H) {
268+ if id == 0 || p.Fullscreen {
269+ id, x, y = p.ID, c.cursorX-float64(r.X), c.cursorY-float64(r.Y)
270+ }
271+ if p.Fullscreen {
272+ break
273+ }
274+ }
275+ }
276+ // Layout changes are intentionally re-hit-tested only on motion.
277+ if id != c.pointer {
278+ c.pointer = id
279+ if err := c.command(ctx, ports.PointerFocus{ID: id, X: x, Y: y}); err != nil {
280+ return nil
281+ }
282+ }
283+ if id != 0 {
284+ if err := c.command(ctx, ports.PointerMotionTo{ID: id, X: x, Y: y, TimeMsec: v.TimeMsec}); err != nil {
285+ return nil
286+ }
287+ }
288+ continue
289+ case ports.PointerButton:
290+ id := c.pointer
291+ if c.grab != 0 {
292+ id = c.grab
293+ }
294+ if v.Pressed {
295+ if len(c.buttons) == 0 {
296+ c.grab = id
297+ }
298+ c.buttons[v.Button] = true
299+ } else {
300+ delete(c.buttons, v.Button)
301+ }
302+ if id != 0 {
303+ if err := c.command(ctx, ports.PointerButtonTo{ID: id, Button: v.Button, Pressed: v.Pressed, TimeMsec: v.TimeMsec}); err != nil {
304+ return nil
305+ }
306+ if v.Pressed && c.focus != id && c.ws.FocusID(id) {
307+ if err := c.publish(ctx); err != nil {
308+ return nil
309+ }
310+ }
311+ }
312+ if len(c.buttons) == 0 {
313+ c.grab = 0
314+ }
315+ continue
316+ }
248 key, ok := ev.(ports.KeyEvent)317 key, ok := ev.(ports.KeyEvent)
249 if !ok {318 if !ok {
250 continue319 continue
M internal/core/core_test.go +54 -0
@@ -192,3 +192,57 @@ func TestBoundReleaseSwallowed(t *testing.T) {
192192 t.Fatal(v)
193193 }
194194 }
195+
196+func TestPointerFocusAndGrab(t *testing.T) {
197+ cfg := config.Defaults()
198+ client := make(chan ports.ClientEvent, 8)
199+ input := make(chan ports.InputEvent, 8)
200+ output := make(chan ports.OutputEvent, 8)
201+ commands := make(chan ports.ClientCommand, 64)
202+ scenes := make(chan ports.Scene, 1)
203+ c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes})
204+ if err != nil {
205+ t.Fatal(err)
206+ }
207+ ctx, cancel := context.WithCancel(context.Background())
208+ defer cancel()
209+ go c.Run(ctx)
210+ output <- ports.OutputMode{Width: 100, Height: 80}
211+ receive(t, scenes)
212+ client <- ports.WindowMapped{ID: 1}
213+ first := receive(t, scenes).Windows[0].Rect
214+ client <- ports.WindowMapped{ID: 2}
215+ receive(t, scenes)
216+ for len(commands) > 0 {
217+ <-commands
218+ }
219+ input <- ports.PointerMotion{X: float64(first.X + 2), Y: float64(first.Y + 3), TimeMsec: 1}
220+ if v := receive(t, commands); v != (ports.PointerFocus{ID: 1, X: 2, Y: 3}) {
221+ t.Fatal(v)
222+ }
223+ if v := receive(t, commands); v != (ports.PointerMotionTo{ID: 1, X: 2, Y: 3, TimeMsec: 1}) {
224+ t.Fatal(v)
225+ }
226+ input <- ports.PointerButton{Button: 0x110, Pressed: true}
227+ if v := receive(t, commands); v != (ports.PointerButtonTo{ID: 1, Button: 0x110, Pressed: true}) {
228+ t.Fatal(v)
229+ }
230+ receive(t, scenes)
231+ found := false
232+ for len(commands) > 0 {
233+ if v, ok := (<-commands).(ports.FocusWindow); ok && v.ID == 1 {
234+ found = true
235+ }
236+ }
237+ if !found {
238+ t.Fatal("missing keyboard focus")
239+ }
240+ input <- ports.PointerMotion{X: 0, Y: 0}
241+ if v := receive(t, commands); v != (ports.PointerFocus{}) {
242+ t.Fatal(v)
243+ }
244+ input <- ports.PointerButton{Button: 0x110}
245+ if v := receive(t, commands); v != (ports.PointerButtonTo{ID: 1, Button: 0x110}) {
246+ t.Fatal(v)
247+ }
248+}
@@ -192,3 +192,57 @@ func TestBoundReleaseSwallowed(t *testing.T) {
192 t.Fatal(v)192 t.Fatal(v)
193 }193 }
194 }194 }
195+
196+func TestPointerFocusAndGrab(t *testing.T) {
197+ cfg := config.Defaults()
198+ client := make(chan ports.ClientEvent, 8)
199+ input := make(chan ports.InputEvent, 8)
200+ output := make(chan ports.OutputEvent, 8)
201+ commands := make(chan ports.ClientCommand, 64)
202+ scenes := make(chan ports.Scene, 1)
203+ c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes})
204+ if err != nil {
205+ t.Fatal(err)
206+ }
207+ ctx, cancel := context.WithCancel(context.Background())
208+ defer cancel()
209+ go c.Run(ctx)
210+ output <- ports.OutputMode{Width: 100, Height: 80}
211+ receive(t, scenes)
212+ client <- ports.WindowMapped{ID: 1}
213+ first := receive(t, scenes).Windows[0].Rect
214+ client <- ports.WindowMapped{ID: 2}
215+ receive(t, scenes)
216+ for len(commands) > 0 {
217+ <-commands
218+ }
219+ input <- ports.PointerMotion{X: float64(first.X + 2), Y: float64(first.Y + 3), TimeMsec: 1}
220+ if v := receive(t, commands); v != (ports.PointerFocus{ID: 1, X: 2, Y: 3}) {
221+ t.Fatal(v)
222+ }
223+ if v := receive(t, commands); v != (ports.PointerMotionTo{ID: 1, X: 2, Y: 3, TimeMsec: 1}) {
224+ t.Fatal(v)
225+ }
226+ input <- ports.PointerButton{Button: 0x110, Pressed: true}
227+ if v := receive(t, commands); v != (ports.PointerButtonTo{ID: 1, Button: 0x110, Pressed: true}) {
228+ t.Fatal(v)
229+ }
230+ receive(t, scenes)
231+ found := false
232+ for len(commands) > 0 {
233+ if v, ok := (<-commands).(ports.FocusWindow); ok && v.ID == 1 {
234+ found = true
235+ }
236+ }
237+ if !found {
238+ t.Fatal("missing keyboard focus")
239+ }
240+ input <- ports.PointerMotion{X: 0, Y: 0}
241+ if v := receive(t, commands); v != (ports.PointerFocus{}) {
242+ t.Fatal(v)
243+ }
244+ input <- ports.PointerButton{Button: 0x110}
245+ if v := receive(t, commands); v != (ports.PointerButtonTo{ID: 1, Button: 0x110}) {
246+ t.Fatal(v)
247+ }
248+}
M internal/core/layout.go +15 -0
@@ -156,6 +156,21 @@ func (w *Workspace) RemoveWindow(id WindowID) {
156156 }
157157 }
158158 }
159+
160+// FocusID selects a window and scrolls its column into view.
161+func (w *Workspace) FocusID(id WindowID) bool {
162+ for i := range w.Columns {
163+ for j, v := range w.Columns[i].Windows {
164+ if v == id {
165+ w.Focus = i
166+ w.Columns[i].Focus = j
167+ w.scroll()
168+ return true
169+ }
170+ }
171+ }
172+ return false
173+}
159174 func (w *Workspace) FocusColumn(dir int) {
160175 if len(w.Columns) > 0 && (dir == -1 || dir == 1) && w.Focus+dir >= 0 && w.Focus+dir < len(w.Columns) {
161176 w.Focus += dir
@@ -156,6 +156,21 @@ func (w *Workspace) RemoveWindow(id WindowID) {
156 }156 }
157 }157 }
158 }158 }
159+
160+// FocusID selects a window and scrolls its column into view.
161+func (w *Workspace) FocusID(id WindowID) bool {
162+ for i := range w.Columns {
163+ for j, v := range w.Columns[i].Windows {
164+ if v == id {
165+ w.Focus = i
166+ w.Columns[i].Focus = j
167+ w.scroll()
168+ return true
169+ }
170+ }
171+ }
172+ return false
173+}
159 func (w *Workspace) FocusColumn(dir int) {174 func (w *Workspace) FocusColumn(dir int) {
160 if len(w.Columns) > 0 && (dir == -1 || dir == 1) && w.Focus+dir >= 0 && w.Focus+dir < len(w.Columns) {175 if len(w.Columns) > 0 && (dir == -1 || dir == 1) && w.Focus+dir >= 0 && w.Focus+dir < len(w.Columns) {
161 w.Focus += dir176 w.Focus += dir
M internal/ports/messages.go +42 -0
@@ -54,6 +54,23 @@ type ModState struct{ Depressed, Latched, Locked, Group uint32 }
5454
5555 func (KeyEvent) inputEvent() {}
5656
57+// PointerMotion uses absolute output coordinates.
58+type PointerMotion struct {
59+ X, Y float64
60+ TimeMsec uint32
61+}
62+
63+func (PointerMotion) inputEvent() {}
64+
65+// PointerButton uses evdev button codes (BTN_LEFT is 0x110).
66+type PointerButton struct {
67+ Button uint32
68+ Pressed bool
69+ TimeMsec uint32
70+}
71+
72+func (PointerButton) inputEvent() {}
73+
5774 // OutputEvent carries output → core notifications.
5875 type OutputEvent interface{ outputEvent() }
5976
@@ -92,6 +109,31 @@ type FocusWindow struct{ ID WindowID }
92109
93110 func (FocusWindow) clientCommand() {}
94111
112+// PointerFocus changes the pointer surface; ID 0 clears focus. Coordinates are surface-local.
113+type PointerFocus struct {
114+ ID WindowID
115+ X, Y float64
116+}
117+
118+func (PointerFocus) clientCommand() {}
119+
120+type PointerMotionTo struct {
121+ ID WindowID
122+ X, Y float64
123+ TimeMsec uint32
124+}
125+
126+func (PointerMotionTo) clientCommand() {}
127+
128+type PointerButtonTo struct {
129+ ID WindowID
130+ Button uint32
131+ Pressed bool
132+ TimeMsec uint32
133+}
134+
135+func (PointerButtonTo) clientCommand() {}
136+
95137 // ForwardKey carries core → wayland unbound keys.
96138 type ForwardKey struct {
97139 ID WindowID
@@ -54,6 +54,23 @@ type ModState struct{ Depressed, Latched, Locked, Group uint32 }
54 54
55 func (KeyEvent) inputEvent() {}55 func (KeyEvent) inputEvent() {}
56 56
57+// PointerMotion uses absolute output coordinates.
58+type PointerMotion struct {
59+ X, Y float64
60+ TimeMsec uint32
61+}
62+
63+func (PointerMotion) inputEvent() {}
64+
65+// PointerButton uses evdev button codes (BTN_LEFT is 0x110).
66+type PointerButton struct {
67+ Button uint32
68+ Pressed bool
69+ TimeMsec uint32
70+}
71+
72+func (PointerButton) inputEvent() {}
73+
57 // OutputEvent carries output → core notifications.74 // OutputEvent carries output → core notifications.
58 type OutputEvent interface{ outputEvent() }75 type OutputEvent interface{ outputEvent() }
59 76
@@ -92,6 +109,31 @@ type FocusWindow struct{ ID WindowID }
92 109
93 func (FocusWindow) clientCommand() {}110 func (FocusWindow) clientCommand() {}
94 111
112+// PointerFocus changes the pointer surface; ID 0 clears focus. Coordinates are surface-local.
113+type PointerFocus struct {
114+ ID WindowID
115+ X, Y float64
116+}
117+
118+func (PointerFocus) clientCommand() {}
119+
120+type PointerMotionTo struct {
121+ ID WindowID
122+ X, Y float64
123+ TimeMsec uint32
124+}
125+
126+func (PointerMotionTo) clientCommand() {}
127+
128+type PointerButtonTo struct {
129+ ID WindowID
130+ Button uint32
131+ Pressed bool
132+ TimeMsec uint32
133+}
134+
135+func (PointerButtonTo) clientCommand() {}
136+
95 // ForwardKey carries core → wayland unbound keys.137 // ForwardKey carries core → wayland unbound keys.
96 type ForwardKey struct {138 type ForwardKey struct {
97 ID WindowID139 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 25

Tags 10

↑↓ moveenter openesc close