detached f192a7a

fix(input): unblock script shutdown, send held keys on enter, fix flaky protocol tests

commit
f192a7afaf9669d0bc9dbb172b5b55fbcbb337e9
author
brice · · 2026-09-23 16:14 UTC
parent
e930e7f
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
6 files · +270 -7
M internal/adapters/headlessinput/input.go +9 -1
@@ -40,7 +40,15 @@ func Run(ctx context.Context, km *xkb.Keymap, script <-chan string, input chan<-
4040 return nil
4141 }
4242 if shift {
43- mods = append(mods, 42)
43+ found := false
44+ for _, m := range mods {
45+ if m == 42 || m == 54 {
46+ found = true
47+ }
48+ }
49+ if !found {
50+ mods = append(mods, 42)
51+ }
4452 }
4553 for _, m := range mods {
4654 if err := emit(m, true); err != nil {
@@ -40,7 +40,15 @@ func Run(ctx context.Context, km *xkb.Keymap, script <-chan string, input chan<-
40 return nil40 return nil
41 }41 }
42 if shift {42 if shift {
43- mods = append(mods, 42)43+ found := false
44+ for _, m := range mods {
45+ if m == 42 || m == 54 {
46+ found = true
47+ }
48+ }
49+ if !found {
50+ mods = append(mods, 42)
51+ }
44 }52 }
45 for _, m := range mods {53 for _, m := range mods {
46 if err := emit(m, true); err != nil {54 if err := emit(m, true); err != nil {
M internal/adapters/headlessinput/input_test.go +3 -2
@@ -16,6 +16,7 @@ func TestScript(t *testing.T) {
1616 codes []uint32
1717 }{
1818 {"type Hi!", []string{"Shift_L", "H", "H", "Shift_L", "i", "i", "Shift_L", "exclam", "exclam", "Shift_L"}, nil},
19+ {"key Shift+A", []string{"Shift_L", "A", "A", "Shift_L"}, []uint32{42, 30, 30, 42}},
1920 {"key Super+Return", []string{"Super_L", "Return", "Return", "Super_L"}, []uint32{125, 28, 28, 125}},
2021 } {
2122 t.Run(tt.line, func(t *testing.T) {
@@ -44,10 +45,10 @@ func TestScript(t *testing.T) {
4445 if tt.codes != nil && ev.Keycode != tt.codes[i] {
4546 t.Errorf("code %d: %d", i, ev.Keycode)
4647 }
47- if tt.codes != nil && i == 1 && ev.Mods&ports.ModSuper == 0 {
48+ if tt.line == "key Super+Return" && i == 1 && ev.Mods&ports.ModSuper == 0 {
4849 t.Error("Return missing Super")
4950 }
50- if tt.codes != nil && i == 3 && ev.Mods&ports.ModSuper != 0 {
51+ if tt.line == "key Super+Return" && i == 3 && ev.Mods&ports.ModSuper != 0 {
5152 t.Error("Super not released")
5253 }
5354 }
@@ -16,6 +16,7 @@ func TestScript(t *testing.T) {
16 codes []uint3216 codes []uint32
17 }{17 }{
18 {"type Hi!", []string{"Shift_L", "H", "H", "Shift_L", "i", "i", "Shift_L", "exclam", "exclam", "Shift_L"}, nil},18 {"type Hi!", []string{"Shift_L", "H", "H", "Shift_L", "i", "i", "Shift_L", "exclam", "exclam", "Shift_L"}, nil},
19+ {"key Shift+A", []string{"Shift_L", "A", "A", "Shift_L"}, []uint32{42, 30, 30, 42}},
19 {"key Super+Return", []string{"Super_L", "Return", "Return", "Super_L"}, []uint32{125, 28, 28, 125}},20 {"key Super+Return", []string{"Super_L", "Return", "Return", "Super_L"}, []uint32{125, 28, 28, 125}},
20 } {21 } {
21 t.Run(tt.line, func(t *testing.T) {22 t.Run(tt.line, func(t *testing.T) {
@@ -44,10 +45,10 @@ func TestScript(t *testing.T) {
44 if tt.codes != nil && ev.Keycode != tt.codes[i] {45 if tt.codes != nil && ev.Keycode != tt.codes[i] {
45 t.Errorf("code %d: %d", i, ev.Keycode)46 t.Errorf("code %d: %d", i, ev.Keycode)
46 }47 }
47- if tt.codes != nil && i == 1 && ev.Mods&ports.ModSuper == 0 {48+ if tt.line == "key Super+Return" && i == 1 && ev.Mods&ports.ModSuper == 0 {
48 t.Error("Return missing Super")49 t.Error("Return missing Super")
49 }50 }
50- if tt.codes != nil && i == 3 && ev.Mods&ports.ModSuper != 0 {51+ if tt.line == "key Super+Return" && i == 3 && ev.Mods&ports.ModSuper != 0 {
51 t.Error("Super not released")52 t.Error("Super not released")
52 }53 }
53 }54 }
M internal/adapters/wayland/protocol_test.go +223 -1
@@ -1,6 +1,7 @@
11 package wayland
22
33 import (
4+ "bytes"
45 "errors"
56 "github.com/bnema/nefertty/internal/ports"
67 "path/filepath"
@@ -49,7 +50,14 @@ func requestProtocol(t *testing.T, c *wlturbo.Display, id uint32, op uint32, arg
4950 func expectProtocolError(t *testing.T, c *wlturbo.Display, object, code uint32) {
5051 t.Helper()
5152 done := make(chan error, 1)
52- go func() { done <- c.Roundtrip() }()
53+ go func() {
54+ for {
55+ if err := c.Dispatch(); err != nil {
56+ done <- err
57+ return
58+ }
59+ }
60+ }()
5361 select {
5462 case err := <-done:
5563 var displayErr *wlturbo.DisplayError
@@ -333,3 +341,217 @@ func TestBufferContentUnpaddedLastRow(t *testing.T) {
333341 }
334342 }
335343 }
344+
345+type pointerEvents struct {
346+ wlturbo.BaseProxy
347+ enters chan [2]float64
348+ buttons chan uint32
349+}
350+
351+func (p *pointerEvents) Dispatch(e *wlturbo.Event) {
352+ switch e.Opcode {
353+ case uint16(wayland.PointerEventEnter):
354+ _ = e.Uint32()
355+ _ = e.Uint32()
356+ p.enters <- [2]float64{e.Fixed().Float64(), e.Fixed().Float64()}
357+ case uint16(wayland.PointerEventButton):
358+ _ = e.Uint32()
359+ _ = e.Uint32()
360+ p.buttons <- e.Uint32()
361+ }
362+}
363+
364+func TestPointerProtocol(t *testing.T) {
365+ s, events, commands, dir := lifecycleServer(t)
366+ c := protocolClient(t, s, dir)
367+ seat := bindProtocol(t, c, "wl_seat")
368+ registerProtocol(t, c, seat)
369+ pointer := c.AllocateID()
370+ p := &pointerEvents{enters: make(chan [2]float64, 1), buttons: make(chan uint32, 1)}
371+ p.SetID(pointer)
372+ c.Context().Register(p)
373+ requestProtocol(t, c, seat, wayland.SeatRequestGetPointer, pointer)
374+ comp := bindProtocol(t, c, "wl_compositor")
375+ wm := bindProtocol(t, c, "xdg_wm_base")
376+ shm := bindProtocol(t, c, "wl_shm")
377+ registerProtocol(t, c, shm)
378+ surf, xdg := c.AllocateID(), c.AllocateID()
379+ requestProtocol(t, c, comp, wayland.CompositorRequestCreateSurface, surf)
380+ registerProtocol(t, c, surf)
381+ requestProtocol(t, c, wm, xdgshell.WmBaseRequestGetXdgSurface, xdg, surf)
382+ serials := make(chan uint32, 1)
383+ xp := &configureProxy{serial: serials}
384+ xp.SetID(xdg)
385+ c.Context().Register(xp)
386+ top := c.AllocateID()
387+ registerProtocol(t, c, top)
388+ requestProtocol(t, c, xdg, xdgshell.SurfaceRequestGetToplevel, top)
389+ requestProtocol(t, c, surf, wayland.SurfaceRequestCommit)
390+ if err := c.Roundtrip(); err != nil {
391+ t.Fatal(err)
392+ }
393+ requestProtocol(t, c, xdg, xdgshell.SurfaceRequestAckConfigure, <-serials)
394+ fd, err := unix.MemfdCreate("pointer-buffer", 0)
395+ if err != nil {
396+ t.Fatal(err)
397+ }
398+ defer unix.Close(fd)
399+ if err := unix.Ftruncate(fd, 4); err != nil {
400+ t.Fatal(err)
401+ }
402+ pool, buffer := c.AllocateID(), c.AllocateID()
403+ registerProtocol(t, c, pool)
404+ registerProtocol(t, c, buffer)
405+ if err := c.SendRequestWithFDs(shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, int32(4)); err != nil {
406+ t.Fatal(err)
407+ }
408+ requestProtocol(t, c, pool, wayland.ShmPoolRequestCreateBuffer, buffer, int32(0), int32(1), int32(1), int32(4), uint32(0))
409+ requestProtocol(t, c, surf, wayland.SurfaceRequestAttach, buffer, int32(0), int32(0))
410+ requestProtocol(t, c, surf, wayland.SurfaceRequestCommit)
411+ if err := c.Roundtrip(); err != nil {
412+ t.Fatal(err)
413+ }
414+ w := mapped(t, events, 2*time.Second)
415+ commands <- ports.PointerFocus{ID: w.ID, X: 10, Y: 20}
416+ commands <- ports.PointerButtonTo{ID: w.ID, Button: 0x110, Pressed: true, TimeMsec: 1}
417+ deadline := time.After(2 * time.Second)
418+ dispatched := make(chan error, 1)
419+ go func() {
420+ for {
421+ if err := c.Dispatch(); err != nil {
422+ dispatched <- err
423+ return
424+ }
425+ }
426+ }()
427+ for len(p.enters) == 0 || len(p.buttons) == 0 {
428+ select {
429+ case err := <-dispatched:
430+ t.Fatal(err)
431+ case <-deadline:
432+ t.Fatal("pointer events timeout")
433+ case <-time.After(time.Millisecond):
434+ }
435+ }
436+ select {
437+ case coords := <-p.enters:
438+ if coords != [2]float64{10, 20} {
439+ t.Fatalf("enter: %v", coords)
440+ }
441+ default:
442+ t.Fatal("missing pointer enter")
443+ }
444+ select {
445+ case button := <-p.buttons:
446+ if button != 0x110 {
447+ t.Fatalf("button: %d", button)
448+ }
449+ default:
450+ t.Fatal("missing pointer button")
451+ }
452+}
453+
454+type heldKeyProxy struct {
455+ wlturbo.BaseProxy
456+ enters chan []byte
457+}
458+
459+func (p *heldKeyProxy) Dispatch(e *wlturbo.Event) {
460+ if e.Opcode == uint16(wayland.KeyboardEventEnter) {
461+ _ = e.Uint32()
462+ _ = e.Uint32()
463+ p.enters <- e.Array()
464+ }
465+}
466+
467+func TestHeldKeyOnKeyboardFocusTransfer(t *testing.T) {
468+ s, events, commands, dir := keyboardServer(t)
469+ c := protocolClient(t, s, dir)
470+ seat := bindProtocol(t, c, "wl_seat")
471+ registerProtocol(t, c, seat)
472+ keyboard := c.AllocateID()
473+ kp := &heldKeyProxy{enters: make(chan []byte, 4)}
474+ kp.SetID(keyboard)
475+ c.Context().Register(kp)
476+ requestProtocol(t, c, seat, wayland.SeatRequestGetKeyboard, keyboard)
477+ comp := bindProtocol(t, c, "wl_compositor")
478+ wm := bindProtocol(t, c, "xdg_wm_base")
479+ shm := bindProtocol(t, c, "wl_shm")
480+ registerProtocol(t, c, shm)
481+ fd, err := unix.MemfdCreate("held-key-buffer", 0)
482+ if err != nil {
483+ t.Fatal(err)
484+ }
485+ defer unix.Close(fd)
486+ if err := unix.Ftruncate(fd, 4); err != nil {
487+ t.Fatal(err)
488+ }
489+ pool, buffer := c.AllocateID(), c.AllocateID()
490+ registerProtocol(t, c, pool)
491+ registerProtocol(t, c, buffer)
492+ if err := c.SendRequestWithFDs(shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, int32(4)); err != nil {
493+ t.Fatal(err)
494+ }
495+ requestProtocol(t, c, pool, wayland.ShmPoolRequestCreateBuffer, buffer, int32(0), int32(1), int32(1), int32(4), uint32(0))
496+ mapWindow := func() ports.WindowID {
497+ surf, xdg, top := c.AllocateID(), c.AllocateID(), c.AllocateID()
498+ requestProtocol(t, c, comp, wayland.CompositorRequestCreateSurface, surf)
499+ registerProtocol(t, c, surf)
500+ requestProtocol(t, c, wm, xdgshell.WmBaseRequestGetXdgSurface, xdg, surf)
501+ serials := make(chan uint32, 1)
502+ xp := &configureProxy{serial: serials}
503+ xp.SetID(xdg)
504+ c.Context().Register(xp)
505+ registerProtocol(t, c, top)
506+ requestProtocol(t, c, xdg, xdgshell.SurfaceRequestGetToplevel, top)
507+ requestProtocol(t, c, surf, wayland.SurfaceRequestCommit)
508+ if err := c.Roundtrip(); err != nil {
509+ t.Fatal(err)
510+ }
511+ select {
512+ case serial := <-serials:
513+ requestProtocol(t, c, xdg, xdgshell.SurfaceRequestAckConfigure, serial)
514+ default:
515+ t.Fatal("missing configure")
516+ }
517+ requestProtocol(t, c, surf, wayland.SurfaceRequestAttach, buffer, int32(0), int32(0))
518+ requestProtocol(t, c, surf, wayland.SurfaceRequestCommit)
519+ if err := c.Roundtrip(); err != nil {
520+ t.Fatal(err)
521+ }
522+ return mapped(t, events, 2*time.Second).ID
523+ }
524+ a := mapWindow()
525+ b := mapWindow()
526+ // Dispatch without sending a new request after focus changes.
527+ dispatched := make(chan error, 1)
528+ go func() {
529+ for {
530+ if err := c.Dispatch(); err != nil {
531+ dispatched <- err
532+ return
533+ }
534+ }
535+ }()
536+ enter := func(want []byte) {
537+ t.Helper()
538+ select {
539+ case got := <-kp.enters:
540+ if !bytes.Equal(got, want) {
541+ t.Fatalf("keyboard enter keys = %v, want %v", got, want)
542+ }
543+ case err := <-dispatched:
544+ t.Fatal(err)
545+ case <-time.After(2 * time.Second):
546+ t.Fatal("keyboard enter timeout")
547+ }
548+ }
549+ commands <- ports.FocusWindow{ID: a}
550+ enter(nil)
551+ commands <- ports.ForwardKey{ID: a, Key: ports.KeyEvent{Keycode: 30, Pressed: true, TimeMsec: 1}}
552+ commands <- ports.FocusWindow{ID: b}
553+ enter([]byte{30, 0, 0, 0})
554+ commands <- ports.ForwardKey{ID: b, Key: ports.KeyEvent{Keycode: 30, TimeMsec: 2}}
555+ commands <- ports.FocusWindow{ID: a}
556+ enter(nil)
557+}
@@ -1,6 +1,7 @@
1 package wayland1 package wayland
2 2
3 import (3 import (
4+ "bytes"
4 "errors"5 "errors"
5 "github.com/bnema/nefertty/internal/ports"6 "github.com/bnema/nefertty/internal/ports"
6 "path/filepath"7 "path/filepath"
@@ -49,7 +50,14 @@ func requestProtocol(t *testing.T, c *wlturbo.Display, id uint32, op uint32, arg
49 func expectProtocolError(t *testing.T, c *wlturbo.Display, object, code uint32) {50 func expectProtocolError(t *testing.T, c *wlturbo.Display, object, code uint32) {
50 t.Helper()51 t.Helper()
51 done := make(chan error, 1)52 done := make(chan error, 1)
52- go func() { done <- c.Roundtrip() }()53+ go func() {
54+ for {
55+ if err := c.Dispatch(); err != nil {
56+ done <- err
57+ return
58+ }
59+ }
60+ }()
53 select {61 select {
54 case err := <-done:62 case err := <-done:
55 var displayErr *wlturbo.DisplayError63 var displayErr *wlturbo.DisplayError
@@ -333,3 +341,217 @@ func TestBufferContentUnpaddedLastRow(t *testing.T) {
333 }341 }
334 }342 }
335 }343 }
344+
345+type pointerEvents struct {
346+ wlturbo.BaseProxy
347+ enters chan [2]float64
348+ buttons chan uint32
349+}
350+
351+func (p *pointerEvents) Dispatch(e *wlturbo.Event) {
352+ switch e.Opcode {
353+ case uint16(wayland.PointerEventEnter):
354+ _ = e.Uint32()
355+ _ = e.Uint32()
356+ p.enters <- [2]float64{e.Fixed().Float64(), e.Fixed().Float64()}
357+ case uint16(wayland.PointerEventButton):
358+ _ = e.Uint32()
359+ _ = e.Uint32()
360+ p.buttons <- e.Uint32()
361+ }
362+}
363+
364+func TestPointerProtocol(t *testing.T) {
365+ s, events, commands, dir := lifecycleServer(t)
366+ c := protocolClient(t, s, dir)
367+ seat := bindProtocol(t, c, "wl_seat")
368+ registerProtocol(t, c, seat)
369+ pointer := c.AllocateID()
370+ p := &pointerEvents{enters: make(chan [2]float64, 1), buttons: make(chan uint32, 1)}
371+ p.SetID(pointer)
372+ c.Context().Register(p)
373+ requestProtocol(t, c, seat, wayland.SeatRequestGetPointer, pointer)
374+ comp := bindProtocol(t, c, "wl_compositor")
375+ wm := bindProtocol(t, c, "xdg_wm_base")
376+ shm := bindProtocol(t, c, "wl_shm")
377+ registerProtocol(t, c, shm)
378+ surf, xdg := c.AllocateID(), c.AllocateID()
379+ requestProtocol(t, c, comp, wayland.CompositorRequestCreateSurface, surf)
380+ registerProtocol(t, c, surf)
381+ requestProtocol(t, c, wm, xdgshell.WmBaseRequestGetXdgSurface, xdg, surf)
382+ serials := make(chan uint32, 1)
383+ xp := &configureProxy{serial: serials}
384+ xp.SetID(xdg)
385+ c.Context().Register(xp)
386+ top := c.AllocateID()
387+ registerProtocol(t, c, top)
388+ requestProtocol(t, c, xdg, xdgshell.SurfaceRequestGetToplevel, top)
389+ requestProtocol(t, c, surf, wayland.SurfaceRequestCommit)
390+ if err := c.Roundtrip(); err != nil {
391+ t.Fatal(err)
392+ }
393+ requestProtocol(t, c, xdg, xdgshell.SurfaceRequestAckConfigure, <-serials)
394+ fd, err := unix.MemfdCreate("pointer-buffer", 0)
395+ if err != nil {
396+ t.Fatal(err)
397+ }
398+ defer unix.Close(fd)
399+ if err := unix.Ftruncate(fd, 4); err != nil {
400+ t.Fatal(err)
401+ }
402+ pool, buffer := c.AllocateID(), c.AllocateID()
403+ registerProtocol(t, c, pool)
404+ registerProtocol(t, c, buffer)
405+ if err := c.SendRequestWithFDs(shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, int32(4)); err != nil {
406+ t.Fatal(err)
407+ }
408+ requestProtocol(t, c, pool, wayland.ShmPoolRequestCreateBuffer, buffer, int32(0), int32(1), int32(1), int32(4), uint32(0))
409+ requestProtocol(t, c, surf, wayland.SurfaceRequestAttach, buffer, int32(0), int32(0))
410+ requestProtocol(t, c, surf, wayland.SurfaceRequestCommit)
411+ if err := c.Roundtrip(); err != nil {
412+ t.Fatal(err)
413+ }
414+ w := mapped(t, events, 2*time.Second)
415+ commands <- ports.PointerFocus{ID: w.ID, X: 10, Y: 20}
416+ commands <- ports.PointerButtonTo{ID: w.ID, Button: 0x110, Pressed: true, TimeMsec: 1}
417+ deadline := time.After(2 * time.Second)
418+ dispatched := make(chan error, 1)
419+ go func() {
420+ for {
421+ if err := c.Dispatch(); err != nil {
422+ dispatched <- err
423+ return
424+ }
425+ }
426+ }()
427+ for len(p.enters) == 0 || len(p.buttons) == 0 {
428+ select {
429+ case err := <-dispatched:
430+ t.Fatal(err)
431+ case <-deadline:
432+ t.Fatal("pointer events timeout")
433+ case <-time.After(time.Millisecond):
434+ }
435+ }
436+ select {
437+ case coords := <-p.enters:
438+ if coords != [2]float64{10, 20} {
439+ t.Fatalf("enter: %v", coords)
440+ }
441+ default:
442+ t.Fatal("missing pointer enter")
443+ }
444+ select {
445+ case button := <-p.buttons:
446+ if button != 0x110 {
447+ t.Fatalf("button: %d", button)
448+ }
449+ default:
450+ t.Fatal("missing pointer button")
451+ }
452+}
453+
454+type heldKeyProxy struct {
455+ wlturbo.BaseProxy
456+ enters chan []byte
457+}
458+
459+func (p *heldKeyProxy) Dispatch(e *wlturbo.Event) {
460+ if e.Opcode == uint16(wayland.KeyboardEventEnter) {
461+ _ = e.Uint32()
462+ _ = e.Uint32()
463+ p.enters <- e.Array()
464+ }
465+}
466+
467+func TestHeldKeyOnKeyboardFocusTransfer(t *testing.T) {
468+ s, events, commands, dir := keyboardServer(t)
469+ c := protocolClient(t, s, dir)
470+ seat := bindProtocol(t, c, "wl_seat")
471+ registerProtocol(t, c, seat)
472+ keyboard := c.AllocateID()
473+ kp := &heldKeyProxy{enters: make(chan []byte, 4)}
474+ kp.SetID(keyboard)
475+ c.Context().Register(kp)
476+ requestProtocol(t, c, seat, wayland.SeatRequestGetKeyboard, keyboard)
477+ comp := bindProtocol(t, c, "wl_compositor")
478+ wm := bindProtocol(t, c, "xdg_wm_base")
479+ shm := bindProtocol(t, c, "wl_shm")
480+ registerProtocol(t, c, shm)
481+ fd, err := unix.MemfdCreate("held-key-buffer", 0)
482+ if err != nil {
483+ t.Fatal(err)
484+ }
485+ defer unix.Close(fd)
486+ if err := unix.Ftruncate(fd, 4); err != nil {
487+ t.Fatal(err)
488+ }
489+ pool, buffer := c.AllocateID(), c.AllocateID()
490+ registerProtocol(t, c, pool)
491+ registerProtocol(t, c, buffer)
492+ if err := c.SendRequestWithFDs(shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, int32(4)); err != nil {
493+ t.Fatal(err)
494+ }
495+ requestProtocol(t, c, pool, wayland.ShmPoolRequestCreateBuffer, buffer, int32(0), int32(1), int32(1), int32(4), uint32(0))
496+ mapWindow := func() ports.WindowID {
497+ surf, xdg, top := c.AllocateID(), c.AllocateID(), c.AllocateID()
498+ requestProtocol(t, c, comp, wayland.CompositorRequestCreateSurface, surf)
499+ registerProtocol(t, c, surf)
500+ requestProtocol(t, c, wm, xdgshell.WmBaseRequestGetXdgSurface, xdg, surf)
501+ serials := make(chan uint32, 1)
502+ xp := &configureProxy{serial: serials}
503+ xp.SetID(xdg)
504+ c.Context().Register(xp)
505+ registerProtocol(t, c, top)
506+ requestProtocol(t, c, xdg, xdgshell.SurfaceRequestGetToplevel, top)
507+ requestProtocol(t, c, surf, wayland.SurfaceRequestCommit)
508+ if err := c.Roundtrip(); err != nil {
509+ t.Fatal(err)
510+ }
511+ select {
512+ case serial := <-serials:
513+ requestProtocol(t, c, xdg, xdgshell.SurfaceRequestAckConfigure, serial)
514+ default:
515+ t.Fatal("missing configure")
516+ }
517+ requestProtocol(t, c, surf, wayland.SurfaceRequestAttach, buffer, int32(0), int32(0))
518+ requestProtocol(t, c, surf, wayland.SurfaceRequestCommit)
519+ if err := c.Roundtrip(); err != nil {
520+ t.Fatal(err)
521+ }
522+ return mapped(t, events, 2*time.Second).ID
523+ }
524+ a := mapWindow()
525+ b := mapWindow()
526+ // Dispatch without sending a new request after focus changes.
527+ dispatched := make(chan error, 1)
528+ go func() {
529+ for {
530+ if err := c.Dispatch(); err != nil {
531+ dispatched <- err
532+ return
533+ }
534+ }
535+ }()
536+ enter := func(want []byte) {
537+ t.Helper()
538+ select {
539+ case got := <-kp.enters:
540+ if !bytes.Equal(got, want) {
541+ t.Fatalf("keyboard enter keys = %v, want %v", got, want)
542+ }
543+ case err := <-dispatched:
544+ t.Fatal(err)
545+ case <-time.After(2 * time.Second):
546+ t.Fatal("keyboard enter timeout")
547+ }
548+ }
549+ commands <- ports.FocusWindow{ID: a}
550+ enter(nil)
551+ commands <- ports.ForwardKey{ID: a, Key: ports.KeyEvent{Keycode: 30, Pressed: true, TimeMsec: 1}}
552+ commands <- ports.FocusWindow{ID: b}
553+ enter([]byte{30, 0, 0, 0})
554+ commands <- ports.ForwardKey{ID: b, Key: ports.KeyEvent{Keycode: 30, TimeMsec: 2}}
555+ commands <- ports.FocusWindow{ID: a}
556+ enter(nil)
557+}
M internal/adapters/wayland/server.go +14 -1
@@ -50,6 +50,7 @@ type Server struct {
5050 pointers map[server.Client][]*wayland.Pointer
5151 modState ports.ModState
5252 keyboards map[server.Client][]*wayland.Keyboard
53+ heldKeys map[uint32]bool
5354 keymapFD int
5455 keymapSize uint32
5556 repeatRate, repeatDelay int
@@ -327,6 +328,14 @@ func (s *Server) apply(cmd ports.ClientCommand) {
327328 s.log.Debug().Uint64("id", uint64(c.ID)).Msg("ignored forward key")
328329 return
329330 }
331+ if s.heldKeys == nil {
332+ s.heldKeys = make(map[uint32]bool)
333+ }
334+ if c.Key.Pressed {
335+ s.heldKeys[c.Key.Keycode] = true
336+ } else {
337+ delete(s.heldKeys, c.Key.Keycode)
338+ }
330339 s.serial++
331340 state := uint32(0)
332341 if c.Key.Pressed {
@@ -399,7 +408,11 @@ func (s *Server) changeFocus(id ports.WindowID) {
399408 s.focused = id
400409 for _, k := range s.windowKeyboards(w) {
401410 s.serial++
402- k.SendEnter(s.serial, w.xdg.surfaceResource(), []byte{})
411+ var keys []byte
412+ for code := range s.heldKeys {
413+ keys = binary.LittleEndian.AppendUint32(keys, code)
414+ }
415+ k.SendEnter(s.serial, w.xdg.surfaceResource(), keys)
403416 s.sendModifiers(k)
404417 }
405418 }
@@ -50,6 +50,7 @@ type Server struct {
50 pointers map[server.Client][]*wayland.Pointer50 pointers map[server.Client][]*wayland.Pointer
51 modState ports.ModState51 modState ports.ModState
52 keyboards map[server.Client][]*wayland.Keyboard52 keyboards map[server.Client][]*wayland.Keyboard
53+ heldKeys map[uint32]bool
53 keymapFD int54 keymapFD int
54 keymapSize uint3255 keymapSize uint32
55 repeatRate, repeatDelay int56 repeatRate, repeatDelay int
@@ -327,6 +328,14 @@ func (s *Server) apply(cmd ports.ClientCommand) {
327 s.log.Debug().Uint64("id", uint64(c.ID)).Msg("ignored forward key")328 s.log.Debug().Uint64("id", uint64(c.ID)).Msg("ignored forward key")
328 return329 return
329 }330 }
331+ if s.heldKeys == nil {
332+ s.heldKeys = make(map[uint32]bool)
333+ }
334+ if c.Key.Pressed {
335+ s.heldKeys[c.Key.Keycode] = true
336+ } else {
337+ delete(s.heldKeys, c.Key.Keycode)
338+ }
330 s.serial++339 s.serial++
331 state := uint32(0)340 state := uint32(0)
332 if c.Key.Pressed {341 if c.Key.Pressed {
@@ -399,7 +408,11 @@ func (s *Server) changeFocus(id ports.WindowID) {
399 s.focused = id408 s.focused = id
400 for _, k := range s.windowKeyboards(w) {409 for _, k := range s.windowKeyboards(w) {
401 s.serial++410 s.serial++
402- k.SendEnter(s.serial, w.xdg.surfaceResource(), []byte{})411+ var keys []byte
412+ for code := range s.heldKeys {
413+ keys = binary.LittleEndian.AppendUint32(keys, code)
414+ }
415+ k.SendEnter(s.serial, w.xdg.surfaceResource(), keys)
403 s.sendModifiers(k)416 s.sendModifiers(k)
404 }417 }
405 }418 }
M internal/app/app.go +5 -2
@@ -101,9 +101,12 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
101101 _ = headlessinput.Run(ctx, km, script, input, logging.For(ctx, "input"))
102102 }()
103103 if opts.Script != nil {
104- workers.Add(1)
104+ // A reader without Close may remain blocked in Scan after shutdown.
105+ // It must not be joined with the application workers.
106+ if closer, ok := opts.Script.(io.Closer); ok {
107+ go func() { <-ctx.Done(); _ = closer.Close() }()
108+ }
105109 go func() {
106- defer workers.Done()
107110 defer close(script)
108111 scanner := bufio.NewScanner(opts.Script)
109112 for scanner.Scan() {
@@ -101,9 +101,12 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
101 _ = headlessinput.Run(ctx, km, script, input, logging.For(ctx, "input"))101 _ = headlessinput.Run(ctx, km, script, input, logging.For(ctx, "input"))
102 }()102 }()
103 if opts.Script != nil {103 if opts.Script != nil {
104- workers.Add(1)104+ // A reader without Close may remain blocked in Scan after shutdown.
105+ // It must not be joined with the application workers.
106+ if closer, ok := opts.Script.(io.Closer); ok {
107+ go func() { <-ctx.Done(); _ = closer.Close() }()
108+ }
105 go func() {109 go func() {
106- defer workers.Done()
107 defer close(script)110 defer close(script)
108 scanner := bufio.NewScanner(opts.Script)111 scanner := bufio.NewScanner(opts.Script)
109 for scanner.Scan() {112 for scanner.Scan() {
M internal/app/app_test.go +16 -0
@@ -4,6 +4,7 @@ import (
44 "context"
55 "image/color"
66 "image/png"
7+ "io"
78 "os"
89 "os/exec"
910 "path/filepath"
@@ -241,3 +242,18 @@ func TestHeadlessPointerClickFocus(t *testing.T) {
241242 t.Fatalf("two columns=%v first focused=%v", two, focused)
242243 }
243244 }
245+
246+func TestBlockedScriptShutdown(t *testing.T) {
247+ t.Setenv("XDG_RUNTIME_DIR", t.TempDir())
248+ r, w := io.Pipe()
249+ defer w.Close()
250+ done := make(chan error, 1)
251+ go func() {
252+ done <- Run(context.Background(), Options{Backend: "headless", Config: config.Defaults(), NoTerminal: true, Script: r, Timeout: 300 * time.Millisecond})
253+ }()
254+ select {
255+ case <-done:
256+ case <-time.After(2 * time.Second):
257+ t.Fatal("blocked script prevented shutdown")
258+ }
259+}
@@ -4,6 +4,7 @@ import (
4 "context"4 "context"
5 "image/color"5 "image/color"
6 "image/png"6 "image/png"
7+ "io"
7 "os"8 "os"
8 "os/exec"9 "os/exec"
9 "path/filepath"10 "path/filepath"
@@ -241,3 +242,18 @@ func TestHeadlessPointerClickFocus(t *testing.T) {
241 t.Fatalf("two columns=%v first focused=%v", two, focused)242 t.Fatalf("two columns=%v first focused=%v", two, focused)
242 }243 }
243 }244 }
245+
246+func TestBlockedScriptShutdown(t *testing.T) {
247+ t.Setenv("XDG_RUNTIME_DIR", t.TempDir())
248+ r, w := io.Pipe()
249+ defer w.Close()
250+ done := make(chan error, 1)
251+ go func() {
252+ done <- Run(context.Background(), Options{Backend: "headless", Config: config.Defaults(), NoTerminal: true, Script: r, Timeout: 300 * time.Millisecond})
253+ }()
254+ select {
255+ case <-done:
256+ case <-time.After(2 * time.Second):
257+ t.Fatal("blocked script prevented shutdown")
258+ }
259+}

Keyboard shortcuts

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

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

Branches 23

Tags 10

↑↓ moveenter openesc close