Merge pull request 'feat(input): repeat held move binds and overview keys' (#57) from feat/overview-key-repeat into main
Reviewed-on: https://git.bnema.dev/bnema/neferwl/pulls/57
10 files · +549 -5
M docs/config.md +1 -0
| @@ -159,6 +159,7 @@ A swipe moves one step at most. Other gestures (pinch, hold, two-finger scroll) | |||
| 159 | 159 | - Other keys use their xkb name (`wev` shows it). | |
| 160 | 160 | - `none` removes a default bind. | |
| 161 | 161 | - `spawn <command>` runs a program without a shell. See [Running commands](desktop.md#running-commands). | |
| 162 | + | - Holding a move bind repeats it: focus, move, resize and workspace steps, first after 250 ms, then 8 times a second. Toggles, spawns and closes run once. The overview's arrows and `h/j/k/l` repeat the same way. | |
| 162 | 163 | ||
| 163 | 164 | ```ini | |
| 164 | 165 | bind.XF86AudioRaiseVolume = spawn wpctl set-volume -l 1.0 @DEFAULT_AUDIO_SINK@ 5%+ | |
| @@ -159,6 +159,7 @@ A swipe moves one step at most. Other gestures (pinch, hold, two-finger scroll) | |||||
| 159 | - Other keys use their xkb name (`wev` shows it). | 159 | - Other keys use their xkb name (`wev` shows it). | ||
| 160 | - `none` removes a default bind. | 160 | - `none` removes a default bind. | ||
| 161 | - `spawn <command>` runs a program without a shell. See [Running commands](desktop.md#running-commands). | 161 | - `spawn <command>` runs a program without a shell. See [Running commands](desktop.md#running-commands). | ||
| 162 | + | - Holding a move bind repeats it: focus, move, resize and workspace steps, first after 250 ms, then 8 times a second. Toggles, spawns and closes run once. The overview's arrows and `h/j/k/l` repeat the same way. | |||
| 162 | 163 | ||||
| 163 | ```ini | 164 | ```ini | ||
| 164 | bind.XF86AudioRaiseVolume = spawn wpctl set-volume -l 1.0 @DEFAULT_AUDIO_SINK@ 5%+ | 165 | bind.XF86AudioRaiseVolume = spawn wpctl set-volume -l 1.0 @DEFAULT_AUDIO_SINK@ 5%+ | ||
M docs/headless.md +1 -0
| @@ -20,6 +20,7 @@ WAYLAND_DISPLAY=<name from the log> foot | |||
| 20 | 20 | |---|---| | |
| 21 | 21 | | `type <text>` | Type text | | |
| 22 | 22 | | `key Super+Return` | Press a key, with optional Shift, Ctrl, Alt or Super | | |
| 23 | + | | `hold Super+h 1s` | Press a key like `key`, hold it, then release it | | |
| 23 | 24 | | `sleep 1s` | Wait | | |
| 24 | 25 | | `move <x> <y>` | Move the pointer, in output coordinates | | |
| 25 | 26 | | `click [left\|right\|middle]`, `down <button>`, `up <button>` | Mouse buttons | | |
| @@ -20,6 +20,7 @@ WAYLAND_DISPLAY=<name from the log> foot | |||||
| 20 | |---|---| | 20 | |---|---| | ||
| 21 | | `type <text>` | Type text | | 21 | | `type <text>` | Type text | | ||
| 22 | | `key Super+Return` | Press a key, with optional Shift, Ctrl, Alt or Super | | 22 | | `key Super+Return` | Press a key, with optional Shift, Ctrl, Alt or Super | | ||
| 23 | + | | `hold Super+h 1s` | Press a key like `key`, hold it, then release it | | |||
| 23 | | `sleep 1s` | Wait | | 24 | | `sleep 1s` | Wait | | ||
| 24 | | `move <x> <y>` | Move the pointer, in output coordinates | | 25 | | `move <x> <y>` | Move the pointer, in output coordinates | | ||
| 25 | | `click [left\|right\|middle]`, `down <button>`, `up <button>` | Mouse buttons | | 26 | | `click [left\|right\|middle]`, `down <button>`, `up <button>` | Mouse buttons | | ||
M internal/adapters/headlessinput/input.go +29 -5
| @@ -81,7 +81,9 @@ func RunSecure(ctx context.Context, km *xkb.Keymap, keymaps <-chan *xkb.Keymap, | |||
| 81 | 81 | return nil | |
| 82 | 82 | } | |
| 83 | 83 | } | |
| 84 | - | stroke := func(name string, mods []uint32) error { | |
| 84 | + | // stroke presses mods then name, holds name for hold, and releases | |
| 85 | + | // them in reverse order. | |
| 86 | + | stroke := func(name string, mods []uint32, hold time.Duration) error { | |
| 85 | 87 | code, shift, ok := km.KeycodeFor(name) | |
| 86 | 88 | if !ok { | |
| 87 | 89 | warnInput("key", name, "unknown key") | |
| @@ -106,6 +108,15 @@ func RunSecure(ctx context.Context, km *xkb.Keymap, keymaps <-chan *xkb.Keymap, | |||
| 106 | 108 | if err := emit(code, true); err != nil { | |
| 107 | 109 | return err | |
| 108 | 110 | } | |
| 111 | + | if hold > 0 { | |
| 112 | + | timer := time.NewTimer(hold) | |
| 113 | + | select { | |
| 114 | + | case <-ctx.Done(): | |
| 115 | + | timer.Stop() | |
| 116 | + | return ctx.Err() | |
| 117 | + | case <-timer.C: | |
| 118 | + | } | |
| 119 | + | } | |
| 109 | 120 | if err := emit(code, false); err != nil { | |
| 110 | 121 | return err | |
| 111 | 122 | } | |
| @@ -227,12 +238,25 @@ func RunSecure(ctx context.Context, km *xkb.Keymap, keymaps <-chan *xkb.Keymap, | |||
| 227 | 238 | warnInput("rune", string(r), "unknown rune") | |
| 228 | 239 | continue | |
| 229 | 240 | } | |
| 230 | - | if err := stroke(name, nil); err != nil { | |
| 241 | + | if err := stroke(name, nil, 0); err != nil { | |
| 231 | 242 | return err | |
| 232 | 243 | } | |
| 233 | 244 | } | |
| 234 | - | case strings.HasPrefix(line, "key "): | |
| 235 | - | parts := strings.Split(strings.TrimPrefix(line, "key "), "+") | |
| 245 | + | case strings.HasPrefix(line, "key "), strings.HasPrefix(line, "hold "): | |
| 246 | + | combo, hold := strings.TrimPrefix(line, "key "), time.Duration(0) | |
| 247 | + | if rest, ok := strings.CutPrefix(line, "hold "); ok { | |
| 248 | + | f := strings.Fields(rest) | |
| 249 | + | var err error | |
| 250 | + | if len(f) == 2 { | |
| 251 | + | hold, err = time.ParseDuration(f[1]) | |
| 252 | + | } | |
| 253 | + | if len(f) != 2 || err != nil || hold <= 0 { | |
| 254 | + | warnInput("line", line, "invalid hold") | |
| 255 | + | continue | |
| 256 | + | } | |
| 257 | + | combo = f[0] | |
| 258 | + | } | |
| 259 | + | parts := strings.Split(combo, "+") | |
| 236 | 260 | var mods []uint32 | |
| 237 | 261 | valid := true | |
| 238 | 262 | for _, part := range parts[:len(parts)-1] { | |
| @@ -253,7 +277,7 @@ func RunSecure(ctx context.Context, km *xkb.Keymap, keymaps <-chan *xkb.Keymap, | |||
| 253 | 277 | warnInput("combo", line, "unknown modifier") | |
| 254 | 278 | continue | |
| 255 | 279 | } | |
| 256 | - | if err := stroke(parts[len(parts)-1], mods); err != nil { | |
| 280 | + | if err := stroke(parts[len(parts)-1], mods, hold); err != nil { | |
| 257 | 281 | return err | |
| 258 | 282 | } | |
| 259 | 283 | case strings.HasPrefix(line, "sleep "): | |
| @@ -81,7 +81,9 @@ func RunSecure(ctx context.Context, km *xkb.Keymap, keymaps <-chan *xkb.Keymap, | |||||
| 81 | return nil | 81 | return nil | ||
| 82 | } | 82 | } | ||
| 83 | } | 83 | } | ||
| 84 | - | stroke := func(name string, mods []uint32) error { | 84 | + | // stroke presses mods then name, holds name for hold, and releases |
| 85 | + | // them in reverse order. | |||
| 86 | + | stroke := func(name string, mods []uint32, hold time.Duration) error { | |||
| 85 | code, shift, ok := km.KeycodeFor(name) | 87 | code, shift, ok := km.KeycodeFor(name) | ||
| 86 | if !ok { | 88 | if !ok { | ||
| 87 | warnInput("key", name, "unknown key") | 89 | warnInput("key", name, "unknown key") | ||
| @@ -106,6 +108,15 @@ func RunSecure(ctx context.Context, km *xkb.Keymap, keymaps <-chan *xkb.Keymap, | |||||
| 106 | if err := emit(code, true); err != nil { | 108 | if err := emit(code, true); err != nil { | ||
| 107 | return err | 109 | return err | ||
| 108 | } | 110 | } | ||
| 111 | + | if hold > 0 { | |||
| 112 | + | timer := time.NewTimer(hold) | |||
| 113 | + | select { | |||
| 114 | + | case <-ctx.Done(): | |||
| 115 | + | timer.Stop() | |||
| 116 | + | return ctx.Err() | |||
| 117 | + | case <-timer.C: | |||
| 118 | + | } | |||
| 119 | + | } | |||
| 109 | if err := emit(code, false); err != nil { | 120 | if err := emit(code, false); err != nil { | ||
| 110 | return err | 121 | return err | ||
| 111 | } | 122 | } | ||
| @@ -227,12 +238,25 @@ func RunSecure(ctx context.Context, km *xkb.Keymap, keymaps <-chan *xkb.Keymap, | |||||
| 227 | warnInput("rune", string(r), "unknown rune") | 238 | warnInput("rune", string(r), "unknown rune") | ||
| 228 | continue | 239 | continue | ||
| 229 | } | 240 | } | ||
| 230 | - | if err := stroke(name, nil); err != nil { | 241 | + | if err := stroke(name, nil, 0); err != nil { |
| 231 | return err | 242 | return err | ||
| 232 | } | 243 | } | ||
| 233 | } | 244 | } | ||
| 234 | - | case strings.HasPrefix(line, "key "): | 245 | + | case strings.HasPrefix(line, "key "), strings.HasPrefix(line, "hold "): |
| 235 | - | parts := strings.Split(strings.TrimPrefix(line, "key "), "+") | 246 | + | combo, hold := strings.TrimPrefix(line, "key "), time.Duration(0) |
| 247 | + | if rest, ok := strings.CutPrefix(line, "hold "); ok { | |||
| 248 | + | f := strings.Fields(rest) | |||
| 249 | + | var err error | |||
| 250 | + | if len(f) == 2 { | |||
| 251 | + | hold, err = time.ParseDuration(f[1]) | |||
| 252 | + | } | |||
| 253 | + | if len(f) != 2 || err != nil || hold <= 0 { | |||
| 254 | + | warnInput("line", line, "invalid hold") | |||
| 255 | + | continue | |||
| 256 | + | } | |||
| 257 | + | combo = f[0] | |||
| 258 | + | } | |||
| 259 | + | parts := strings.Split(combo, "+") | |||
| 236 | var mods []uint32 | 260 | var mods []uint32 | ||
| 237 | valid := true | 261 | valid := true | ||
| 238 | for _, part := range parts[:len(parts)-1] { | 262 | for _, part := range parts[:len(parts)-1] { | ||
| @@ -253,7 +277,7 @@ func RunSecure(ctx context.Context, km *xkb.Keymap, keymaps <-chan *xkb.Keymap, | |||||
| 253 | warnInput("combo", line, "unknown modifier") | 277 | warnInput("combo", line, "unknown modifier") | ||
| 254 | continue | 278 | continue | ||
| 255 | } | 279 | } | ||
| 256 | - | if err := stroke(parts[len(parts)-1], mods); err != nil { | 280 | + | if err := stroke(parts[len(parts)-1], mods, hold); err != nil { |
| 257 | return err | 281 | return err | ||
| 258 | } | 282 | } | ||
| 259 | case strings.HasPrefix(line, "sleep "): | 283 | case strings.HasPrefix(line, "sleep "): | ||
M internal/adapters/headlessinput/input_test.go +46 -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" | |
| @@ -19,6 +21,8 @@ func TestScript(t *testing.T) { | |||
| 19 | 21 | {"type Hi!", []string{"Shift_L", "H", "H", "Shift_L", "i", "i", "Shift_L", "exclam", "exclam", "Shift_L"}, nil}, | |
| 20 | 22 | {"key Shift+A", []string{"Shift_L", "A", "A", "Shift_L"}, []uint32{42, 30, 30, 42}}, | |
| 21 | 23 | {"key Super+Return", []string{"Super_L", "Return", "Return", "Super_L"}, []uint32{125, 28, 28, 125}}, | |
| 24 | + | {"hold Super+h 20ms", []string{"Super_L", "h", "h", "Super_L"}, []uint32{125, 35, 35, 125}}, | |
| 25 | + | {"hold h", nil, nil}, | |
| 22 | 26 | } { | |
| 23 | 27 | t.Run(tt.line, func(t *testing.T) { | |
| 24 | 28 | km, err := xkb.New(xkb.RMLVO{Layout: "us"}) | |
| @@ -57,6 +61,48 @@ func TestScript(t *testing.T) { | |||
| 57 | 61 | } | |
| 58 | 62 | } | |
| 59 | 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 | + | ||
| 60 | 106 | func TestPointerScript(t *testing.T) { | |
| 61 | 107 | km, err := xkb.New(xkb.RMLVO{Layout: "us"}) | |
| 62 | 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" | ||
| @@ -19,6 +21,8 @@ func TestScript(t *testing.T) { | |||||
| 19 | {"type Hi!", []string{"Shift_L", "H", "H", "Shift_L", "i", "i", "Shift_L", "exclam", "exclam", "Shift_L"}, nil}, | 21 | {"type Hi!", []string{"Shift_L", "H", "H", "Shift_L", "i", "i", "Shift_L", "exclam", "exclam", "Shift_L"}, nil}, | ||
| 20 | {"key Shift+A", []string{"Shift_L", "A", "A", "Shift_L"}, []uint32{42, 30, 30, 42}}, | 22 | {"key Shift+A", []string{"Shift_L", "A", "A", "Shift_L"}, []uint32{42, 30, 30, 42}}, | ||
| 21 | {"key Super+Return", []string{"Super_L", "Return", "Return", "Super_L"}, []uint32{125, 28, 28, 125}}, | 23 | {"key Super+Return", []string{"Super_L", "Return", "Return", "Super_L"}, []uint32{125, 28, 28, 125}}, | ||
| 24 | + | {"hold Super+h 20ms", []string{"Super_L", "h", "h", "Super_L"}, []uint32{125, 35, 35, 125}}, | |||
| 25 | + | {"hold h", nil, nil}, | |||
| 22 | } { | 26 | } { | ||
| 23 | t.Run(tt.line, func(t *testing.T) { | 27 | t.Run(tt.line, func(t *testing.T) { | ||
| 24 | km, err := xkb.New(xkb.RMLVO{Layout: "us"}) | 28 | km, err := xkb.New(xkb.RMLVO{Layout: "us"}) | ||
| @@ -57,6 +61,48 @@ func TestScript(t *testing.T) { | |||||
| 57 | } | 61 | } | ||
| 58 | } | 62 | } | ||
| 59 | 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 | + | ||||
| 60 | func TestPointerScript(t *testing.T) { | 106 | func TestPointerScript(t *testing.T) { | ||
| 61 | km, err := xkb.New(xkb.RMLVO{Layout: "us"}) | 107 | km, err := xkb.New(xkb.RMLVO{Layout: "us"}) | ||
| 62 | if err != nil { | 108 | if err != nil { | ||
M internal/core/core.go +30 -0
| @@ -161,6 +161,10 @@ type Core struct { | |||
| 161 | 161 | timerC <-chan time.Time | |
| 162 | 162 | timerStop func() bool | |
| 163 | 163 | } | |
| 164 | + | // repeat is the held key that repeats a move (repeat.go); repeatC its | |
| 165 | + | // timer's channel, nil when no key repeats. | |
| 166 | + | repeat keyRepeat | |
| 167 | + | repeatC <-chan time.Time | |
| 164 | 168 | } | |
| 165 | 169 | ||
| 166 | 170 | // publishScratch is where publishState and publishWorkspaces build their | |
| @@ -1054,6 +1058,7 @@ func (c *Core) Run(ctx context.Context) error { | |||
| 1054 | 1058 | defer c.stopCaptureTimer() | |
| 1055 | 1059 | defer c.stopPulseTimer() | |
| 1056 | 1060 | defer c.cancelSwitcher() | |
| 1061 | + | defer c.stopRepeat() | |
| 1057 | 1062 | // Startup commands run once per session. Full launcher queues retain a | |
| 1058 | 1063 | // bounded remainder, selectable alongside owner events without polling. | |
| 1059 | 1064 | c.startup = make([][]string, len(c.cfg.Startup)) | |
| @@ -1137,6 +1142,28 @@ func (c *Core) Run(ctx context.Context) error { | |||
| 1137 | 1142 | return nil | |
| 1138 | 1143 | } | |
| 1139 | 1144 | continue | |
| 1145 | + | case <-c.repeatC: | |
| 1146 | + | if c.securityCheckpoint(ctx) != nil { | |
| 1147 | + | return nil | |
| 1148 | + | } | |
| 1149 | + | if !c.repeatLive() { | |
| 1150 | + | continue | |
| 1151 | + | } | |
| 1152 | + | // A held key is activity, as its press was: idle timers | |
| 1153 | + | // must not fire while it repeats. | |
| 1154 | + | if err := c.userActivity(ctx); err != nil { | |
| 1155 | + | return nil | |
| 1156 | + | } | |
| 1157 | + | if c.securityCheckpoint(ctx) != nil { | |
| 1158 | + | return nil | |
| 1159 | + | } | |
| 1160 | + | if err := c.repeatTick(ctx); err != nil { | |
| 1161 | + | if errors.Is(err, errSecurityChanged) { | |
| 1162 | + | continue | |
| 1163 | + | } | |
| 1164 | + | return err | |
| 1165 | + | } | |
| 1166 | + | continue | |
| 1140 | 1167 | case <-c.capt.timerC: | |
| 1141 | 1168 | c.capt.timerC, c.capt.timerStop = nil, nil | |
| 1142 | 1169 | if c.captureFlashTick() && c.publish(ctx) != nil { | |
| @@ -1181,6 +1208,9 @@ func (c *Core) Run(ctx context.Context) error { | |||
| 1181 | 1208 | if c.securityCheckpoint(ctx) != nil { | |
| 1182 | 1209 | return nil | |
| 1183 | 1210 | } | |
| 1211 | + | // A new keymap quarantines held keys: their release never | |
| 1212 | + | // comes, so no repeat may outlive a reload. | |
| 1213 | + | c.stopRepeat() | |
| 1184 | 1214 | if err := c.apply(ev.Config); err != nil { | |
| 1185 | 1215 | select { | |
| 1186 | 1216 | case c.ch.ConfigErrors <- err: | |
| @@ -161,6 +161,10 @@ type Core struct { | |||||
| 161 | timerC <-chan time.Time | 161 | timerC <-chan time.Time | ||
| 162 | timerStop func() bool | 162 | timerStop func() bool | ||
| 163 | } | 163 | } | ||
| 164 | + | // repeat is the held key that repeats a move (repeat.go); repeatC its | |||
| 165 | + | // timer's channel, nil when no key repeats. | |||
| 166 | + | repeat keyRepeat | |||
| 167 | + | repeatC <-chan time.Time | |||
| 164 | } | 168 | } | ||
| 165 | 169 | ||||
| 166 | // publishScratch is where publishState and publishWorkspaces build their | 170 | // publishScratch is where publishState and publishWorkspaces build their | ||
| @@ -1054,6 +1058,7 @@ func (c *Core) Run(ctx context.Context) error { | |||||
| 1054 | defer c.stopCaptureTimer() | 1058 | defer c.stopCaptureTimer() | ||
| 1055 | defer c.stopPulseTimer() | 1059 | defer c.stopPulseTimer() | ||
| 1056 | defer c.cancelSwitcher() | 1060 | defer c.cancelSwitcher() | ||
| 1061 | + | defer c.stopRepeat() | |||
| 1057 | // Startup commands run once per session. Full launcher queues retain a | 1062 | // Startup commands run once per session. Full launcher queues retain a | ||
| 1058 | // bounded remainder, selectable alongside owner events without polling. | 1063 | // bounded remainder, selectable alongside owner events without polling. | ||
| 1059 | c.startup = make([][]string, len(c.cfg.Startup)) | 1064 | c.startup = make([][]string, len(c.cfg.Startup)) | ||
| @@ -1137,6 +1142,28 @@ func (c *Core) Run(ctx context.Context) error { | |||||
| 1137 | return nil | 1142 | return nil | ||
| 1138 | } | 1143 | } | ||
| 1139 | continue | 1144 | continue | ||
| 1145 | + | case <-c.repeatC: | |||
| 1146 | + | if c.securityCheckpoint(ctx) != nil { | |||
| 1147 | + | return nil | |||
| 1148 | + | } | |||
| 1149 | + | if !c.repeatLive() { | |||
| 1150 | + | continue | |||
| 1151 | + | } | |||
| 1152 | + | // A held key is activity, as its press was: idle timers | |||
| 1153 | + | // must not fire while it repeats. | |||
| 1154 | + | if err := c.userActivity(ctx); err != nil { | |||
| 1155 | + | return nil | |||
| 1156 | + | } | |||
| 1157 | + | if c.securityCheckpoint(ctx) != nil { | |||
| 1158 | + | return nil | |||
| 1159 | + | } | |||
| 1160 | + | if err := c.repeatTick(ctx); err != nil { | |||
| 1161 | + | if errors.Is(err, errSecurityChanged) { | |||
| 1162 | + | continue | |||
| 1163 | + | } | |||
| 1164 | + | return err | |||
| 1165 | + | } | |||
| 1166 | + | continue | |||
| 1140 | case <-c.capt.timerC: | 1167 | case <-c.capt.timerC: | ||
| 1141 | c.capt.timerC, c.capt.timerStop = nil, nil | 1168 | c.capt.timerC, c.capt.timerStop = nil, nil | ||
| 1142 | if c.captureFlashTick() && c.publish(ctx) != nil { | 1169 | if c.captureFlashTick() && c.publish(ctx) != nil { | ||
| @@ -1181,6 +1208,9 @@ func (c *Core) Run(ctx context.Context) error { | |||||
| 1181 | if c.securityCheckpoint(ctx) != nil { | 1208 | if c.securityCheckpoint(ctx) != nil { | ||
| 1182 | return nil | 1209 | return nil | ||
| 1183 | } | 1210 | } | ||
| 1211 | + | // A new keymap quarantines held keys: their release never | |||
| 1212 | + | // comes, so no repeat may outlive a reload. | |||
| 1213 | + | c.stopRepeat() | |||
| 1184 | if err := c.apply(ev.Config); err != nil { | 1214 | if err := c.apply(ev.Config); err != nil { | ||
| 1185 | select { | 1215 | select { | ||
| 1186 | case c.ch.ConfigErrors <- err: | 1216 | case c.ch.ConfigErrors <- err: | ||
M internal/core/input.go +7 -0
| @@ -236,6 +236,7 @@ func (c *Core) pointerAxis(ctx context.Context, v ports.PointerAxis) error { | |||
| 236 | 236 | ||
| 237 | 237 | func (c *Core) keyEvent(ctx context.Context, key ports.KeyEvent) error { | |
| 238 | 238 | c.mods = key.Mods | |
| 239 | + | c.repeatKey(key) | |
| 239 | 240 | // Releasing the command key commits the switcher; the release still | |
| 240 | 241 | // goes on to the bind lookup and the clients. | |
| 241 | 242 | if c.switching() && c.mods&c.cmdMod == 0 { | |
| @@ -268,6 +269,9 @@ func (c *Core) keyEvent(ctx context.Context, key ports.KeyEvent) error { | |||
| 268 | 269 | } | |
| 269 | 270 | if key.Pressed && mon.overviewKey(key) { | |
| 270 | 271 | c.pressed[heldKey(key)] = true | |
| 272 | + | if mon.ov.open && overviewRepeats(key) { | |
| 273 | + | c.startRepeat(key, "") | |
| 274 | + | } | |
| 271 | 275 | c.transition(shots, now) | |
| 272 | 276 | if err := c.workspaceVisible(ctx, true); err != nil { | |
| 273 | 277 | return err | |
| @@ -315,6 +319,9 @@ func (c *Core) keyEvent(ctx context.Context, key ports.KeyEvent) error { | |||
| 315 | 319 | if key.Pressed { | |
| 316 | 320 | if bound { | |
| 317 | 321 | c.pressed[held] = true | |
| 322 | + | if repeats(action) { | |
| 323 | + | c.startRepeat(key, action) | |
| 324 | + | } | |
| 318 | 325 | return c.runBind(ctx, action) | |
| 319 | 326 | } | |
| 320 | 327 | c.pressed[held] = false | |
| @@ -236,6 +236,7 @@ func (c *Core) pointerAxis(ctx context.Context, v ports.PointerAxis) error { | |||||
| 236 | 236 | ||||
| 237 | func (c *Core) keyEvent(ctx context.Context, key ports.KeyEvent) error { | 237 | func (c *Core) keyEvent(ctx context.Context, key ports.KeyEvent) error { | ||
| 238 | c.mods = key.Mods | 238 | c.mods = key.Mods | ||
| 239 | + | c.repeatKey(key) | |||
| 239 | // Releasing the command key commits the switcher; the release still | 240 | // Releasing the command key commits the switcher; the release still | ||
| 240 | // goes on to the bind lookup and the clients. | 241 | // goes on to the bind lookup and the clients. | ||
| 241 | if c.switching() && c.mods&c.cmdMod == 0 { | 242 | if c.switching() && c.mods&c.cmdMod == 0 { | ||
| @@ -268,6 +269,9 @@ func (c *Core) keyEvent(ctx context.Context, key ports.KeyEvent) error { | |||||
| 268 | } | 269 | } | ||
| 269 | if key.Pressed && mon.overviewKey(key) { | 270 | if key.Pressed && mon.overviewKey(key) { | ||
| 270 | c.pressed[heldKey(key)] = true | 271 | c.pressed[heldKey(key)] = true | ||
| 272 | + | if mon.ov.open && overviewRepeats(key) { | |||
| 273 | + | c.startRepeat(key, "") | |||
| 274 | + | } | |||
| 271 | c.transition(shots, now) | 275 | c.transition(shots, now) | ||
| 272 | if err := c.workspaceVisible(ctx, true); err != nil { | 276 | if err := c.workspaceVisible(ctx, true); err != nil { | ||
| 273 | return err | 277 | return err | ||
| @@ -315,6 +319,9 @@ func (c *Core) keyEvent(ctx context.Context, key ports.KeyEvent) error { | |||||
| 315 | if key.Pressed { | 319 | if key.Pressed { | ||
| 316 | if bound { | 320 | if bound { | ||
| 317 | c.pressed[held] = true | 321 | c.pressed[held] = true | ||
| 322 | + | if repeats(action) { | |||
| 323 | + | c.startRepeat(key, action) | |||
| 324 | + | } | |||
| 318 | return c.runBind(ctx, action) | 325 | return c.runBind(ctx, action) | ||
| 319 | } | 326 | } | ||
| 320 | c.pressed[held] = false | 327 | c.pressed[held] = false | ||
A internal/core/repeat.go +138 -0
| @@ -0,0 +1,138 @@ | |||
| 1 | + | package core | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "time" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/neferwl/internal/ports" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | // A held key repeats the moves it runs in the compositor: a navigation bind | |
| 11 | + | // (focus, move, resize) or an overview key (h/j/k/l, arrows). Clients repeat | |
| 12 | + | // their own keys; these never reach a client. The pace is the compositor's, | |
| 13 | + | // slower than typing so each step can be followed: the first repeat after | |
| 14 | + | // repeatDelay, then one every repeatInterval. With animations on, each step | |
| 15 | + | // retargets the running motion, so a held key slides on smoothly. | |
| 16 | + | const ( | |
| 17 | + | repeatDelay = 250 * time.Millisecond | |
| 18 | + | repeatInterval = 125 * time.Millisecond | |
| 19 | + | ) | |
| 20 | + | ||
| 21 | + | // keyRepeat is the held key that repeats, if any. | |
| 22 | + | type keyRepeat struct { | |
| 23 | + | // held is the physical key (heldKey); mods the modifiers it was | |
| 24 | + | // pressed with: a change stops the repeat. | |
| 25 | + | held string | |
| 26 | + | mods ports.Mods | |
| 27 | + | // action is the bind it repeats; key the overview key, when action is | |
| 28 | + | // empty. | |
| 29 | + | action Action | |
| 30 | + | key ports.KeyEvent | |
| 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 | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | // repeats reports whether a held key repeats bind a: moves only, never an | |
| 38 | + | // action that toggles, opens, closes or spawns. | |
| 39 | + | func repeats(a Action) bool { | |
| 40 | + | if _, _, ok := ResizeArg(a); ok { | |
| 41 | + | return true | |
| 42 | + | } | |
| 43 | + | switch a { | |
| 44 | + | case ActionFocusColumnLeft, ActionFocusColumnRight, | |
| 45 | + | ActionFocusWindowUp, ActionFocusWindowDown, | |
| 46 | + | ActionMoveColumnLeft, ActionMoveColumnRight, | |
| 47 | + | ActionMoveWindowUp, ActionMoveWindowDown, | |
| 48 | + | ActionConsumeOrExpelLeft, ActionConsumeOrExpelRight, | |
| 49 | + | ActionFocusWorkspacePrev, ActionFocusWorkspaceNext, | |
| 50 | + | ActionMoveWorkspacePrev, ActionMoveWorkspaceNext, | |
| 51 | + | ActionMoveColumnToWorkspacePrev, ActionMoveColumnToWorkspaceNext, | |
| 52 | + | ActionMoveWindowToWorkspacePrev, ActionMoveWindowToWorkspaceNext: | |
| 53 | + | return true | |
| 54 | + | } | |
| 55 | + | return false | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | // overviewRepeats reports whether a held overview key repeats: the moves, | |
| 59 | + | // not Return or Escape. | |
| 60 | + | func overviewRepeats(key ports.KeyEvent) bool { | |
| 61 | + | switch keyName(key.Keysym) { | |
| 62 | + | case "h", "j", "k", "l", "Left", "Right", "Up", "Down": | |
| 63 | + | return true | |
| 64 | + | } | |
| 65 | + | return false | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | // startRepeat repeats action, or the overview key when action is empty, | |
| 69 | + | // while key stays held with its modifiers. | |
| 70 | + | func (c *Core) startRepeat(key ports.KeyEvent, action Action) { | |
| 71 | + | c.stopRepeat() | |
| 72 | + | r := &c.repeat | |
| 73 | + | r.held, r.mods, r.action, r.key = heldKey(key), key.Mods, action, key | |
| 74 | + | r.overview = c.cur().mon.ov.open | |
| 75 | + | r.timer = newPortTimer(c.opts.Clock, repeatDelay) | |
| 76 | + | c.repeatC = r.timer.C() | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | // stopRepeat ends the repeat, if any. | |
| 80 | + | func (c *Core) stopRepeat() { | |
| 81 | + | if c.repeat.timer != nil { | |
| 82 | + | c.repeat.timer.Stop() | |
| 83 | + | } | |
| 84 | + | c.repeat, c.repeatC = keyRepeat{}, nil | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | // repeatKey stops the repeat on a key event that ends it: another key | |
| 88 | + | // pressed, the held key released, or the modifiers changed. | |
| 89 | + | func (c *Core) repeatKey(key ports.KeyEvent) { | |
| 90 | + | r := &c.repeat | |
| 91 | + | if r.timer == nil { | |
| 92 | + | return | |
| 93 | + | } | |
| 94 | + | if key.Pressed || heldKey(key) == r.held || key.Mods != r.mods { | |
| 95 | + | c.stopRepeat() | |
| 96 | + | } | |
| 97 | + | } | |
| 98 | + | ||
| 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 { | |
| 104 | + | r := c.repeat | |
| 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() { | |
| 120 | + | return nil | |
| 121 | + | } | |
| 122 | + | r, mon := c.repeat, c.cur().mon | |
| 123 | + | c.inputActive = true | |
| 124 | + | defer func() { c.inputActive = false }() | |
| 125 | + | if r.action == "" { | |
| 126 | + | now := c.now() | |
| 127 | + | shots := c.snapshot(now) | |
| 128 | + | mon.overviewKey(r.key) | |
| 129 | + | c.transition(shots, now) | |
| 130 | + | if err := c.workspaceVisible(ctx, true); err != nil { | |
| 131 | + | return err | |
| 132 | + | } | |
| 133 | + | c.repeat.timer.Reset(repeatInterval) | |
| 134 | + | return c.publish(ctx) | |
| 135 | + | } | |
| 136 | + | c.repeat.timer.Reset(repeatInterval) | |
| 137 | + | return c.runBind(ctx, r.action) | |
| 138 | + | } | |
| @@ -0,0 +1,138 @@ | |||||
| 1 | + | package core | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "time" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | // A held key repeats the moves it runs in the compositor: a navigation bind | |||
| 11 | + | // (focus, move, resize) or an overview key (h/j/k/l, arrows). Clients repeat | |||
| 12 | + | // their own keys; these never reach a client. The pace is the compositor's, | |||
| 13 | + | // slower than typing so each step can be followed: the first repeat after | |||
| 14 | + | // repeatDelay, then one every repeatInterval. With animations on, each step | |||
| 15 | + | // retargets the running motion, so a held key slides on smoothly. | |||
| 16 | + | const ( | |||
| 17 | + | repeatDelay = 250 * time.Millisecond | |||
| 18 | + | repeatInterval = 125 * time.Millisecond | |||
| 19 | + | ) | |||
| 20 | + | ||||
| 21 | + | // keyRepeat is the held key that repeats, if any. | |||
| 22 | + | type keyRepeat struct { | |||
| 23 | + | // held is the physical key (heldKey); mods the modifiers it was | |||
| 24 | + | // pressed with: a change stops the repeat. | |||
| 25 | + | held string | |||
| 26 | + | mods ports.Mods | |||
| 27 | + | // action is the bind it repeats; key the overview key, when action is | |||
| 28 | + | // empty. | |||
| 29 | + | action Action | |||
| 30 | + | key ports.KeyEvent | |||
| 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 | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | // repeats reports whether a held key repeats bind a: moves only, never an | |||
| 38 | + | // action that toggles, opens, closes or spawns. | |||
| 39 | + | func repeats(a Action) bool { | |||
| 40 | + | if _, _, ok := ResizeArg(a); ok { | |||
| 41 | + | return true | |||
| 42 | + | } | |||
| 43 | + | switch a { | |||
| 44 | + | case ActionFocusColumnLeft, ActionFocusColumnRight, | |||
| 45 | + | ActionFocusWindowUp, ActionFocusWindowDown, | |||
| 46 | + | ActionMoveColumnLeft, ActionMoveColumnRight, | |||
| 47 | + | ActionMoveWindowUp, ActionMoveWindowDown, | |||
| 48 | + | ActionConsumeOrExpelLeft, ActionConsumeOrExpelRight, | |||
| 49 | + | ActionFocusWorkspacePrev, ActionFocusWorkspaceNext, | |||
| 50 | + | ActionMoveWorkspacePrev, ActionMoveWorkspaceNext, | |||
| 51 | + | ActionMoveColumnToWorkspacePrev, ActionMoveColumnToWorkspaceNext, | |||
| 52 | + | ActionMoveWindowToWorkspacePrev, ActionMoveWindowToWorkspaceNext: | |||
| 53 | + | return true | |||
| 54 | + | } | |||
| 55 | + | return false | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | // overviewRepeats reports whether a held overview key repeats: the moves, | |||
| 59 | + | // not Return or Escape. | |||
| 60 | + | func overviewRepeats(key ports.KeyEvent) bool { | |||
| 61 | + | switch keyName(key.Keysym) { | |||
| 62 | + | case "h", "j", "k", "l", "Left", "Right", "Up", "Down": | |||
| 63 | + | return true | |||
| 64 | + | } | |||
| 65 | + | return false | |||
| 66 | + | } | |||
| 67 | + | ||||
| 68 | + | // startRepeat repeats action, or the overview key when action is empty, | |||
| 69 | + | // while key stays held with its modifiers. | |||
| 70 | + | func (c *Core) startRepeat(key ports.KeyEvent, action Action) { | |||
| 71 | + | c.stopRepeat() | |||
| 72 | + | r := &c.repeat | |||
| 73 | + | r.held, r.mods, r.action, r.key = heldKey(key), key.Mods, action, key | |||
| 74 | + | r.overview = c.cur().mon.ov.open | |||
| 75 | + | r.timer = newPortTimer(c.opts.Clock, repeatDelay) | |||
| 76 | + | c.repeatC = r.timer.C() | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | // stopRepeat ends the repeat, if any. | |||
| 80 | + | func (c *Core) stopRepeat() { | |||
| 81 | + | if c.repeat.timer != nil { | |||
| 82 | + | c.repeat.timer.Stop() | |||
| 83 | + | } | |||
| 84 | + | c.repeat, c.repeatC = keyRepeat{}, nil | |||
| 85 | + | } | |||
| 86 | + | ||||
| 87 | + | // repeatKey stops the repeat on a key event that ends it: another key | |||
| 88 | + | // pressed, the held key released, or the modifiers changed. | |||
| 89 | + | func (c *Core) repeatKey(key ports.KeyEvent) { | |||
| 90 | + | r := &c.repeat | |||
| 91 | + | if r.timer == nil { | |||
| 92 | + | return | |||
| 93 | + | } | |||
| 94 | + | if key.Pressed || heldKey(key) == r.held || key.Mods != r.mods { | |||
| 95 | + | c.stopRepeat() | |||
| 96 | + | } | |||
| 97 | + | } | |||
| 98 | + | ||||
| 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 { | |||
| 104 | + | r := c.repeat | |||
| 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() { | |||
| 120 | + | return nil | |||
| 121 | + | } | |||
| 122 | + | r, mon := c.repeat, c.cur().mon | |||
| 123 | + | c.inputActive = true | |||
| 124 | + | defer func() { c.inputActive = false }() | |||
| 125 | + | if r.action == "" { | |||
| 126 | + | now := c.now() | |||
| 127 | + | shots := c.snapshot(now) | |||
| 128 | + | mon.overviewKey(r.key) | |||
| 129 | + | c.transition(shots, now) | |||
| 130 | + | if err := c.workspaceVisible(ctx, true); err != nil { | |||
| 131 | + | return err | |||
| 132 | + | } | |||
| 133 | + | c.repeat.timer.Reset(repeatInterval) | |||
| 134 | + | return c.publish(ctx) | |||
| 135 | + | } | |||
| 136 | + | c.repeat.timer.Reset(repeatInterval) | |||
| 137 | + | return c.runBind(ctx, r.action) | |||
| 138 | + | } | |||
A internal/core/repeat_run_test.go +122 -0
| @@ -0,0 +1,122 @@ | |||
| 1 | + | package core_test | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "testing" | |
| 6 | + | "testing/synctest" | |
| 7 | + | "time" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/neferwl/internal/core" | |
| 10 | + | "github.com/bnema/neferwl/internal/ports" | |
| 11 | + | ) | |
| 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 | + | ||
| 22 | + | // repeatRig runs a Core with five windows in a row on one output, the last | |
| 23 | + | // one focused. | |
| 24 | + | func repeatRig(t *testing.T) rig { | |
| 25 | + | t.Helper() | |
| 26 | + | input := make(chan ports.InputEvent, 4) | |
| 27 | + | client := make(chan ports.ClientEvent, 8) | |
| 28 | + | output := make(chan ports.OutputEvent, 1) | |
| 29 | + | reload := make(chan ports.ConfigChanged, 1) | |
| 30 | + | commands := make(chan ports.ClientCommand, 256) | |
| 31 | + | cfg := scrollDefaults() | |
| 32 | + | cfg.Layout.MaxColumns = 5 | |
| 33 | + | c, err := core.New(cfg, core.Channels{Input: input, Client: client, Output: output, Config: reload, ConfigErrors: make(chan error, 1), Commands: commands, Scenes: make(chan []ports.Scene, 1), Spawn: make(chan ports.SpawnRequest, 4)}, core.Options{}) | |
| 34 | + | if err != nil { | |
| 35 | + | t.Fatal(err) | |
| 36 | + | } | |
| 37 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 38 | + | done := make(chan error, 1) | |
| 39 | + | go func() { done <- c.Run(ctx) }() | |
| 40 | + | t.Cleanup(func() { cancel(); <-done }) | |
| 41 | + | output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 500, Height: 100}} | |
| 42 | + | for id := ports.WindowID(1); id <= 5; id++ { | |
| 43 | + | client <- ports.WindowMapped{ID: id} | |
| 44 | + | } | |
| 45 | + | synctest.Wait() | |
| 46 | + | return rig{input, client, reload, commands, cfg} | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | // drain counts the focus changes and activity reports sent since the last | |
| 50 | + | // drain. | |
| 51 | + | func drain(commands chan ports.ClientCommand) (focus, activity int) { | |
| 52 | + | for len(commands) > 0 { | |
| 53 | + | switch (<-commands).(type) { | |
| 54 | + | case ports.FocusWindow: | |
| 55 | + | focus++ | |
| 56 | + | case ports.UserActivity: | |
| 57 | + | activity++ | |
| 58 | + | } | |
| 59 | + | } | |
| 60 | + | return focus, activity | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | var cmdLeft = ports.KeyEvent{Keysym: "Left", Keycode: 113, Mods: ports.ModSuper, Pressed: true} | |
| 64 | + | ||
| 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. | |
| 67 | + | func TestHeldKeyRepeatsThroughRun(t *testing.T) { | |
| 68 | + | synctest.Test(t, func(t *testing.T) { | |
| 69 | + | r := repeatRig(t) | |
| 70 | + | input, commands := r.input, r.commands | |
| 71 | + | drain(commands) | |
| 72 | + | input <- cmdLeft | |
| 73 | + | synctest.Wait() | |
| 74 | + | if focus, _ := drain(commands); focus != 1 { | |
| 75 | + | t.Fatalf("press: %d focus changes, want 1", focus) | |
| 76 | + | } | |
| 77 | + | // Past the first repeat and the activity throttle. | |
| 78 | + | time.Sleep(time.Second) | |
| 79 | + | synctest.Wait() | |
| 80 | + | if focus, activity := drain(commands); focus < 2 || activity == 0 { | |
| 81 | + | t.Fatalf("held: %d focus changes, %d activity reports", focus, activity) | |
| 82 | + | } | |
| 83 | + | }) | |
| 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 | + | } | |
| @@ -0,0 +1,122 @@ | |||||
| 1 | + | package core_test | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "testing" | |||
| 6 | + | "testing/synctest" | |||
| 7 | + | "time" | |||
| 8 | + | ||||
| 9 | + | "github.com/bnema/neferwl/internal/core" | |||
| 10 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 11 | + | ) | |||
| 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 | + | ||||
| 22 | + | // repeatRig runs a Core with five windows in a row on one output, the last | |||
| 23 | + | // one focused. | |||
| 24 | + | func repeatRig(t *testing.T) rig { | |||
| 25 | + | t.Helper() | |||
| 26 | + | input := make(chan ports.InputEvent, 4) | |||
| 27 | + | client := make(chan ports.ClientEvent, 8) | |||
| 28 | + | output := make(chan ports.OutputEvent, 1) | |||
| 29 | + | reload := make(chan ports.ConfigChanged, 1) | |||
| 30 | + | commands := make(chan ports.ClientCommand, 256) | |||
| 31 | + | cfg := scrollDefaults() | |||
| 32 | + | cfg.Layout.MaxColumns = 5 | |||
| 33 | + | c, err := core.New(cfg, core.Channels{Input: input, Client: client, Output: output, Config: reload, ConfigErrors: make(chan error, 1), Commands: commands, Scenes: make(chan []ports.Scene, 1), Spawn: make(chan ports.SpawnRequest, 4)}, core.Options{}) | |||
| 34 | + | if err != nil { | |||
| 35 | + | t.Fatal(err) | |||
| 36 | + | } | |||
| 37 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 38 | + | done := make(chan error, 1) | |||
| 39 | + | go func() { done <- c.Run(ctx) }() | |||
| 40 | + | t.Cleanup(func() { cancel(); <-done }) | |||
| 41 | + | output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 500, Height: 100}} | |||
| 42 | + | for id := ports.WindowID(1); id <= 5; id++ { | |||
| 43 | + | client <- ports.WindowMapped{ID: id} | |||
| 44 | + | } | |||
| 45 | + | synctest.Wait() | |||
| 46 | + | return rig{input, client, reload, commands, cfg} | |||
| 47 | + | } | |||
| 48 | + | ||||
| 49 | + | // drain counts the focus changes and activity reports sent since the last | |||
| 50 | + | // drain. | |||
| 51 | + | func drain(commands chan ports.ClientCommand) (focus, activity int) { | |||
| 52 | + | for len(commands) > 0 { | |||
| 53 | + | switch (<-commands).(type) { | |||
| 54 | + | case ports.FocusWindow: | |||
| 55 | + | focus++ | |||
| 56 | + | case ports.UserActivity: | |||
| 57 | + | activity++ | |||
| 58 | + | } | |||
| 59 | + | } | |||
| 60 | + | return focus, activity | |||
| 61 | + | } | |||
| 62 | + | ||||
| 63 | + | var cmdLeft = ports.KeyEvent{Keysym: "Left", Keycode: 113, Mods: ports.ModSuper, Pressed: true} | |||
| 64 | + | ||||
| 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. | |||
| 67 | + | func TestHeldKeyRepeatsThroughRun(t *testing.T) { | |||
| 68 | + | synctest.Test(t, func(t *testing.T) { | |||
| 69 | + | r := repeatRig(t) | |||
| 70 | + | input, commands := r.input, r.commands | |||
| 71 | + | drain(commands) | |||
| 72 | + | input <- cmdLeft | |||
| 73 | + | synctest.Wait() | |||
| 74 | + | if focus, _ := drain(commands); focus != 1 { | |||
| 75 | + | t.Fatalf("press: %d focus changes, want 1", focus) | |||
| 76 | + | } | |||
| 77 | + | // Past the first repeat and the activity throttle. | |||
| 78 | + | time.Sleep(time.Second) | |||
| 79 | + | synctest.Wait() | |||
| 80 | + | if focus, activity := drain(commands); focus < 2 || activity == 0 { | |||
| 81 | + | t.Fatalf("held: %d focus changes, %d activity reports", focus, activity) | |||
| 82 | + | } | |||
| 83 | + | }) | |||
| 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 | + | } | |||
A internal/core/repeat_test.go +174 -0
| @@ -0,0 +1,174 @@ | |||
| 1 | + | package core | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/neferwl/internal/ports" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | const ( | |
| 10 | + | keyH = 43 | |
| 11 | + | keyLeft = 113 | |
| 12 | + | keyF = 41 | |
| 13 | + | keyShift = 50 | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | // repeatCore is a Core on a test clock with four columns, the last one | |
| 17 | + | // focused, and a few move and toggle binds. | |
| 18 | + | func repeatCore(t *testing.T) (*Core, *indicatorClock) { | |
| 19 | + | t.Helper() | |
| 20 | + | c, ic := indicatorCore(t) | |
| 21 | + | cfg := c.cfg | |
| 22 | + | cfg.Layout.MaxColumns = 4 | |
| 23 | + | cfg.Binds = map[string]string{ | |
| 24 | + | "Cmd+h": string(ActionFocusColumnLeft), | |
| 25 | + | "Cmd+l": string(ActionFocusColumnRight), | |
| 26 | + | "Cmd+Left": string(ActionFocusColumnLeft), | |
| 27 | + | "Cmd+f": string(ActionToggleFullscreen), | |
| 28 | + | "Cmd+o": string(ActionToggleOverview), | |
| 29 | + | "Cmd+Shift+h": string(ActionMoveColumnLeft), | |
| 30 | + | "Cmd+Ctrl+Left": string(ActionFocusWorkspacePrev), | |
| 31 | + | } | |
| 32 | + | if err := c.apply(cfg); err != nil { | |
| 33 | + | t.Fatal(err) | |
| 34 | + | } | |
| 35 | + | m := c.cur().mon | |
| 36 | + | for id := WindowID(1); id <= 4; id++ { | |
| 37 | + | m.AddWindow(id) | |
| 38 | + | } | |
| 39 | + | m.Current().FocusID(4) | |
| 40 | + | indicatorScene(t, c) | |
| 41 | + | return c, ic | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | func key(sym string, code uint32, mods ports.Mods, pressed bool) ports.KeyEvent { | |
| 45 | + | return ports.KeyEvent{Keysym: sym, Keycode: code, Mods: mods, Pressed: pressed} | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | // tick fires the repeat timer once, as Run does on its channel. | |
| 49 | + | func tick(t *testing.T, c *Core) { | |
| 50 | + | t.Helper() | |
| 51 | + | if c.repeatC == nil { | |
| 52 | + | t.Fatal("no repeat armed") | |
| 53 | + | } | |
| 54 | + | if err := c.repeatTick(t.Context()); err != nil { | |
| 55 | + | t.Fatal(err) | |
| 56 | + | } | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | // A held move bind repeats: the first repeat after repeatDelay, then one | |
| 60 | + | // per repeatInterval, until the key is released. | |
| 61 | + | func TestHeldBindRepeats(t *testing.T) { | |
| 62 | + | c, ic := repeatCore(t) | |
| 63 | + | before := len(ic.timers) | |
| 64 | + | sendKeys(t, c, key("h", keyH, ports.ModSuper, true)) | |
| 65 | + | if focusedID(c.cur().mon) != 3 { | |
| 66 | + | t.Fatalf("press: focus %d, want 3", focusedID(c.cur().mon)) | |
| 67 | + | } | |
| 68 | + | if len(ic.timers) != before+1 || ic.timers[len(ic.timers)-1] != repeatDelay { | |
| 69 | + | t.Fatalf("repeat timer %v, want %v", ic.timers[before:], repeatDelay) | |
| 70 | + | } | |
| 71 | + | tick(t, c) | |
| 72 | + | tick(t, c) | |
| 73 | + | if focusedID(c.cur().mon) != 1 { | |
| 74 | + | t.Fatalf("two repeats: focus %d, want 1", focusedID(c.cur().mon)) | |
| 75 | + | } | |
| 76 | + | if r := ic.resets[len(ic.resets)-1]; r != repeatInterval { | |
| 77 | + | t.Fatalf("next repeat in %v, want %v", r, repeatInterval) | |
| 78 | + | } | |
| 79 | + | sendKeys(t, c, key("h", keyH, ports.ModSuper, false)) | |
| 80 | + | if c.repeatC != nil || c.repeat.timer != nil { | |
| 81 | + | t.Fatal("release did not stop the repeat") | |
| 82 | + | } | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | // Only moves repeat: a held toggle runs once. | |
| 86 | + | func TestHeldToggleDoesNotRepeat(t *testing.T) { | |
| 87 | + | c, _ := repeatCore(t) | |
| 88 | + | sendKeys(t, c, key("f", keyF, ports.ModSuper, true)) | |
| 89 | + | if c.repeatC != nil { | |
| 90 | + | t.Fatal("toggle-fullscreen repeats") | |
| 91 | + | } | |
| 92 | + | for _, a := range []Action{ActionToggleFullscreen, ActionToggleOverview, ActionCloseWindow, ActionSpawnTerminal, ActionQuit, ActionToggleWindowStash, ActionMaximizeColumn} { | |
| 93 | + | if repeats(a) { | |
| 94 | + | t.Errorf("%s repeats", a) | |
| 95 | + | } | |
| 96 | + | } | |
| 97 | + | for _, a := range []Action{ActionFocusColumnLeft, ActionMoveColumnRight, ActionFocusWorkspaceNext, ActionMoveWindowToWorkspacePrev, "set-column-width +1"} { | |
| 98 | + | if !repeats(a) { | |
| 99 | + | t.Errorf("%s does not repeat", a) | |
| 100 | + | } | |
| 101 | + | } | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | // Another key pressed or the modifiers changed end the repeat: releasing | |
| 105 | + | // Cmd first does not leave the move running. | |
| 106 | + | func TestRepeatStopsOnKeyOrModifierChange(t *testing.T) { | |
| 107 | + | c, _ := repeatCore(t) | |
| 108 | + | sendKeys(t, c, key("h", keyH, ports.ModSuper, true)) | |
| 109 | + | sendKeys(t, c, key("Super_L", keySuper, 0, false)) | |
| 110 | + | if c.repeatC != nil { | |
| 111 | + | t.Fatal("releasing Cmd did not stop the repeat") | |
| 112 | + | } | |
| 113 | + | sendKeys(t, c, key("h", keyH, 0, false)) | |
| 114 | + | ||
| 115 | + | sendKeys(t, c, key("h", keyH, ports.ModSuper, true)) | |
| 116 | + | sendKeys(t, c, key("Shift_L", keyShift, ports.ModSuper|ports.ModShift, true)) | |
| 117 | + | if c.repeatC != nil { | |
| 118 | + | t.Fatal("pressing Shift did not stop the repeat") | |
| 119 | + | } | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | // In the overview, held h/j/k/l or arrows repeat without Cmd, and so do | |
| 123 | + | // the Cmd binds: holding Left walks the unfolded stash card by card. | |
| 124 | + | func TestOverviewHeldKeyRepeats(t *testing.T) { | |
| 125 | + | c, _ := repeatCore(t) | |
| 126 | + | m := c.cur().mon | |
| 127 | + | w := m.Current() | |
| 128 | + | w.Overflow = OverflowCascade | |
| 129 | + | for id := WindowID(40); id < 44; id++ { | |
| 130 | + | w.AddWindow(id) | |
| 131 | + | w.FocusID(id) | |
| 132 | + | w.ToggleWindowStash() | |
| 133 | + | } | |
| 134 | + | m.ToggleOverview() | |
| 135 | + | m.selectCard(w, 3) | |
| 136 | + | sendKeys(t, c, key("Left", keyLeft, 0, true)) | |
| 137 | + | if m.cardAt(w) != 2 { | |
| 138 | + | t.Fatalf("press: card %d, want 2", m.cardAt(w)) | |
| 139 | + | } | |
| 140 | + | tick(t, c) | |
| 141 | + | tick(t, c) | |
| 142 | + | if m.cardAt(w) != 0 { | |
| 143 | + | t.Fatalf("two repeats: card %d, want 0", m.cardAt(w)) | |
| 144 | + | } | |
| 145 | + | sendKeys(t, c, key("Left", keyLeft, 0, false)) | |
| 146 | + | if c.repeatC != nil { | |
| 147 | + | t.Fatal("release did not stop the repeat") | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | // Cmd+Left, a bind, repeats in the overview too. | |
| 151 | + | m.selectCard(w, 3) | |
| 152 | + | sendKeys(t, c, key("Left", keyLeft, ports.ModSuper, true)) | |
| 153 | + | tick(t, c) | |
| 154 | + | if m.cardAt(w) != 1 { | |
| 155 | + | t.Fatalf("Cmd+Left held: card %d, want 1", m.cardAt(w)) | |
| 156 | + | } | |
| 157 | + | sendKeys(t, c, key("Left", keyLeft, ports.ModSuper, false)) | |
| 158 | + | ||
| 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)) | |
| 173 | + | } | |
| 174 | + | } | |
| @@ -0,0 +1,174 @@ | |||||
| 1 | + | package core | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | const ( | |||
| 10 | + | keyH = 43 | |||
| 11 | + | keyLeft = 113 | |||
| 12 | + | keyF = 41 | |||
| 13 | + | keyShift = 50 | |||
| 14 | + | ) | |||
| 15 | + | ||||
| 16 | + | // repeatCore is a Core on a test clock with four columns, the last one | |||
| 17 | + | // focused, and a few move and toggle binds. | |||
| 18 | + | func repeatCore(t *testing.T) (*Core, *indicatorClock) { | |||
| 19 | + | t.Helper() | |||
| 20 | + | c, ic := indicatorCore(t) | |||
| 21 | + | cfg := c.cfg | |||
| 22 | + | cfg.Layout.MaxColumns = 4 | |||
| 23 | + | cfg.Binds = map[string]string{ | |||
| 24 | + | "Cmd+h": string(ActionFocusColumnLeft), | |||
| 25 | + | "Cmd+l": string(ActionFocusColumnRight), | |||
| 26 | + | "Cmd+Left": string(ActionFocusColumnLeft), | |||
| 27 | + | "Cmd+f": string(ActionToggleFullscreen), | |||
| 28 | + | "Cmd+o": string(ActionToggleOverview), | |||
| 29 | + | "Cmd+Shift+h": string(ActionMoveColumnLeft), | |||
| 30 | + | "Cmd+Ctrl+Left": string(ActionFocusWorkspacePrev), | |||
| 31 | + | } | |||
| 32 | + | if err := c.apply(cfg); err != nil { | |||
| 33 | + | t.Fatal(err) | |||
| 34 | + | } | |||
| 35 | + | m := c.cur().mon | |||
| 36 | + | for id := WindowID(1); id <= 4; id++ { | |||
| 37 | + | m.AddWindow(id) | |||
| 38 | + | } | |||
| 39 | + | m.Current().FocusID(4) | |||
| 40 | + | indicatorScene(t, c) | |||
| 41 | + | return c, ic | |||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | func key(sym string, code uint32, mods ports.Mods, pressed bool) ports.KeyEvent { | |||
| 45 | + | return ports.KeyEvent{Keysym: sym, Keycode: code, Mods: mods, Pressed: pressed} | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | // tick fires the repeat timer once, as Run does on its channel. | |||
| 49 | + | func tick(t *testing.T, c *Core) { | |||
| 50 | + | t.Helper() | |||
| 51 | + | if c.repeatC == nil { | |||
| 52 | + | t.Fatal("no repeat armed") | |||
| 53 | + | } | |||
| 54 | + | if err := c.repeatTick(t.Context()); err != nil { | |||
| 55 | + | t.Fatal(err) | |||
| 56 | + | } | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | // A held move bind repeats: the first repeat after repeatDelay, then one | |||
| 60 | + | // per repeatInterval, until the key is released. | |||
| 61 | + | func TestHeldBindRepeats(t *testing.T) { | |||
| 62 | + | c, ic := repeatCore(t) | |||
| 63 | + | before := len(ic.timers) | |||
| 64 | + | sendKeys(t, c, key("h", keyH, ports.ModSuper, true)) | |||
| 65 | + | if focusedID(c.cur().mon) != 3 { | |||
| 66 | + | t.Fatalf("press: focus %d, want 3", focusedID(c.cur().mon)) | |||
| 67 | + | } | |||
| 68 | + | if len(ic.timers) != before+1 || ic.timers[len(ic.timers)-1] != repeatDelay { | |||
| 69 | + | t.Fatalf("repeat timer %v, want %v", ic.timers[before:], repeatDelay) | |||
| 70 | + | } | |||
| 71 | + | tick(t, c) | |||
| 72 | + | tick(t, c) | |||
| 73 | + | if focusedID(c.cur().mon) != 1 { | |||
| 74 | + | t.Fatalf("two repeats: focus %d, want 1", focusedID(c.cur().mon)) | |||
| 75 | + | } | |||
| 76 | + | if r := ic.resets[len(ic.resets)-1]; r != repeatInterval { | |||
| 77 | + | t.Fatalf("next repeat in %v, want %v", r, repeatInterval) | |||
| 78 | + | } | |||
| 79 | + | sendKeys(t, c, key("h", keyH, ports.ModSuper, false)) | |||
| 80 | + | if c.repeatC != nil || c.repeat.timer != nil { | |||
| 81 | + | t.Fatal("release did not stop the repeat") | |||
| 82 | + | } | |||
| 83 | + | } | |||
| 84 | + | ||||
| 85 | + | // Only moves repeat: a held toggle runs once. | |||
| 86 | + | func TestHeldToggleDoesNotRepeat(t *testing.T) { | |||
| 87 | + | c, _ := repeatCore(t) | |||
| 88 | + | sendKeys(t, c, key("f", keyF, ports.ModSuper, true)) | |||
| 89 | + | if c.repeatC != nil { | |||
| 90 | + | t.Fatal("toggle-fullscreen repeats") | |||
| 91 | + | } | |||
| 92 | + | for _, a := range []Action{ActionToggleFullscreen, ActionToggleOverview, ActionCloseWindow, ActionSpawnTerminal, ActionQuit, ActionToggleWindowStash, ActionMaximizeColumn} { | |||
| 93 | + | if repeats(a) { | |||
| 94 | + | t.Errorf("%s repeats", a) | |||
| 95 | + | } | |||
| 96 | + | } | |||
| 97 | + | for _, a := range []Action{ActionFocusColumnLeft, ActionMoveColumnRight, ActionFocusWorkspaceNext, ActionMoveWindowToWorkspacePrev, "set-column-width +1"} { | |||
| 98 | + | if !repeats(a) { | |||
| 99 | + | t.Errorf("%s does not repeat", a) | |||
| 100 | + | } | |||
| 101 | + | } | |||
| 102 | + | } | |||
| 103 | + | ||||
| 104 | + | // Another key pressed or the modifiers changed end the repeat: releasing | |||
| 105 | + | // Cmd first does not leave the move running. | |||
| 106 | + | func TestRepeatStopsOnKeyOrModifierChange(t *testing.T) { | |||
| 107 | + | c, _ := repeatCore(t) | |||
| 108 | + | sendKeys(t, c, key("h", keyH, ports.ModSuper, true)) | |||
| 109 | + | sendKeys(t, c, key("Super_L", keySuper, 0, false)) | |||
| 110 | + | if c.repeatC != nil { | |||
| 111 | + | t.Fatal("releasing Cmd did not stop the repeat") | |||
| 112 | + | } | |||
| 113 | + | sendKeys(t, c, key("h", keyH, 0, false)) | |||
| 114 | + | ||||
| 115 | + | sendKeys(t, c, key("h", keyH, ports.ModSuper, true)) | |||
| 116 | + | sendKeys(t, c, key("Shift_L", keyShift, ports.ModSuper|ports.ModShift, true)) | |||
| 117 | + | if c.repeatC != nil { | |||
| 118 | + | t.Fatal("pressing Shift did not stop the repeat") | |||
| 119 | + | } | |||
| 120 | + | } | |||
| 121 | + | ||||
| 122 | + | // In the overview, held h/j/k/l or arrows repeat without Cmd, and so do | |||
| 123 | + | // the Cmd binds: holding Left walks the unfolded stash card by card. | |||
| 124 | + | func TestOverviewHeldKeyRepeats(t *testing.T) { | |||
| 125 | + | c, _ := repeatCore(t) | |||
| 126 | + | m := c.cur().mon | |||
| 127 | + | w := m.Current() | |||
| 128 | + | w.Overflow = OverflowCascade | |||
| 129 | + | for id := WindowID(40); id < 44; id++ { | |||
| 130 | + | w.AddWindow(id) | |||
| 131 | + | w.FocusID(id) | |||
| 132 | + | w.ToggleWindowStash() | |||
| 133 | + | } | |||
| 134 | + | m.ToggleOverview() | |||
| 135 | + | m.selectCard(w, 3) | |||
| 136 | + | sendKeys(t, c, key("Left", keyLeft, 0, true)) | |||
| 137 | + | if m.cardAt(w) != 2 { | |||
| 138 | + | t.Fatalf("press: card %d, want 2", m.cardAt(w)) | |||
| 139 | + | } | |||
| 140 | + | tick(t, c) | |||
| 141 | + | tick(t, c) | |||
| 142 | + | if m.cardAt(w) != 0 { | |||
| 143 | + | t.Fatalf("two repeats: card %d, want 0", m.cardAt(w)) | |||
| 144 | + | } | |||
| 145 | + | sendKeys(t, c, key("Left", keyLeft, 0, false)) | |||
| 146 | + | if c.repeatC != nil { | |||
| 147 | + | t.Fatal("release did not stop the repeat") | |||
| 148 | + | } | |||
| 149 | + | ||||
| 150 | + | // Cmd+Left, a bind, repeats in the overview too. | |||
| 151 | + | m.selectCard(w, 3) | |||
| 152 | + | sendKeys(t, c, key("Left", keyLeft, ports.ModSuper, true)) | |||
| 153 | + | tick(t, c) | |||
| 154 | + | if m.cardAt(w) != 1 { | |||
| 155 | + | t.Fatalf("Cmd+Left held: card %d, want 1", m.cardAt(w)) | |||
| 156 | + | } | |||
| 157 | + | sendKeys(t, c, key("Left", keyLeft, ports.ModSuper, false)) | |||
| 158 | + | ||||
| 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)) | |||
| 173 | + | } | |||
| 174 | + | } | |||
M internal/core/security.go +1 -0
| @@ -47,6 +47,7 @@ func (c *Core) syncSecurity() bool { | |||
| 47 | 47 | c.stopAnimations() | |
| 48 | 48 | c.stopPulse() | |
| 49 | 49 | c.cancelSwitcher() | |
| 50 | + | c.stopRepeat() | |
| 50 | 51 | if state.Protected { | |
| 51 | 52 | c.dropCaptureSessions() | |
| 52 | 53 | } | |
| @@ -47,6 +47,7 @@ func (c *Core) syncSecurity() bool { | |||||
| 47 | c.stopAnimations() | 47 | c.stopAnimations() | ||
| 48 | c.stopPulse() | 48 | c.stopPulse() | ||
| 49 | c.cancelSwitcher() | 49 | c.cancelSwitcher() | ||
| 50 | + | c.stopRepeat() | |||
| 50 | if state.Protected { | 51 | if state.Protected { | ||
| 51 | c.dropCaptureSessions() | 52 | c.dropCaptureSessions() | ||
| 52 | } | 53 | } | ||