detached 3d47d2b

fix(input): report activity while a key repeats and stop it on reload

A repeating key never reported user activity, so idle timers could
blank or lock the screen while a move bind was held. A config reload
installs a new keymap that quarantines held keys and swallows their
release, which left a repeat running: a reload now ends it.
commit
3d47d2b49d0777ed89344e05d5ee4ad46ff50545
author
brice · · 2026-10-15 06:01 UTC
parent
9e93199
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +95 -0
M internal/core/core.go +11 -0
@@ -1143,6 +1143,14 @@ func (c *Core) Run(ctx context.Context) error {
11431143 }
11441144 continue
11451145 case <-c.repeatC:
1146+ if c.securityCheckpoint(ctx) != nil {
1147+ return nil
1148+ }
1149+ // A held key is activity, as its press was: idle timers
1150+ // must not fire while it repeats.
1151+ if err := c.userActivity(ctx); err != nil {
1152+ return nil
1153+ }
11461154 if c.securityCheckpoint(ctx) != nil {
11471155 return nil
11481156 }
@@ -1197,6 +1205,9 @@ func (c *Core) Run(ctx context.Context) error {
11971205 if c.securityCheckpoint(ctx) != nil {
11981206 return nil
11991207 }
1208+ // A new keymap quarantines held keys: their release never
1209+ // comes, so no repeat may outlive a reload.
1210+ c.stopRepeat()
12001211 if err := c.apply(ev.Config); err != nil {
12011212 select {
12021213 case c.ch.ConfigErrors <- err:
@@ -1143,6 +1143,14 @@ func (c *Core) Run(ctx context.Context) error {
1143 }1143 }
1144 continue1144 continue
1145 case <-c.repeatC:1145 case <-c.repeatC:
1146+ if c.securityCheckpoint(ctx) != nil {
1147+ return nil
1148+ }
1149+ // A held key is activity, as its press was: idle timers
1150+ // must not fire while it repeats.
1151+ if err := c.userActivity(ctx); err != nil {
1152+ return nil
1153+ }
1146 if c.securityCheckpoint(ctx) != nil {1154 if c.securityCheckpoint(ctx) != nil {
1147 return nil1155 return nil
1148 }1156 }
@@ -1197,6 +1205,9 @@ func (c *Core) Run(ctx context.Context) error {
1197 if c.securityCheckpoint(ctx) != nil {1205 if c.securityCheckpoint(ctx) != nil {
1198 return nil1206 return nil
1199 }1207 }
1208+ // A new keymap quarantines held keys: their release never
1209+ // comes, so no repeat may outlive a reload.
1210+ c.stopRepeat()
1200 if err := c.apply(ev.Config); err != nil {1211 if err := c.apply(ev.Config); err != nil {
1201 select {1212 select {
1202 case c.ch.ConfigErrors <- err:1213 case c.ch.ConfigErrors <- err:
A internal/core/repeat_run_test.go +84 -0
@@ -0,0 +1,84 @@
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+// repeatRig runs a Core with five windows in a row on one output, the last
14+// one focused, and returns its input, config and command channels.
15+func repeatRig(t *testing.T) (chan ports.InputEvent, chan ports.ConfigChanged, chan ports.ClientCommand, ports.Config) {
16+ t.Helper()
17+ input := make(chan ports.InputEvent, 4)
18+ client := make(chan ports.ClientEvent, 8)
19+ output := make(chan ports.OutputEvent, 1)
20+ reload := make(chan ports.ConfigChanged, 1)
21+ commands := make(chan ports.ClientCommand, 256)
22+ cfg := scrollDefaults()
23+ cfg.Layout.MaxColumns = 5
24+ 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{})
25+ if err != nil {
26+ t.Fatal(err)
27+ }
28+ ctx, cancel := context.WithCancel(context.Background())
29+ done := make(chan error, 1)
30+ go func() { done <- c.Run(ctx) }()
31+ t.Cleanup(func() { cancel(); <-done })
32+ output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 500, Height: 100}}
33+ for id := ports.WindowID(1); id <= 5; id++ {
34+ client <- ports.WindowMapped{ID: id}
35+ }
36+ synctest.Wait()
37+ return input, reload, commands, cfg
38+}
39+
40+// drain counts the focus changes and activity reports sent since the last
41+// drain.
42+func drain(commands chan ports.ClientCommand) (focus, activity int) {
43+ for len(commands) > 0 {
44+ switch (<-commands).(type) {
45+ case ports.FocusWindow:
46+ focus++
47+ case ports.UserActivity:
48+ activity++
49+ }
50+ }
51+ return focus, activity
52+}
53+
54+var cmdLeft = ports.KeyEvent{Keysym: "Left", Keycode: 113, Mods: ports.ModSuper, Pressed: true}
55+
56+// Through Run: a held cmd+Left repeats on its timer, each repeat reports
57+// user activity (idle timers must not fire while a key is held), and a
58+// config reload ends it, since the new keymap may swallow the release.
59+func TestHeldKeyRepeatsThroughRun(t *testing.T) {
60+ synctest.Test(t, func(t *testing.T) {
61+ input, reload, commands, cfg := repeatRig(t)
62+ drain(commands)
63+ input <- cmdLeft
64+ synctest.Wait()
65+ if focus, _ := drain(commands); focus != 1 {
66+ t.Fatalf("press: %d focus changes, want 1", focus)
67+ }
68+ // Past the first repeat and the activity throttle.
69+ time.Sleep(time.Second)
70+ synctest.Wait()
71+ if focus, activity := drain(commands); focus < 2 || activity == 0 {
72+ t.Fatalf("held: %d focus changes, %d activity reports", focus, activity)
73+ }
74+
75+ reload <- ports.ConfigChanged{Config: cfg}
76+ synctest.Wait()
77+ drain(commands)
78+ time.Sleep(time.Second)
79+ synctest.Wait()
80+ if focus, _ := drain(commands); focus != 0 {
81+ t.Fatalf("after the reload: %d focus changes, want none", focus)
82+ }
83+ })
84+}
@@ -0,0 +1,84 @@
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+// repeatRig runs a Core with five windows in a row on one output, the last
14+// one focused, and returns its input, config and command channels.
15+func repeatRig(t *testing.T) (chan ports.InputEvent, chan ports.ConfigChanged, chan ports.ClientCommand, ports.Config) {
16+ t.Helper()
17+ input := make(chan ports.InputEvent, 4)
18+ client := make(chan ports.ClientEvent, 8)
19+ output := make(chan ports.OutputEvent, 1)
20+ reload := make(chan ports.ConfigChanged, 1)
21+ commands := make(chan ports.ClientCommand, 256)
22+ cfg := scrollDefaults()
23+ cfg.Layout.MaxColumns = 5
24+ 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{})
25+ if err != nil {
26+ t.Fatal(err)
27+ }
28+ ctx, cancel := context.WithCancel(context.Background())
29+ done := make(chan error, 1)
30+ go func() { done <- c.Run(ctx) }()
31+ t.Cleanup(func() { cancel(); <-done })
32+ output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 500, Height: 100}}
33+ for id := ports.WindowID(1); id <= 5; id++ {
34+ client <- ports.WindowMapped{ID: id}
35+ }
36+ synctest.Wait()
37+ return input, reload, commands, cfg
38+}
39+
40+// drain counts the focus changes and activity reports sent since the last
41+// drain.
42+func drain(commands chan ports.ClientCommand) (focus, activity int) {
43+ for len(commands) > 0 {
44+ switch (<-commands).(type) {
45+ case ports.FocusWindow:
46+ focus++
47+ case ports.UserActivity:
48+ activity++
49+ }
50+ }
51+ return focus, activity
52+}
53+
54+var cmdLeft = ports.KeyEvent{Keysym: "Left", Keycode: 113, Mods: ports.ModSuper, Pressed: true}
55+
56+// Through Run: a held cmd+Left repeats on its timer, each repeat reports
57+// user activity (idle timers must not fire while a key is held), and a
58+// config reload ends it, since the new keymap may swallow the release.
59+func TestHeldKeyRepeatsThroughRun(t *testing.T) {
60+ synctest.Test(t, func(t *testing.T) {
61+ input, reload, commands, cfg := repeatRig(t)
62+ drain(commands)
63+ input <- cmdLeft
64+ synctest.Wait()
65+ if focus, _ := drain(commands); focus != 1 {
66+ t.Fatalf("press: %d focus changes, want 1", focus)
67+ }
68+ // Past the first repeat and the activity throttle.
69+ time.Sleep(time.Second)
70+ synctest.Wait()
71+ if focus, activity := drain(commands); focus < 2 || activity == 0 {
72+ t.Fatalf("held: %d focus changes, %d activity reports", focus, activity)
73+ }
74+
75+ reload <- ports.ConfigChanged{Config: cfg}
76+ synctest.Wait()
77+ drain(commands)
78+ time.Sleep(time.Second)
79+ synctest.Wait()
80+ if focus, _ := drain(commands); focus != 0 {
81+ t.Fatalf("after the reload: %d focus changes, want none", focus)
82+ }
83+ })
84+}

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