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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 ( | |||
| 4 | 4 | "context" | |
| 5 | 5 | "strings" | |
| 6 | 6 | "testing" | |
| 7 | + | "testing/synctest" | |
| 8 | + | "time" | |
| 7 | 9 | ||
| 8 | 10 | "github.com/bnema/neferwl/internal/adapters/logging" | |
| 9 | 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 | 106 | func TestPointerScript(t *testing.T) { | |
| 63 | 107 | km, err := xkb.New(xkb.RMLVO{Layout: "us"}) | |
| 64 | 108 | 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 { | |||
| 1146 | 1146 | if c.securityCheckpoint(ctx) != nil { | |
| 1147 | 1147 | return nil | |
| 1148 | 1148 | } | |
| 1149 | + | if !c.repeatLive() { | |
| 1150 | + | continue | |
| 1151 | + | } | |
| 1149 | 1152 | // A held key is activity, as its press was: idle timers | |
| 1150 | 1153 | // must not fire while it repeats. | |
| 1151 | 1154 | 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 nil | 1147 | return nil | ||
| 1148 | } | 1148 | } | ||
| 1149 | + | if !c.repeatLive() { | |||
| 1150 | + | continue | |||
| 1151 | + | } | |||
| 1149 | // A held key is activity, as its press was: idle timers | 1152 | // 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 { | |||
| 28 | 28 | // empty. | |
| 29 | 29 | action Action | |
| 30 | 30 | 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 | |
| 32 | 35 | } | |
| 33 | 36 | ||
| 34 | 37 | // 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 | 71 | c.stopRepeat() | |
| 69 | 72 | r := &c.repeat | |
| 70 | 73 | r.held, r.mods, r.action, r.key = heldKey(key), key.Mods, action, key | |
| 74 | + | r.overview = c.cur().mon.ov.open | |
| 71 | 75 | r.timer = newPortTimer(c.opts.Clock, repeatDelay) | |
| 72 | 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 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 { | |
| 99 | 104 | r := c.repeat | |
| 100 | 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 | 120 | return nil | |
| 102 | 121 | } | |
| 122 | + | r, mon := c.repeat, c.cur().mon | |
| 103 | 123 | c.inputActive = true | |
| 104 | 124 | defer func() { c.inputActive = false }() | |
| 105 | 125 | if r.action == "" { | |
| 106 | - | mon := c.cur().mon | |
| 107 | - | if !mon.ov.open || c.overviewKeyboardTaken() { | |
| 108 | - | c.stopRepeat() | |
| 109 | - | return nil | |
| 110 | - | } | |
| 111 | 126 | now := c.now() | |
| 112 | 127 | shots := c.snapshot(now) | |
| 113 | 128 | mon.overviewKey(r.key) | |
| @@ -118,10 +133,6 @@ func (c *Core) repeatTick(ctx context.Context) error { | |||
| 118 | 133 | c.repeat.timer.Reset(repeatInterval) | |
| 119 | 134 | return c.publish(ctx) | |
| 120 | 135 | } | |
| 121 | - | if c.keyboard.inhibited(c.keyboardFocus()) { | |
| 122 | - | c.stopRepeat() | |
| 123 | - | return nil | |
| 124 | - | } | |
| 125 | 136 | c.repeat.timer.Reset(repeatInterval) | |
| 126 | 137 | return c.runBind(ctx, r.action) | |
| 127 | 138 | } | |
| @@ -28,7 +28,10 @@ type keyRepeat struct { | |||||
| 28 | // empty. | 28 | // empty. | ||
| 29 | action Action | 29 | action Action | ||
| 30 | key ports.KeyEvent | 30 | 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 | |||
| 32 | } | 35 | } | ||
| 33 | 36 | ||||
| 34 | // repeats reports whether a held key repeats bind a: moves only, never an | 37 | // 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.repeat | 72 | r := &c.repeat | ||
| 70 | r.held, r.mods, r.action, r.key = heldKey(key), key.Mods, action, key | 73 | 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 stops | 99 | + | // 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 a | 100 | + | // 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.repeat | 104 | 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 nil | 120 | return nil | ||
| 102 | } | 121 | } | ||
| 122 | + | r, mon := c.repeat, c.cur().mon | |||
| 103 | c.inputActive = true | 123 | 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 ( | |||
| 10 | 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 | 22 | // 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 { | |
| 16 | 25 | t.Helper() | |
| 17 | 26 | input := make(chan ports.InputEvent, 4) | |
| 18 | 27 | client := make(chan ports.ClientEvent, 8) | |
| @@ -34,7 +43,7 @@ func repeatRig(t *testing.T) (chan ports.InputEvent, chan ports.ConfigChanged, c | |||
| 34 | 43 | client <- ports.WindowMapped{ID: id} | |
| 35 | 44 | } | |
| 36 | 45 | synctest.Wait() | |
| 37 | - | return input, reload, commands, cfg | |
| 46 | + | return rig{input, client, reload, commands, cfg} | |
| 38 | 47 | } | |
| 39 | 48 | ||
| 40 | 49 | // 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 | 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 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. | |
| 59 | 67 | func TestHeldKeyRepeatsThroughRun(t *testing.T) { | |
| 60 | 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 | 71 | drain(commands) | |
| 63 | 72 | input <- cmdLeft | |
| 64 | 73 | synctest.Wait() | |
| @@ -71,14 +80,43 @@ func TestHeldKeyRepeatsThroughRun(t *testing.T) { | |||
| 71 | 80 | if focus, activity := drain(commands); focus < 2 || activity == 0 { | |
| 72 | 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 | + | } | |
| @@ -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 last | 22 | // 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, cfg | 46 | + | return rig{input, client, reload, commands, cfg} |
| 38 | } | 47 | } | ||
| 39 | 48 | ||||
| 40 | // drain counts the focus changes and activity reports sent since the last | 49 | // 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 reports | 65 | + | // 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 a | 66 | + | // 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 <- cmdLeft | 72 | 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) { | |||
| 156 | 156 | } | |
| 157 | 157 | sendKeys(t, c, key("Left", keyLeft, ports.ModSuper, false)) | |
| 158 | 158 | ||
| 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)) | |
| 165 | 173 | } | |
| 166 | 174 | } | |
| @@ -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 | } | ||