feat(input): repeat held move binds and overview keys
A bind the compositor runs never reaches a client, so nothing repeated it: holding cmd+h or Left in the overview moved once. A held move bind (focus, move, resize, workspace steps) and a held overview arrow or h/j/k/l now repeat after 250 ms, 8 times a second, on a core timer. Toggles, spawns and closes still run once. The repeat stops on release, another key, a modifier change, the overview closing, a launcher taking the keyboard, a shortcuts inhibitor or a security change. Headless scripts gain hold <combo> <duration> to test held keys.
- commit
- 9e93199b92b2c369705ae70f6cfa51c9995664d7
- author
- brice · · 2026-10-15 05:57 UTC
- parent
- f8b3a18
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
9 files · +350 -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 +2 -0
| @@ -19,6 +19,8 @@ func TestScript(t *testing.T) { | |||
| 19 | 19 | {"type Hi!", []string{"Shift_L", "H", "H", "Shift_L", "i", "i", "Shift_L", "exclam", "exclam", "Shift_L"}, nil}, | |
| 20 | 20 | {"key Shift+A", []string{"Shift_L", "A", "A", "Shift_L"}, []uint32{42, 30, 30, 42}}, | |
| 21 | 21 | {"key Super+Return", []string{"Super_L", "Return", "Return", "Super_L"}, []uint32{125, 28, 28, 125}}, | |
| 22 | + | {"hold Super+h 20ms", []string{"Super_L", "h", "h", "Super_L"}, []uint32{125, 35, 35, 125}}, | |
| 23 | + | {"hold h", nil, nil}, | |
| 22 | 24 | } { | |
| 23 | 25 | t.Run(tt.line, func(t *testing.T) { | |
| 24 | 26 | km, err := xkb.New(xkb.RMLVO{Layout: "us"}) | |
| @@ -19,6 +19,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}, | 19 | {"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}}, | 20 | {"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}}, | 21 | {"key Super+Return", []string{"Super_L", "Return", "Return", "Super_L"}, []uint32{125, 28, 28, 125}}, | ||
| 22 | + | {"hold Super+h 20ms", []string{"Super_L", "h", "h", "Super_L"}, []uint32{125, 35, 35, 125}}, | |||
| 23 | + | {"hold h", nil, nil}, | |||
| 22 | } { | 24 | } { | ||
| 23 | t.Run(tt.line, func(t *testing.T) { | 25 | t.Run(tt.line, func(t *testing.T) { | ||
| 24 | km, err := xkb.New(xkb.RMLVO{Layout: "us"}) | 26 | km, err := xkb.New(xkb.RMLVO{Layout: "us"}) | ||
M internal/core/core.go +16 -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,17 @@ 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 err := c.repeatTick(ctx); err != nil { | |
| 1150 | + | if errors.Is(err, errSecurityChanged) { | |
| 1151 | + | continue | |
| 1152 | + | } | |
| 1153 | + | return err | |
| 1154 | + | } | |
| 1155 | + | continue | |
| 1140 | 1156 | case <-c.capt.timerC: | |
| 1141 | 1157 | c.capt.timerC, c.capt.timerStop = nil, nil | |
| 1142 | 1158 | if c.captureFlashTick() && c.publish(ctx) != nil { | |
| @@ -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,17 @@ 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 err := c.repeatTick(ctx); err != nil { | |||
| 1150 | + | if errors.Is(err, errSecurityChanged) { | |||
| 1151 | + | continue | |||
| 1152 | + | } | |||
| 1153 | + | return err | |||
| 1154 | + | } | |||
| 1155 | + | continue | |||
| 1140 | case <-c.capt.timerC: | 1156 | case <-c.capt.timerC: | ||
| 1141 | c.capt.timerC, c.capt.timerStop = nil, nil | 1157 | c.capt.timerC, c.capt.timerStop = nil, nil | ||
| 1142 | if c.captureFlashTick() && c.publish(ctx) != nil { | 1158 | if c.captureFlashTick() && c.publish(ctx) != nil { | ||
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 +127 -0
| @@ -0,0 +1,127 @@ | |||
| 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 | + | timer ports.Timer | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | // repeats reports whether a held key repeats bind a: moves only, never an | |
| 35 | + | // action that toggles, opens, closes or spawns. | |
| 36 | + | func repeats(a Action) bool { | |
| 37 | + | if _, _, ok := ResizeArg(a); ok { | |
| 38 | + | return true | |
| 39 | + | } | |
| 40 | + | switch a { | |
| 41 | + | case ActionFocusColumnLeft, ActionFocusColumnRight, | |
| 42 | + | ActionFocusWindowUp, ActionFocusWindowDown, | |
| 43 | + | ActionMoveColumnLeft, ActionMoveColumnRight, | |
| 44 | + | ActionMoveWindowUp, ActionMoveWindowDown, | |
| 45 | + | ActionConsumeOrExpelLeft, ActionConsumeOrExpelRight, | |
| 46 | + | ActionFocusWorkspacePrev, ActionFocusWorkspaceNext, | |
| 47 | + | ActionMoveWorkspacePrev, ActionMoveWorkspaceNext, | |
| 48 | + | ActionMoveColumnToWorkspacePrev, ActionMoveColumnToWorkspaceNext, | |
| 49 | + | ActionMoveWindowToWorkspacePrev, ActionMoveWindowToWorkspaceNext: | |
| 50 | + | return true | |
| 51 | + | } | |
| 52 | + | return false | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | // overviewRepeats reports whether a held overview key repeats: the moves, | |
| 56 | + | // not Return or Escape. | |
| 57 | + | func overviewRepeats(key ports.KeyEvent) bool { | |
| 58 | + | switch keyName(key.Keysym) { | |
| 59 | + | case "h", "j", "k", "l", "Left", "Right", "Up", "Down": | |
| 60 | + | return true | |
| 61 | + | } | |
| 62 | + | return false | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | // startRepeat repeats action, or the overview key when action is empty, | |
| 66 | + | // while key stays held with its modifiers. | |
| 67 | + | func (c *Core) startRepeat(key ports.KeyEvent, action Action) { | |
| 68 | + | c.stopRepeat() | |
| 69 | + | r := &c.repeat | |
| 70 | + | r.held, r.mods, r.action, r.key = heldKey(key), key.Mods, action, key | |
| 71 | + | r.timer = newPortTimer(c.opts.Clock, repeatDelay) | |
| 72 | + | c.repeatC = r.timer.C() | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | // stopRepeat ends the repeat, if any. | |
| 76 | + | func (c *Core) stopRepeat() { | |
| 77 | + | if c.repeat.timer != nil { | |
| 78 | + | c.repeat.timer.Stop() | |
| 79 | + | } | |
| 80 | + | c.repeat, c.repeatC = keyRepeat{}, nil | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | // repeatKey stops the repeat on a key event that ends it: another key | |
| 84 | + | // pressed, the held key released, or the modifiers changed. | |
| 85 | + | func (c *Core) repeatKey(key ports.KeyEvent) { | |
| 86 | + | r := &c.repeat | |
| 87 | + | if r.timer == nil { | |
| 88 | + | return | |
| 89 | + | } | |
| 90 | + | if key.Pressed || heldKey(key) == r.held || key.Mods != r.mods { | |
| 91 | + | c.stopRepeat() | |
| 92 | + | } | |
| 93 | + | } | |
| 94 | + | ||
| 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 | + | r := c.repeat | |
| 100 | + | if r.timer == nil { | |
| 101 | + | return nil | |
| 102 | + | } | |
| 103 | + | c.inputActive = true | |
| 104 | + | defer func() { c.inputActive = false }() | |
| 105 | + | 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() | |
| 112 | + | shots := c.snapshot(now) | |
| 113 | + | mon.overviewKey(r.key) | |
| 114 | + | c.transition(shots, now) | |
| 115 | + | if err := c.workspaceVisible(ctx, true); err != nil { | |
| 116 | + | return err | |
| 117 | + | } | |
| 118 | + | c.repeat.timer.Reset(repeatInterval) | |
| 119 | + | return c.publish(ctx) | |
| 120 | + | } | |
| 121 | + | if c.keyboard.inhibited(c.keyboardFocus()) { | |
| 122 | + | c.stopRepeat() | |
| 123 | + | return nil | |
| 124 | + | } | |
| 125 | + | c.repeat.timer.Reset(repeatInterval) | |
| 126 | + | return c.runBind(ctx, r.action) | |
| 127 | + | } | |
| @@ -0,0 +1,127 @@ | |||||
| 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 | + | timer ports.Timer | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | // repeats reports whether a held key repeats bind a: moves only, never an | |||
| 35 | + | // action that toggles, opens, closes or spawns. | |||
| 36 | + | func repeats(a Action) bool { | |||
| 37 | + | if _, _, ok := ResizeArg(a); ok { | |||
| 38 | + | return true | |||
| 39 | + | } | |||
| 40 | + | switch a { | |||
| 41 | + | case ActionFocusColumnLeft, ActionFocusColumnRight, | |||
| 42 | + | ActionFocusWindowUp, ActionFocusWindowDown, | |||
| 43 | + | ActionMoveColumnLeft, ActionMoveColumnRight, | |||
| 44 | + | ActionMoveWindowUp, ActionMoveWindowDown, | |||
| 45 | + | ActionConsumeOrExpelLeft, ActionConsumeOrExpelRight, | |||
| 46 | + | ActionFocusWorkspacePrev, ActionFocusWorkspaceNext, | |||
| 47 | + | ActionMoveWorkspacePrev, ActionMoveWorkspaceNext, | |||
| 48 | + | ActionMoveColumnToWorkspacePrev, ActionMoveColumnToWorkspaceNext, | |||
| 49 | + | ActionMoveWindowToWorkspacePrev, ActionMoveWindowToWorkspaceNext: | |||
| 50 | + | return true | |||
| 51 | + | } | |||
| 52 | + | return false | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | // overviewRepeats reports whether a held overview key repeats: the moves, | |||
| 56 | + | // not Return or Escape. | |||
| 57 | + | func overviewRepeats(key ports.KeyEvent) bool { | |||
| 58 | + | switch keyName(key.Keysym) { | |||
| 59 | + | case "h", "j", "k", "l", "Left", "Right", "Up", "Down": | |||
| 60 | + | return true | |||
| 61 | + | } | |||
| 62 | + | return false | |||
| 63 | + | } | |||
| 64 | + | ||||
| 65 | + | // startRepeat repeats action, or the overview key when action is empty, | |||
| 66 | + | // while key stays held with its modifiers. | |||
| 67 | + | func (c *Core) startRepeat(key ports.KeyEvent, action Action) { | |||
| 68 | + | c.stopRepeat() | |||
| 69 | + | r := &c.repeat | |||
| 70 | + | r.held, r.mods, r.action, r.key = heldKey(key), key.Mods, action, key | |||
| 71 | + | r.timer = newPortTimer(c.opts.Clock, repeatDelay) | |||
| 72 | + | c.repeatC = r.timer.C() | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | // stopRepeat ends the repeat, if any. | |||
| 76 | + | func (c *Core) stopRepeat() { | |||
| 77 | + | if c.repeat.timer != nil { | |||
| 78 | + | c.repeat.timer.Stop() | |||
| 79 | + | } | |||
| 80 | + | c.repeat, c.repeatC = keyRepeat{}, nil | |||
| 81 | + | } | |||
| 82 | + | ||||
| 83 | + | // repeatKey stops the repeat on a key event that ends it: another key | |||
| 84 | + | // pressed, the held key released, or the modifiers changed. | |||
| 85 | + | func (c *Core) repeatKey(key ports.KeyEvent) { | |||
| 86 | + | r := &c.repeat | |||
| 87 | + | if r.timer == nil { | |||
| 88 | + | return | |||
| 89 | + | } | |||
| 90 | + | if key.Pressed || heldKey(key) == r.held || key.Mods != r.mods { | |||
| 91 | + | c.stopRepeat() | |||
| 92 | + | } | |||
| 93 | + | } | |||
| 94 | + | ||||
| 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 | + | r := c.repeat | |||
| 100 | + | if r.timer == nil { | |||
| 101 | + | return nil | |||
| 102 | + | } | |||
| 103 | + | c.inputActive = true | |||
| 104 | + | defer func() { c.inputActive = false }() | |||
| 105 | + | 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() | |||
| 112 | + | shots := c.snapshot(now) | |||
| 113 | + | mon.overviewKey(r.key) | |||
| 114 | + | c.transition(shots, now) | |||
| 115 | + | if err := c.workspaceVisible(ctx, true); err != nil { | |||
| 116 | + | return err | |||
| 117 | + | } | |||
| 118 | + | c.repeat.timer.Reset(repeatInterval) | |||
| 119 | + | return c.publish(ctx) | |||
| 120 | + | } | |||
| 121 | + | if c.keyboard.inhibited(c.keyboardFocus()) { | |||
| 122 | + | c.stopRepeat() | |||
| 123 | + | return nil | |||
| 124 | + | } | |||
| 125 | + | c.repeat.timer.Reset(repeatInterval) | |||
| 126 | + | return c.runBind(ctx, r.action) | |||
| 127 | + | } | |||
A internal/core/repeat_test.go +166 -0
| @@ -0,0 +1,166 @@ | |||
| 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. | |
| 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") | |
| 165 | + | } | |
| 166 | + | } | |
| @@ -0,0 +1,166 @@ | |||||
| 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. | |||
| 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") | |||
| 165 | + | } | |||
| 166 | + | } | |||
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 | } | ||