detached 5d664d1

fix(input): stop bind repeats when the overview, a launcher or an inhibitor takes over

A bound repeat now remembers whether the overview was open at the press
and stops when it opens or closes, or when a launcher or menu takes the
keyboard. Bare overview-key repeats stop on the same conditions.
These checks run before the activity report, so a dead repeat no longer
reports user activity.
Tests now cover reload, launcher and inhibitor through Run, and the
headless hold duration.
commit
5d664d1e8b485ce7f056ce0c17e1897f03a04ee8
author
brice · · 2026-10-15 07:33 UTC
parent
3d47d2b
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +140 -36
M internal/adapters/headlessinput/input_test.go +44 -0
@@ -4,6 +4,8 @@ import (
44 "context"
55 "strings"
66 "testing"
7+ "testing/synctest"
8+ "time"
79
810 "github.com/bnema/neferwl/internal/adapters/logging"
911 "github.com/bnema/neferwl/internal/adapters/xkb"
@@ -59,6 +61,48 @@ func TestScript(t *testing.T) {
5961 }
6062 }
6163
64+// hold keeps the key and its modifiers down for the given duration, then
65+// releases the key before the modifier.
66+func TestHoldKeepsKeyDown(t *testing.T) {
67+ synctest.Test(t, func(t *testing.T) {
68+ km, err := xkb.New(xkb.RMLVO{Layout: "us"})
69+ if err != nil {
70+ t.Skip(err)
71+ }
72+ script := make(chan string, 1)
73+ script <- "hold Super+h 1s"
74+ close(script)
75+ input := make(chan ports.InputEvent, 8)
76+ done := make(chan error, 1)
77+ go func() {
78+ done <- Run(context.Background(), km, nil, script, input, nil, nil, logging.For(context.Background(), "input"))
79+ }()
80+ time.Sleep(900 * time.Millisecond)
81+ synctest.Wait()
82+ if len(input) != 2 {
83+ t.Fatalf("before the hold ends: %d events, want the 2 presses", len(input))
84+ }
85+ if err := <-done; err != nil {
86+ t.Fatal(err)
87+ }
88+ for i, want := range []struct {
89+ name string
90+ pressed bool
91+ super bool
92+ }{
93+ {"Super_L", true, true},
94+ {"h", true, true},
95+ {"h", false, true},
96+ {"Super_L", false, false},
97+ } {
98+ ev := (<-input).(ports.KeyEvent)
99+ if ev.Keysym != want.name || ev.Pressed != want.pressed || (ev.Mods&ports.ModSuper != 0) != want.super {
100+ t.Errorf("event %d: %s pressed=%v mods=%v, want %s pressed=%v super=%v", i, ev.Keysym, ev.Pressed, ev.Mods, want.name, want.pressed, want.super)
101+ }
102+ }
103+ })
104+}
105+
62106 func TestPointerScript(t *testing.T) {
63107 km, err := xkb.New(xkb.RMLVO{Layout: "us"})
64108 if err != nil {
@@ -4,6 +4,8 @@ import (
4 "context"4 "context"
5 "strings"5 "strings"
6 "testing"6 "testing"
7+ "testing/synctest"
8+ "time"
7 9
8 "github.com/bnema/neferwl/internal/adapters/logging"10 "github.com/bnema/neferwl/internal/adapters/logging"
9 "github.com/bnema/neferwl/internal/adapters/xkb"11 "github.com/bnema/neferwl/internal/adapters/xkb"
@@ -59,6 +61,48 @@ func TestScript(t *testing.T) {
59 }61 }
60 }62 }
61 63
64+// hold keeps the key and its modifiers down for the given duration, then
65+// releases the key before the modifier.
66+func TestHoldKeepsKeyDown(t *testing.T) {
67+ synctest.Test(t, func(t *testing.T) {
68+ km, err := xkb.New(xkb.RMLVO{Layout: "us"})
69+ if err != nil {
70+ t.Skip(err)
71+ }
72+ script := make(chan string, 1)
73+ script <- "hold Super+h 1s"
74+ close(script)
75+ input := make(chan ports.InputEvent, 8)
76+ done := make(chan error, 1)
77+ go func() {
78+ done <- Run(context.Background(), km, nil, script, input, nil, nil, logging.For(context.Background(), "input"))
79+ }()
80+ time.Sleep(900 * time.Millisecond)
81+ synctest.Wait()
82+ if len(input) != 2 {
83+ t.Fatalf("before the hold ends: %d events, want the 2 presses", len(input))
84+ }
85+ if err := <-done; err != nil {
86+ t.Fatal(err)
87+ }
88+ for i, want := range []struct {
89+ name string
90+ pressed bool
91+ super bool
92+ }{
93+ {"Super_L", true, true},
94+ {"h", true, true},
95+ {"h", false, true},
96+ {"Super_L", false, false},
97+ } {
98+ ev := (<-input).(ports.KeyEvent)
99+ if ev.Keysym != want.name || ev.Pressed != want.pressed || (ev.Mods&ports.ModSuper != 0) != want.super {
100+ t.Errorf("event %d: %s pressed=%v mods=%v, want %s pressed=%v super=%v", i, ev.Keysym, ev.Pressed, ev.Mods, want.name, want.pressed, want.super)
101+ }
102+ }
103+ })
104+}
105+
62 func TestPointerScript(t *testing.T) {106 func TestPointerScript(t *testing.T) {
63 km, err := xkb.New(xkb.RMLVO{Layout: "us"})107 km, err := xkb.New(xkb.RMLVO{Layout: "us"})
64 if err != nil {108 if err != nil {
M internal/core/core.go +3 -0
@@ -1146,6 +1146,9 @@ func (c *Core) Run(ctx context.Context) error {
11461146 if c.securityCheckpoint(ctx) != nil {
11471147 return nil
11481148 }
1149+ if !c.repeatLive() {
1150+ continue
1151+ }
11491152 // A held key is activity, as its press was: idle timers
11501153 // must not fire while it repeats.
11511154 if err := c.userActivity(ctx); err != nil {
@@ -1146,6 +1146,9 @@ func (c *Core) Run(ctx context.Context) error {
1146 if c.securityCheckpoint(ctx) != nil {1146 if c.securityCheckpoint(ctx) != nil {
1147 return nil1147 return nil
1148 }1148 }
1149+ if !c.repeatLive() {
1150+ continue
1151+ }
1149 // A held key is activity, as its press was: idle timers1152 // A held key is activity, as its press was: idle timers
1150 // must not fire while it repeats.1153 // must not fire while it repeats.
1151 if err := c.userActivity(ctx); err != nil {1154 if err := c.userActivity(ctx); err != nil {
M internal/core/repeat.go +25 -14
@@ -28,7 +28,10 @@ type keyRepeat struct {
2828 // empty.
2929 action Action
3030 key ports.KeyEvent
31- timer ports.Timer
31+ // overview is whether the overview was open at the press: opening or
32+ // closing it stops the repeat.
33+ overview bool
34+ timer ports.Timer
3235 }
3336
3437 // repeats reports whether a held key repeats bind a: moves only, never an
@@ -68,6 +71,7 @@ func (c *Core) startRepeat(key ports.KeyEvent, action Action) {
6871 c.stopRepeat()
6972 r := &c.repeat
7073 r.held, r.mods, r.action, r.key = heldKey(key), key.Mods, action, key
74+ r.overview = c.cur().mon.ov.open
7175 r.timer = newPortTimer(c.opts.Clock, repeatDelay)
7276 c.repeatC = r.timer.C()
7377 }
@@ -92,22 +96,33 @@ func (c *Core) repeatKey(key ports.KeyEvent) {
9296 }
9397 }
9498
95-// repeatTick runs one repeat of the held key and arms the next. It stops
96-// when the key can no longer run what it ran: the overview closed or a
97-// launcher took the keyboard, or a window inhibits the shortcuts.
98-func (c *Core) repeatTick(ctx context.Context) error {
99+// repeatLive reports whether the held key still runs what it ran, and stops
100+// the repeat when not: the overview opened or closed, or a launcher or a
101+// menu took the keyboard. A bind also stops when a window inhibits the
102+// shortcuts; overview keys run before inhibitors, as on a press.
103+func (c *Core) repeatLive() bool {
99104 r := c.repeat
100105 if r.timer == nil {
106+ return false
107+ }
108+ if c.cur().mon.ov.open != r.overview || c.overviewKeyboardTaken() ||
109+ r.action != "" && c.keyboard.inhibited(c.keyboardFocus()) {
110+ c.stopRepeat()
111+ return false
112+ }
113+ return true
114+}
115+
116+// repeatTick runs one repeat of the held key, if still live, and arms the
117+// next.
118+func (c *Core) repeatTick(ctx context.Context) error {
119+ if !c.repeatLive() {
101120 return nil
102121 }
122+ r, mon := c.repeat, c.cur().mon
103123 c.inputActive = true
104124 defer func() { c.inputActive = false }()
105125 if r.action == "" {
106- mon := c.cur().mon
107- if !mon.ov.open || c.overviewKeyboardTaken() {
108- c.stopRepeat()
109- return nil
110- }
111126 now := c.now()
112127 shots := c.snapshot(now)
113128 mon.overviewKey(r.key)
@@ -118,10 +133,6 @@ func (c *Core) repeatTick(ctx context.Context) error {
118133 c.repeat.timer.Reset(repeatInterval)
119134 return c.publish(ctx)
120135 }
121- if c.keyboard.inhibited(c.keyboardFocus()) {
122- c.stopRepeat()
123- return nil
124- }
125136 c.repeat.timer.Reset(repeatInterval)
126137 return c.runBind(ctx, r.action)
127138 }
@@ -28,7 +28,10 @@ type keyRepeat struct {
28 // empty.28 // empty.
29 action Action29 action Action
30 key ports.KeyEvent30 key ports.KeyEvent
31- timer ports.Timer31+ // overview is whether the overview was open at the press: opening or
32+ // closing it stops the repeat.
33+ overview bool
34+ timer ports.Timer
32 }35 }
33 36
34 // repeats reports whether a held key repeats bind a: moves only, never an37 // repeats reports whether a held key repeats bind a: moves only, never an
@@ -68,6 +71,7 @@ func (c *Core) startRepeat(key ports.KeyEvent, action Action) {
68 c.stopRepeat()71 c.stopRepeat()
69 r := &c.repeat72 r := &c.repeat
70 r.held, r.mods, r.action, r.key = heldKey(key), key.Mods, action, key73 r.held, r.mods, r.action, r.key = heldKey(key), key.Mods, action, key
74+ r.overview = c.cur().mon.ov.open
71 r.timer = newPortTimer(c.opts.Clock, repeatDelay)75 r.timer = newPortTimer(c.opts.Clock, repeatDelay)
72 c.repeatC = r.timer.C()76 c.repeatC = r.timer.C()
73 }77 }
@@ -92,22 +96,33 @@ func (c *Core) repeatKey(key ports.KeyEvent) {
92 }96 }
93 }97 }
94 98
95-// repeatTick runs one repeat of the held key and arms the next. It stops99+// repeatLive reports whether the held key still runs what it ran, and stops
96-// when the key can no longer run what it ran: the overview closed or a100+// the repeat when not: the overview opened or closed, or a launcher or a
97-// launcher took the keyboard, or a window inhibits the shortcuts.101+// menu took the keyboard. A bind also stops when a window inhibits the
98-func (c *Core) repeatTick(ctx context.Context) error {102+// shortcuts; overview keys run before inhibitors, as on a press.
103+func (c *Core) repeatLive() bool {
99 r := c.repeat104 r := c.repeat
100 if r.timer == nil {105 if r.timer == nil {
106+ return false
107+ }
108+ if c.cur().mon.ov.open != r.overview || c.overviewKeyboardTaken() ||
109+ r.action != "" && c.keyboard.inhibited(c.keyboardFocus()) {
110+ c.stopRepeat()
111+ return false
112+ }
113+ return true
114+}
115+
116+// repeatTick runs one repeat of the held key, if still live, and arms the
117+// next.
118+func (c *Core) repeatTick(ctx context.Context) error {
119+ if !c.repeatLive() {
101 return nil120 return nil
102 }121 }
122+ r, mon := c.repeat, c.cur().mon
103 c.inputActive = true123 c.inputActive = true
104 defer func() { c.inputActive = false }()124 defer func() { c.inputActive = false }()
105 if r.action == "" {125 if r.action == "" {
106- mon := c.cur().mon
107- if !mon.ov.open || c.overviewKeyboardTaken() {
108- c.stopRepeat()
109- return nil
110- }
111 now := c.now()126 now := c.now()
112 shots := c.snapshot(now)127 shots := c.snapshot(now)
113 mon.overviewKey(r.key)128 mon.overviewKey(r.key)
@@ -118,10 +133,6 @@ func (c *Core) repeatTick(ctx context.Context) error {
118 c.repeat.timer.Reset(repeatInterval)133 c.repeat.timer.Reset(repeatInterval)
119 return c.publish(ctx)134 return c.publish(ctx)
120 }135 }
121- if c.keyboard.inhibited(c.keyboardFocus()) {
122- c.stopRepeat()
123- return nil
124- }
125 c.repeat.timer.Reset(repeatInterval)136 c.repeat.timer.Reset(repeatInterval)
126 return c.runBind(ctx, r.action)137 return c.runBind(ctx, r.action)
127 }138 }
M internal/core/repeat_run_test.go +54 -16
@@ -10,9 +10,18 @@ import (
1010 "github.com/bnema/neferwl/internal/ports"
1111 )
1212
13+// rig is a running Core's channels and config.
14+type rig struct {
15+ input chan ports.InputEvent
16+ client chan ports.ClientEvent
17+ reload chan ports.ConfigChanged
18+ commands chan ports.ClientCommand
19+ cfg ports.Config
20+}
21+
1322 // repeatRig runs a Core with five windows in a row on one output, the last
14-// one focused, and returns its input, config and command channels.
15-func repeatRig(t *testing.T) (chan ports.InputEvent, chan ports.ConfigChanged, chan ports.ClientCommand, ports.Config) {
23+// one focused.
24+func repeatRig(t *testing.T) rig {
1625 t.Helper()
1726 input := make(chan ports.InputEvent, 4)
1827 client := make(chan ports.ClientEvent, 8)
@@ -34,7 +43,7 @@ func repeatRig(t *testing.T) (chan ports.InputEvent, chan ports.ConfigChanged, c
3443 client <- ports.WindowMapped{ID: id}
3544 }
3645 synctest.Wait()
37- return input, reload, commands, cfg
46+ return rig{input, client, reload, commands, cfg}
3847 }
3948
4049 // drain counts the focus changes and activity reports sent since the last
@@ -53,12 +62,12 @@ func drain(commands chan ports.ClientCommand) (focus, activity int) {
5362
5463 var cmdLeft = ports.KeyEvent{Keysym: "Left", Keycode: 113, Mods: ports.ModSuper, Pressed: true}
5564
56-// Through Run: a held cmd+Left repeats on its timer, each repeat reports
57-// user activity (idle timers must not fire while a key is held), and a
58-// config reload ends it, since the new keymap may swallow the release.
65+// Through Run: a held cmd+Left repeats on its timer and each repeat reports
66+// user activity, so idle timers do not fire while a key is held.
5967 func TestHeldKeyRepeatsThroughRun(t *testing.T) {
6068 synctest.Test(t, func(t *testing.T) {
61- input, reload, commands, cfg := repeatRig(t)
69+ r := repeatRig(t)
70+ input, commands := r.input, r.commands
6271 drain(commands)
6372 input <- cmdLeft
6473 synctest.Wait()
@@ -71,14 +80,43 @@ func TestHeldKeyRepeatsThroughRun(t *testing.T) {
7180 if focus, activity := drain(commands); focus < 2 || activity == 0 {
7281 t.Fatalf("held: %d focus changes, %d activity reports", focus, activity)
7382 }
74-
75- reload <- ports.ConfigChanged{Config: cfg}
76- synctest.Wait()
77- drain(commands)
78- time.Sleep(time.Second)
79- synctest.Wait()
80- if focus, _ := drain(commands); focus != 0 {
81- t.Fatalf("after the reload: %d focus changes, want none", focus)
82- }
8383 })
8484 }
85+
86+// A held key stops repeating when what it ran no longer applies. Each
87+// event lands before the first repeat, while windows remain to the left,
88+// so a surviving repeat would move focus or, behind a launcher holding the
89+// keyboard, still report activity.
90+func TestRepeatStopsThroughRun(t *testing.T) {
91+ for _, tt := range []struct {
92+ name string
93+ send func(rig)
94+ }{
95+ // The new keymap may swallow the release.
96+ {"config reload", func(r rig) { r.reload <- ports.ConfigChanged{Config: r.cfg} }},
97+ {"launcher takes the keyboard", func(r rig) {
98+ r.client <- ports.LayerChanged{Layers: []ports.LayerSurface{{ID: 9, Layer: ports.LayerOverlay, Width: 100, Height: 40, Keyboard: 1}}}
99+ }},
100+ {"window inhibits shortcuts", func(r rig) { r.client <- ports.ShortcutsInhibit{Window: 4, Active: true} }},
101+ } {
102+ t.Run(tt.name, func(t *testing.T) {
103+ synctest.Test(t, func(t *testing.T) {
104+ r := repeatRig(t)
105+ drain(r.commands)
106+ r.input <- cmdLeft
107+ synctest.Wait()
108+ if focus, _ := drain(r.commands); focus != 1 {
109+ t.Fatalf("press: %d focus changes, want 1", focus)
110+ }
111+ tt.send(r)
112+ synctest.Wait()
113+ drain(r.commands)
114+ time.Sleep(time.Second)
115+ synctest.Wait()
116+ if focus, activity := drain(r.commands); focus != 0 || activity != 0 {
117+ t.Fatalf("%d focus changes, %d activity reports, want none", focus, activity)
118+ }
119+ })
120+ })
121+ }
122+}
@@ -10,9 +10,18 @@ import (
10 "github.com/bnema/neferwl/internal/ports"10 "github.com/bnema/neferwl/internal/ports"
11 )11 )
12 12
13+// rig is a running Core's channels and config.
14+type rig struct {
15+ input chan ports.InputEvent
16+ client chan ports.ClientEvent
17+ reload chan ports.ConfigChanged
18+ commands chan ports.ClientCommand
19+ cfg ports.Config
20+}
21+
13 // repeatRig runs a Core with five windows in a row on one output, the last22 // repeatRig runs a Core with five windows in a row on one output, the last
14-// one focused, and returns its input, config and command channels.23+// one focused.
15-func repeatRig(t *testing.T) (chan ports.InputEvent, chan ports.ConfigChanged, chan ports.ClientCommand, ports.Config) {24+func repeatRig(t *testing.T) rig {
16 t.Helper()25 t.Helper()
17 input := make(chan ports.InputEvent, 4)26 input := make(chan ports.InputEvent, 4)
18 client := make(chan ports.ClientEvent, 8)27 client := make(chan ports.ClientEvent, 8)
@@ -34,7 +43,7 @@ func repeatRig(t *testing.T) (chan ports.InputEvent, chan ports.ConfigChanged, c
34 client <- ports.WindowMapped{ID: id}43 client <- ports.WindowMapped{ID: id}
35 }44 }
36 synctest.Wait()45 synctest.Wait()
37- return input, reload, commands, cfg46+ return rig{input, client, reload, commands, cfg}
38 }47 }
39 48
40 // drain counts the focus changes and activity reports sent since the last49 // drain counts the focus changes and activity reports sent since the last
@@ -53,12 +62,12 @@ func drain(commands chan ports.ClientCommand) (focus, activity int) {
53 62
54 var cmdLeft = ports.KeyEvent{Keysym: "Left", Keycode: 113, Mods: ports.ModSuper, Pressed: true}63 var cmdLeft = ports.KeyEvent{Keysym: "Left", Keycode: 113, Mods: ports.ModSuper, Pressed: true}
55 64
56-// Through Run: a held cmd+Left repeats on its timer, each repeat reports65+// Through Run: a held cmd+Left repeats on its timer and each repeat reports
57-// user activity (idle timers must not fire while a key is held), and a66+// user activity, so idle timers do not fire while a key is held.
58-// config reload ends it, since the new keymap may swallow the release.
59 func TestHeldKeyRepeatsThroughRun(t *testing.T) {67 func TestHeldKeyRepeatsThroughRun(t *testing.T) {
60 synctest.Test(t, func(t *testing.T) {68 synctest.Test(t, func(t *testing.T) {
61- input, reload, commands, cfg := repeatRig(t)69+ r := repeatRig(t)
70+ input, commands := r.input, r.commands
62 drain(commands)71 drain(commands)
63 input <- cmdLeft72 input <- cmdLeft
64 synctest.Wait()73 synctest.Wait()
@@ -71,14 +80,43 @@ func TestHeldKeyRepeatsThroughRun(t *testing.T) {
71 if focus, activity := drain(commands); focus < 2 || activity == 0 {80 if focus, activity := drain(commands); focus < 2 || activity == 0 {
72 t.Fatalf("held: %d focus changes, %d activity reports", focus, activity)81 t.Fatalf("held: %d focus changes, %d activity reports", focus, activity)
73 }82 }
74-
75- reload <- ports.ConfigChanged{Config: cfg}
76- synctest.Wait()
77- drain(commands)
78- time.Sleep(time.Second)
79- synctest.Wait()
80- if focus, _ := drain(commands); focus != 0 {
81- t.Fatalf("after the reload: %d focus changes, want none", focus)
82- }
83 })83 })
84 }84 }
85+
86+// A held key stops repeating when what it ran no longer applies. Each
87+// event lands before the first repeat, while windows remain to the left,
88+// so a surviving repeat would move focus or, behind a launcher holding the
89+// keyboard, still report activity.
90+func TestRepeatStopsThroughRun(t *testing.T) {
91+ for _, tt := range []struct {
92+ name string
93+ send func(rig)
94+ }{
95+ // The new keymap may swallow the release.
96+ {"config reload", func(r rig) { r.reload <- ports.ConfigChanged{Config: r.cfg} }},
97+ {"launcher takes the keyboard", func(r rig) {
98+ r.client <- ports.LayerChanged{Layers: []ports.LayerSurface{{ID: 9, Layer: ports.LayerOverlay, Width: 100, Height: 40, Keyboard: 1}}}
99+ }},
100+ {"window inhibits shortcuts", func(r rig) { r.client <- ports.ShortcutsInhibit{Window: 4, Active: true} }},
101+ } {
102+ t.Run(tt.name, func(t *testing.T) {
103+ synctest.Test(t, func(t *testing.T) {
104+ r := repeatRig(t)
105+ drain(r.commands)
106+ r.input <- cmdLeft
107+ synctest.Wait()
108+ if focus, _ := drain(r.commands); focus != 1 {
109+ t.Fatalf("press: %d focus changes, want 1", focus)
110+ }
111+ tt.send(r)
112+ synctest.Wait()
113+ drain(r.commands)
114+ time.Sleep(time.Second)
115+ synctest.Wait()
116+ if focus, activity := drain(r.commands); focus != 0 || activity != 0 {
117+ t.Fatalf("%d focus changes, %d activity reports, want none", focus, activity)
118+ }
119+ })
120+ })
121+ }
122+}
M internal/core/repeat_test.go +14 -6
@@ -156,11 +156,19 @@ func TestOverviewHeldKeyRepeats(t *testing.T) {
156156 }
157157 sendKeys(t, c, key("Left", keyLeft, ports.ModSuper, false))
158158
159- // The overview closing ends a repeat.
160- sendKeys(t, c, key("Left", keyLeft, 0, true))
161- m.CancelOverview()
162- tick(t, c)
163- if c.repeatC != nil {
164- t.Fatal("the repeat outlived the overview")
159+ // The overview closing ends a repeat, of an overview key or a bind:
160+ // Cmd+Left would go on in the layout.
161+ for _, mods := range []ports.Mods{0, ports.ModSuper} {
162+ if !m.ov.open {
163+ m.ToggleOverview()
164+ }
165+ sendKeys(t, c, key("Left", keyLeft, mods, true))
166+ m.CancelOverview()
167+ focus := focusedID(m)
168+ tick(t, c)
169+ if c.repeatC != nil || focusedID(m) != focus {
170+ t.Fatalf("mods %v: the repeat outlived the overview", mods)
171+ }
172+ sendKeys(t, c, key("Left", keyLeft, mods, false))
165173 }
166174 }
@@ -156,11 +156,19 @@ func TestOverviewHeldKeyRepeats(t *testing.T) {
156 }156 }
157 sendKeys(t, c, key("Left", keyLeft, ports.ModSuper, false))157 sendKeys(t, c, key("Left", keyLeft, ports.ModSuper, false))
158 158
159- // The overview closing ends a repeat.159+ // The overview closing ends a repeat, of an overview key or a bind:
160- sendKeys(t, c, key("Left", keyLeft, 0, true))160+ // Cmd+Left would go on in the layout.
161- m.CancelOverview()161+ for _, mods := range []ports.Mods{0, ports.ModSuper} {
162- tick(t, c)162+ if !m.ov.open {
163- if c.repeatC != nil {163+ m.ToggleOverview()
164- t.Fatal("the repeat outlived the overview")164+ }
165+ sendKeys(t, c, key("Left", keyLeft, mods, true))
166+ m.CancelOverview()
167+ focus := focusedID(m)
168+ tick(t, c)
169+ if c.repeatC != nil || focusedID(m) != focus {
170+ t.Fatalf("mods %v: the repeat outlived the overview", mods)
171+ }
172+ sendKeys(t, c, key("Left", keyLeft, mods, false))
165 }173 }
166 }174 }

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