detached af5dc26

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
commit
af5dc26bfe6291e580fcb43a88ebaf516377985e
author
bnema · · 2026-10-15 08:06 UTC
merge of
f8b3a18 + 5d664d1 · diff against the first parent
signature
SSH UnverifiedSHA256:VOCA5lYARHcbKDZ7GhTV45VmcjWnJ6ZQIBazj17LivA The key is unknown
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)
159159 - Other keys use their xkb name (`wev` shows it).
160160 - `none` removes a default bind.
161161 - `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.
162163
163164 ```ini
164165 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 ```ini164 ```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
2020 |---|---|
2121 | `type <text>` | Type text |
2222 | `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 |
2324 | `sleep 1s` | Wait |
2425 | `move <x> <y>` | Move the pointer, in output coordinates |
2526 | `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,
8181 return nil
8282 }
8383 }
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 {
8587 code, shift, ok := km.KeycodeFor(name)
8688 if !ok {
8789 warnInput("key", name, "unknown key")
@@ -106,6 +108,15 @@ func RunSecure(ctx context.Context, km *xkb.Keymap, keymaps <-chan *xkb.Keymap,
106108 if err := emit(code, true); err != nil {
107109 return err
108110 }
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+ }
109120 if err := emit(code, false); err != nil {
110121 return err
111122 }
@@ -227,12 +238,25 @@ func RunSecure(ctx context.Context, km *xkb.Keymap, keymaps <-chan *xkb.Keymap,
227238 warnInput("rune", string(r), "unknown rune")
228239 continue
229240 }
230- if err := stroke(name, nil); err != nil {
241+ if err := stroke(name, nil, 0); err != nil {
231242 return err
232243 }
233244 }
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, "+")
236260 var mods []uint32
237261 valid := true
238262 for _, part := range parts[:len(parts)-1] {
@@ -253,7 +277,7 @@ func RunSecure(ctx context.Context, km *xkb.Keymap, keymaps <-chan *xkb.Keymap,
253277 warnInput("combo", line, "unknown modifier")
254278 continue
255279 }
256- if err := stroke(parts[len(parts)-1], mods); err != nil {
280+ if err := stroke(parts[len(parts)-1], mods, hold); err != nil {
257281 return err
258282 }
259283 case strings.HasPrefix(line, "sleep "):
@@ -81,7 +81,9 @@ func RunSecure(ctx context.Context, km *xkb.Keymap, keymaps <-chan *xkb.Keymap,
81 return nil81 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 err109 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 err121 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 continue239 continue
229 }240 }
230- if err := stroke(name, nil); err != nil {241+ if err := stroke(name, nil, 0); err != nil {
231 return err242 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 []uint32260 var mods []uint32
237 valid := true261 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 continue278 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 err281 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 (
44 "context"
55 "strings"
66 "testing"
7+ "testing/synctest"
8+ "time"
79
810 "github.com/bnema/neferwl/internal/adapters/logging"
911 "github.com/bnema/neferwl/internal/adapters/xkb"
@@ -19,6 +21,8 @@ func TestScript(t *testing.T) {
1921 {"type Hi!", []string{"Shift_L", "H", "H", "Shift_L", "i", "i", "Shift_L", "exclam", "exclam", "Shift_L"}, nil},
2022 {"key Shift+A", []string{"Shift_L", "A", "A", "Shift_L"}, []uint32{42, 30, 30, 42}},
2123 {"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},
2226 } {
2327 t.Run(tt.line, func(t *testing.T) {
2428 km, err := xkb.New(xkb.RMLVO{Layout: "us"})
@@ -57,6 +61,48 @@ func TestScript(t *testing.T) {
5761 }
5862 }
5963
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+
60106 func TestPointerScript(t *testing.T) {
61107 km, err := xkb.New(xkb.RMLVO{Layout: "us"})
62108 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 {
161161 timerC <-chan time.Time
162162 timerStop func() bool
163163 }
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
164168 }
165169
166170 // publishScratch is where publishState and publishWorkspaces build their
@@ -1054,6 +1058,7 @@ func (c *Core) Run(ctx context.Context) error {
10541058 defer c.stopCaptureTimer()
10551059 defer c.stopPulseTimer()
10561060 defer c.cancelSwitcher()
1061+ defer c.stopRepeat()
10571062 // Startup commands run once per session. Full launcher queues retain a
10581063 // bounded remainder, selectable alongside owner events without polling.
10591064 c.startup = make([][]string, len(c.cfg.Startup))
@@ -1137,6 +1142,28 @@ func (c *Core) Run(ctx context.Context) error {
11371142 return nil
11381143 }
11391144 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
11401167 case <-c.capt.timerC:
11411168 c.capt.timerC, c.capt.timerStop = nil, nil
11421169 if c.captureFlashTick() && c.publish(ctx) != nil {
@@ -1181,6 +1208,9 @@ func (c *Core) Run(ctx context.Context) error {
11811208 if c.securityCheckpoint(ctx) != nil {
11821209 return nil
11831210 }
1211+ // A new keymap quarantines held keys: their release never
1212+ // comes, so no repeat may outlive a reload.
1213+ c.stopRepeat()
11841214 if err := c.apply(ev.Config); err != nil {
11851215 select {
11861216 case c.ch.ConfigErrors <- err:
@@ -161,6 +161,10 @@ type Core struct {
161 timerC <-chan time.Time161 timerC <-chan time.Time
162 timerStop func() bool162 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 their170 // 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 a1062 // 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 nil1142 return nil
1138 }1143 }
1139 continue1144 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, nil1168 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 nil1209 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 {
236236
237237 func (c *Core) keyEvent(ctx context.Context, key ports.KeyEvent) error {
238238 c.mods = key.Mods
239+ c.repeatKey(key)
239240 // Releasing the command key commits the switcher; the release still
240241 // goes on to the bind lookup and the clients.
241242 if c.switching() && c.mods&c.cmdMod == 0 {
@@ -268,6 +269,9 @@ func (c *Core) keyEvent(ctx context.Context, key ports.KeyEvent) error {
268269 }
269270 if key.Pressed && mon.overviewKey(key) {
270271 c.pressed[heldKey(key)] = true
272+ if mon.ov.open && overviewRepeats(key) {
273+ c.startRepeat(key, "")
274+ }
271275 c.transition(shots, now)
272276 if err := c.workspaceVisible(ctx, true); err != nil {
273277 return err
@@ -315,6 +319,9 @@ func (c *Core) keyEvent(ctx context.Context, key ports.KeyEvent) error {
315319 if key.Pressed {
316320 if bound {
317321 c.pressed[held] = true
322+ if repeats(action) {
323+ c.startRepeat(key, action)
324+ }
318325 return c.runBind(ctx, action)
319326 }
320327 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.Mods238 c.mods = key.Mods
239+ c.repeatKey(key)
239 // Releasing the command key commits the switcher; the release still240 // 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)] = true271 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 err277 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] = true321 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] = false327 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 {
4747 c.stopAnimations()
4848 c.stopPulse()
4949 c.cancelSwitcher()
50+ c.stopRepeat()
5051 if state.Protected {
5152 c.dropCaptureSessions()
5253 }
@@ -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 }

Keyboard shortcuts

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

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

Branches 25

Tags 10

↑↓ moveenter openesc close