detached 538caad

fix(config): later bind wins, repeat-only reload keeps the keymap

Bind collisions under keyboard.cmd now follow the same later-line-wins rule as duplicate keys; invalid duplicates no longer claim to override. Uppercase Latin-1 keys map to their lowercase keysym and characters without a known keysym warn. Repeat-only changes send repeat_info without rebuilding the keymap, a rejected layout still applies its repeat change, and an untaken keymap is freed at shutdown. validate-config without a path accepts a missing file again.
commit
538caadd61ad31ad64e3976de26e111e1ecc5692
author
brice · · 2026-09-24 13:48 UTC
parent
830fa72
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
8 files · +211 -41
M cmd/nefertty/main.go +4 -1
@@ -15,6 +15,7 @@ import (
1515 "github.com/bnema/nefertty/internal/adapters/config"
1616 "github.com/bnema/nefertty/internal/app"
1717 "github.com/bnema/nefertty/internal/logging"
18+ "github.com/bnema/nefertty/internal/ports"
1819 )
1920
2021 type usageError struct{ error }
@@ -50,10 +51,12 @@ func run() error {
5051 return err
5152 }
5253 path := config.DefaultPath()
54+ load := config.LoadDefault
5355 if len(os.Args) == 3 {
5456 path = os.Args[2]
57+ load = func() (ports.Config, []config.Warning, error) { return config.Load(path) }
5558 }
56- _, warnings, err := config.Load(path)
59+ _, warnings, err := load()
5760 if err != nil {
5861 fmt.Fprintln(os.Stderr, err)
5962 return err
@@ -15,6 +15,7 @@ import (
15 "github.com/bnema/nefertty/internal/adapters/config"15 "github.com/bnema/nefertty/internal/adapters/config"
16 "github.com/bnema/nefertty/internal/app"16 "github.com/bnema/nefertty/internal/app"
17 "github.com/bnema/nefertty/internal/logging"17 "github.com/bnema/nefertty/internal/logging"
18+ "github.com/bnema/nefertty/internal/ports"
18 )19 )
19 20
20 type usageError struct{ error }21 type usageError struct{ error }
@@ -50,10 +51,12 @@ func run() error {
50 return err51 return err
51 }52 }
52 path := config.DefaultPath()53 path := config.DefaultPath()
54+ load := config.LoadDefault
53 if len(os.Args) == 3 {55 if len(os.Args) == 3 {
54 path = os.Args[2]56 path = os.Args[2]
57+ load = func() (ports.Config, []config.Warning, error) { return config.Load(path) }
55 }58 }
56- _, warnings, err := config.Load(path)59+ _, warnings, err := load()
57 if err != nil {60 if err != nil {
58 fmt.Fprintln(os.Stderr, err)61 fmt.Fprintln(os.Stderr, err)
59 return err62 return err
M internal/adapters/config/config.go +29 -17
@@ -12,6 +12,7 @@ import (
1212 "sort"
1313 "strconv"
1414 "strings"
15+ "unicode"
1516
1617 "github.com/bnema/nefertty/internal/ports"
1718 )
@@ -157,10 +158,13 @@ func parse(r io.Reader) (ports.Config, map[string]string, []Warning, error) {
157158 c.Binds[canon] = value
158159 continue
159160 }
160- if prev, dup := seen[key]; dup {
161- warn("%s: overrides line %d", key, prev)
161+ override := func() {
162+ if prev, dup := seen[key]; dup {
163+ warn("%s: overrides line %d", key, prev)
164+ }
165+ seen[key] = n
166+ raw[key] = value
162167 }
163- seen[key] = n
164168 if name, ok := strings.CutPrefix(key, "output."); ok {
165169 o := ports.OutputConfig{Name: name}
166170 if value == "off" {
@@ -171,7 +175,7 @@ func parse(r io.Reader) (ports.Config, map[string]string, []Warning, error) {
171175 } else if value != "preferred" {
172176 o.Mode = value
173177 }
174- raw[key] = value
178+ override()
175179 if i, ok := outputs[name]; ok {
176180 c.Outputs[i] = o
177181 } else {
@@ -184,7 +188,7 @@ func parse(r io.Reader) (ports.Config, map[string]string, []Warning, error) {
184188 warn("%s: %v", key, err)
185189 continue
186190 }
187- raw[key] = value
191+ override()
188192 }
189193 if err := scanner.Err(); err != nil {
190194 return c, raw, warnings, err
@@ -195,15 +199,15 @@ func parse(r io.Reader) (ports.Config, map[string]string, []Warning, error) {
195199 delete(c.Binds, combo)
196200 }
197201 }
198- // Cmd resolves to a real modifier; drop binds that then collide. User lines win over
199- // defaults (line 0), then the earliest line wins.
202+ // Cmd resolves to a real modifier; drop binds that then collide. As for any key,
203+ // the later line wins; defaults (no line) lose to every user line.
200204 combos := make([]string, 0, len(c.Binds))
201205 for combo := range c.Binds {
202206 combos = append(combos, combo)
203207 }
204208 order := func(combo string) int {
205209 if n := seen["bind."+combo]; n > 0 {
206- return n
210+ return -n
207211 }
208212 return math.MaxInt
209213 }
@@ -215,7 +219,7 @@ func parse(r io.Reader) (ports.Config, map[string]string, []Warning, error) {
215219 for _, combo := range combos {
216220 r, err := expandCmd(combo, c.Keyboard.CmdKey)
217221 if prev, dup := resolved[r]; err == nil && dup {
218- err = fmt.Errorf("same keys as %s with keyboard.cmd = %s", prev, c.Keyboard.CmdKey)
222+ err = fmt.Errorf("overridden by bind.%s (same keys with keyboard.cmd = %s)", prev, c.Keyboard.CmdKey)
219223 }
220224 if err != nil {
221225 if n := seen["bind."+combo]; n > 0 {
@@ -442,26 +446,34 @@ func parseCombo(s string) (string, error) {
442446 mods = append(mods, m)
443447 }
444448 sort.Strings(mods)
445- return strings.Join(append(mods, keysym(key)), "+"), nil
449+ name, err := keysym(key)
450+ if err != nil {
451+ return "", err
452+ }
453+ return strings.Join(append(mods, name), "+"), nil
446454 }
447455
448456 // keysym maps a written key to its xkb keysym name.
449-func keysym(k string) string {
457+func keysym(k string) (string, error) {
450458 if r := []rune(k); len(r) == 1 {
451- if name, ok := runeKeysyms[r[0]]; ok {
452- return name
459+ // Binds match the unshifted keysym, so É means é.
460+ if name, ok := runeKeysyms[unicode.ToLower(r[0])]; ok {
461+ return name, nil
462+ }
463+ if r[0] > unicode.MaxASCII {
464+ return "", fmt.Errorf("write %q by its xkb keysym name", k)
453465 }
454- return strings.ToLower(k)
466+ return strings.ToLower(k), nil
455467 }
456468 if name, ok := namedKeys[strings.ToLower(k)]; ok {
457- return name
469+ return name, nil
458470 }
459471 if len(k) <= 3 && (k[0] == 'f' || k[0] == 'F') {
460472 if n, err := strconv.Atoi(k[1:]); err == nil && n >= 1 && n <= 24 {
461- return "F" + k[1:]
473+ return "F" + k[1:], nil
462474 }
463475 }
464- return k
476+ return k, nil
465477 }
466478
467479 // ParseMode parses "WxH" or "WxH@Hz" ("preferred" is accepted); hz is 0 when omitted.
@@ -12,6 +12,7 @@ import (
12 "sort"12 "sort"
13 "strconv"13 "strconv"
14 "strings"14 "strings"
15+ "unicode"
15 16
16 "github.com/bnema/nefertty/internal/ports"17 "github.com/bnema/nefertty/internal/ports"
17 )18 )
@@ -157,10 +158,13 @@ func parse(r io.Reader) (ports.Config, map[string]string, []Warning, error) {
157 c.Binds[canon] = value158 c.Binds[canon] = value
158 continue159 continue
159 }160 }
160- if prev, dup := seen[key]; dup {161+ override := func() {
161- warn("%s: overrides line %d", key, prev)162+ if prev, dup := seen[key]; dup {
163+ warn("%s: overrides line %d", key, prev)
164+ }
165+ seen[key] = n
166+ raw[key] = value
162 }167 }
163- seen[key] = n
164 if name, ok := strings.CutPrefix(key, "output."); ok {168 if name, ok := strings.CutPrefix(key, "output."); ok {
165 o := ports.OutputConfig{Name: name}169 o := ports.OutputConfig{Name: name}
166 if value == "off" {170 if value == "off" {
@@ -171,7 +175,7 @@ func parse(r io.Reader) (ports.Config, map[string]string, []Warning, error) {
171 } else if value != "preferred" {175 } else if value != "preferred" {
172 o.Mode = value176 o.Mode = value
173 }177 }
174- raw[key] = value178+ override()
175 if i, ok := outputs[name]; ok {179 if i, ok := outputs[name]; ok {
176 c.Outputs[i] = o180 c.Outputs[i] = o
177 } else {181 } else {
@@ -184,7 +188,7 @@ func parse(r io.Reader) (ports.Config, map[string]string, []Warning, error) {
184 warn("%s: %v", key, err)188 warn("%s: %v", key, err)
185 continue189 continue
186 }190 }
187- raw[key] = value191+ override()
188 }192 }
189 if err := scanner.Err(); err != nil {193 if err := scanner.Err(); err != nil {
190 return c, raw, warnings, err194 return c, raw, warnings, err
@@ -195,15 +199,15 @@ func parse(r io.Reader) (ports.Config, map[string]string, []Warning, error) {
195 delete(c.Binds, combo)199 delete(c.Binds, combo)
196 }200 }
197 }201 }
198- // Cmd resolves to a real modifier; drop binds that then collide. User lines win over202+ // Cmd resolves to a real modifier; drop binds that then collide. As for any key,
199- // defaults (line 0), then the earliest line wins.203+ // the later line wins; defaults (no line) lose to every user line.
200 combos := make([]string, 0, len(c.Binds))204 combos := make([]string, 0, len(c.Binds))
201 for combo := range c.Binds {205 for combo := range c.Binds {
202 combos = append(combos, combo)206 combos = append(combos, combo)
203 }207 }
204 order := func(combo string) int {208 order := func(combo string) int {
205 if n := seen["bind."+combo]; n > 0 {209 if n := seen["bind."+combo]; n > 0 {
206- return n210+ return -n
207 }211 }
208 return math.MaxInt212 return math.MaxInt
209 }213 }
@@ -215,7 +219,7 @@ func parse(r io.Reader) (ports.Config, map[string]string, []Warning, error) {
215 for _, combo := range combos {219 for _, combo := range combos {
216 r, err := expandCmd(combo, c.Keyboard.CmdKey)220 r, err := expandCmd(combo, c.Keyboard.CmdKey)
217 if prev, dup := resolved[r]; err == nil && dup {221 if prev, dup := resolved[r]; err == nil && dup {
218- err = fmt.Errorf("same keys as %s with keyboard.cmd = %s", prev, c.Keyboard.CmdKey)222+ err = fmt.Errorf("overridden by bind.%s (same keys with keyboard.cmd = %s)", prev, c.Keyboard.CmdKey)
219 }223 }
220 if err != nil {224 if err != nil {
221 if n := seen["bind."+combo]; n > 0 {225 if n := seen["bind."+combo]; n > 0 {
@@ -442,26 +446,34 @@ func parseCombo(s string) (string, error) {
442 mods = append(mods, m)446 mods = append(mods, m)
443 }447 }
444 sort.Strings(mods)448 sort.Strings(mods)
445- return strings.Join(append(mods, keysym(key)), "+"), nil449+ name, err := keysym(key)
450+ if err != nil {
451+ return "", err
452+ }
453+ return strings.Join(append(mods, name), "+"), nil
446 }454 }
447 455
448 // keysym maps a written key to its xkb keysym name.456 // keysym maps a written key to its xkb keysym name.
449-func keysym(k string) string {457+func keysym(k string) (string, error) {
450 if r := []rune(k); len(r) == 1 {458 if r := []rune(k); len(r) == 1 {
451- if name, ok := runeKeysyms[r[0]]; ok {459+ // Binds match the unshifted keysym, so É means é.
452- return name460+ if name, ok := runeKeysyms[unicode.ToLower(r[0])]; ok {
461+ return name, nil
462+ }
463+ if r[0] > unicode.MaxASCII {
464+ return "", fmt.Errorf("write %q by its xkb keysym name", k)
453 }465 }
454- return strings.ToLower(k)466+ return strings.ToLower(k), nil
455 }467 }
456 if name, ok := namedKeys[strings.ToLower(k)]; ok {468 if name, ok := namedKeys[strings.ToLower(k)]; ok {
457- return name469+ return name, nil
458 }470 }
459 if len(k) <= 3 && (k[0] == 'f' || k[0] == 'F') {471 if len(k) <= 3 && (k[0] == 'f' || k[0] == 'F') {
460 if n, err := strconv.Atoi(k[1:]); err == nil && n >= 1 && n <= 24 {472 if n, err := strconv.Atoi(k[1:]); err == nil && n >= 1 && n <= 24 {
461- return "F" + k[1:]473+ return "F" + k[1:], nil
462 }474 }
463 }475 }
464- return k476+ return k, nil
465 }477 }
466 478
467 // ParseMode parses "WxH" or "WxH@Hz" ("preferred" is accepted); hz is 0 when omitted.479 // ParseMode parses "WxH" or "WxH@Hz" ("preferred" is accepted); hz is 0 when omitted.
M internal/adapters/config/config_test.go +26 -3
@@ -158,17 +158,40 @@ func TestBindReservedCharacter(t *testing.T) {
158158 }
159159
160160 func TestBindResolvedCollision(t *testing.T) {
161+ // The later line wins, like any duplicate key.
161162 c, w := parseString(t, "keyboard.cmd = ctrl\nbind.ctrl+x = quit\nbind.cmd+x = close-window\nbind.cmd+ctrl+y = quit\n")
162- if c.Binds["Ctrl+x"] != "quit" {
163+ if c.Binds["Cmd+x"] != "close-window" {
163164 t.Fatal(c.Binds)
164165 }
165- if _, ok := c.Binds["Cmd+x"]; ok {
166+ if _, ok := c.Binds["Ctrl+x"]; ok {
166167 t.Fatal(c.Binds)
167168 }
168169 if _, ok := c.Binds["Cmd+Ctrl+y"]; ok {
169170 t.Fatal(c.Binds)
170171 }
171- if len(w) != 2 || w[0].Line != 3 || w[1].Line != 4 {
172+ if len(w) != 2 || w[0].Line != 4 || w[1].Line != 2 || !strings.Contains(w[1].Msg, "overridden by bind.Cmd+x") {
173+ t.Fatal(w)
174+ }
175+ // A user bind replaces a default with the same resolved keys, without a warning.
176+ c, w = parseString(t, "bind.super+q = quit\n")
177+ if c.Binds["Super+q"] != "quit" || c.Binds["Cmd+q"] != "" || len(w) != 0 {
178+ t.Fatal(c.Binds, w)
179+ }
180+}
181+
182+func TestInvalidDuplicateKeepsEarlierValue(t *testing.T) {
183+ c, w := parseString(t, "background = #000000\nbackground = red\nbackground = #111111\n")
184+ if c.Background.Color != "#111111" || len(w) != 2 || w[0].Line != 2 || w[1].Line != 3 || !strings.Contains(w[1].Msg, "overrides line 1") {
185+ t.Fatal(c.Background, w)
186+ }
187+}
188+
189+func TestBindCharacters(t *testing.T) {
190+ c, w := parseString(t, "bind.cmd+É = quit\nbind.cmd+ж = quit\n")
191+ if c.Binds["Cmd+eacute"] != "quit" {
192+ t.Fatal(c.Binds)
193+ }
194+ if len(w) != 1 || w[0].Line != 2 || !strings.Contains(w[0].Msg, "keysym name") {
172195 t.Fatal(w)
173196 }
174197 }
@@ -158,17 +158,40 @@ func TestBindReservedCharacter(t *testing.T) {
158 }158 }
159 159
160 func TestBindResolvedCollision(t *testing.T) {160 func TestBindResolvedCollision(t *testing.T) {
161+ // The later line wins, like any duplicate key.
161 c, w := parseString(t, "keyboard.cmd = ctrl\nbind.ctrl+x = quit\nbind.cmd+x = close-window\nbind.cmd+ctrl+y = quit\n")162 c, w := parseString(t, "keyboard.cmd = ctrl\nbind.ctrl+x = quit\nbind.cmd+x = close-window\nbind.cmd+ctrl+y = quit\n")
162- if c.Binds["Ctrl+x"] != "quit" {163+ if c.Binds["Cmd+x"] != "close-window" {
163 t.Fatal(c.Binds)164 t.Fatal(c.Binds)
164 }165 }
165- if _, ok := c.Binds["Cmd+x"]; ok {166+ if _, ok := c.Binds["Ctrl+x"]; ok {
166 t.Fatal(c.Binds)167 t.Fatal(c.Binds)
167 }168 }
168 if _, ok := c.Binds["Cmd+Ctrl+y"]; ok {169 if _, ok := c.Binds["Cmd+Ctrl+y"]; ok {
169 t.Fatal(c.Binds)170 t.Fatal(c.Binds)
170 }171 }
171- if len(w) != 2 || w[0].Line != 3 || w[1].Line != 4 {172+ if len(w) != 2 || w[0].Line != 4 || w[1].Line != 2 || !strings.Contains(w[1].Msg, "overridden by bind.Cmd+x") {
173+ t.Fatal(w)
174+ }
175+ // A user bind replaces a default with the same resolved keys, without a warning.
176+ c, w = parseString(t, "bind.super+q = quit\n")
177+ if c.Binds["Super+q"] != "quit" || c.Binds["Cmd+q"] != "" || len(w) != 0 {
178+ t.Fatal(c.Binds, w)
179+ }
180+}
181+
182+func TestInvalidDuplicateKeepsEarlierValue(t *testing.T) {
183+ c, w := parseString(t, "background = #000000\nbackground = red\nbackground = #111111\n")
184+ if c.Background.Color != "#111111" || len(w) != 2 || w[0].Line != 2 || w[1].Line != 3 || !strings.Contains(w[1].Msg, "overrides line 1") {
185+ t.Fatal(c.Background, w)
186+ }
187+}
188+
189+func TestBindCharacters(t *testing.T) {
190+ c, w := parseString(t, "bind.cmd+É = quit\nbind.cmd+ж = quit\n")
191+ if c.Binds["Cmd+eacute"] != "quit" {
192+ t.Fatal(c.Binds)
193+ }
194+ if len(w) != 1 || w[0].Line != 2 || !strings.Contains(w[0].Msg, "keysym name") {
172 t.Fatal(w)195 t.Fatal(w)
173 }196 }
174 }197 }
M internal/adapters/wayland/keyboard_test.go +92 -0
@@ -5,6 +5,7 @@ import (
55 "os"
66 "os/exec"
77 "regexp"
8+ "slices"
89 "strings"
910 "testing"
1011 "time"
@@ -118,6 +119,97 @@ func TestKeyboardKeymapProtocol(t *testing.T) {
118119 t.Fatal("missing keymap event")
119120 }
120121 }
122+
123+// events records keyboard event opcodes; a keymap event also checks its content.
124+type eventsProxy struct {
125+ wlturbo.BaseProxy
126+ opcodes []uint16
127+ keymaps []string
128+}
129+
130+func (p *eventsProxy) Dispatch(e *wlturbo.Event) {
131+ p.opcodes = append(p.opcodes, e.Opcode)
132+ if e.Opcode != uint16(wayland.KeyboardEventKeymap) {
133+ return
134+ }
135+ e.Uint32()
136+ fd := int(e.Fd())
137+ size := e.Uint32()
138+ defer unix.Close(fd)
139+ data := make([]byte, size)
140+ if _, err := unix.Pread(fd, data, 0); err == nil {
141+ p.keymaps = append(p.keymaps, strings.TrimRight(string(data), "\x00"))
142+ }
143+}
144+
145+func TestSetKeymap(t *testing.T) {
146+ s, _, commands, dir := keyboardServer(t)
147+ c := protocolClient(t, s, dir)
148+ // Version 4 so the keyboard receives repeat_info.
149+ g, ok := c.Registry().FindGlobal("wl_seat")
150+ if !ok {
151+ t.Fatal("missing wl_seat")
152+ }
153+ seat, err := c.Registry().BindID(g.Name, g.Interface, 4)
154+ if err != nil {
155+ t.Fatal(err)
156+ }
157+ registerProtocol(t, c, seat)
158+ id := c.AllocateID()
159+ proxy := &eventsProxy{}
160+ proxy.SetID(id)
161+ c.Context().Register(proxy)
162+ requestProtocol(t, c, seat, wayland.SeatRequestGetKeyboard, id)
163+ if err := c.Roundtrip(); err != nil {
164+ t.Fatal(err)
165+ }
166+ proxy.opcodes, proxy.keymaps = nil, nil
167+ sync := func() {
168+ t.Helper()
169+ // Commands apply on the display goroutine; wait until it has run them.
170+ deadline := time.Now().Add(time.Second)
171+ for len(commands) > 0 && time.Now().Before(deadline) {
172+ time.Sleep(5 * time.Millisecond)
173+ }
174+ time.Sleep(20 * time.Millisecond)
175+ if err := c.Roundtrip(); err != nil {
176+ t.Fatal(err)
177+ }
178+ }
179+
180+ // Repeat-only: one repeat_info, no keymap.
181+ commands <- ports.SetKeymap{RepeatRate: 40, RepeatDelay: 300}
182+ sync()
183+ if len(proxy.opcodes) != 1 || proxy.opcodes[0] != uint16(wayland.KeyboardEventRepeatInfo) {
184+ t.Fatalf("repeat-only events %v", proxy.opcodes)
185+ }
186+
187+ // New keymap: keymap then repeat_info, and the new content.
188+ proxy.opcodes = nil
189+ next := strings.Replace(keymapText(t), "xkb_keymap", "xkb_keymap ", 1)
190+ commands <- ports.SetKeymap{Keymap: next, RepeatRate: 40, RepeatDelay: 300}
191+ sync()
192+ want := []uint16{uint16(wayland.KeyboardEventKeymap), uint16(wayland.KeyboardEventRepeatInfo)}
193+ if !slices.Equal(proxy.opcodes, want) {
194+ t.Fatalf("keymap events %v, want %v", proxy.opcodes, want)
195+ }
196+ if len(proxy.keymaps) != 1 || proxy.keymaps[0] != next {
197+ t.Fatalf("keymap content not replaced (%d keymaps)", len(proxy.keymaps))
198+ }
199+ // A keyboard created afterwards gets the new keymap.
200+ id2 := c.AllocateID()
201+ late := &eventsProxy{}
202+ late.SetID(id2)
203+ c.Context().Register(late)
204+ requestProtocol(t, c, seat, wayland.SeatRequestGetKeyboard, id2)
205+ if err := c.Roundtrip(); err != nil {
206+ t.Fatal(err)
207+ }
208+ if len(late.keymaps) != 1 || late.keymaps[0] != next {
209+ t.Fatal("late keyboard got the old keymap")
210+ }
211+}
212+
121213 func TestFootKeyboard(t *testing.T) {
122214 tool, err := exec.LookPath("foot")
123215 if err != nil {
@@ -5,6 +5,7 @@ import (
5 "os"5 "os"
6 "os/exec"6 "os/exec"
7 "regexp"7 "regexp"
8+ "slices"
8 "strings"9 "strings"
9 "testing"10 "testing"
10 "time"11 "time"
@@ -118,6 +119,97 @@ func TestKeyboardKeymapProtocol(t *testing.T) {
118 t.Fatal("missing keymap event")119 t.Fatal("missing keymap event")
119 }120 }
120 }121 }
122+
123+// events records keyboard event opcodes; a keymap event also checks its content.
124+type eventsProxy struct {
125+ wlturbo.BaseProxy
126+ opcodes []uint16
127+ keymaps []string
128+}
129+
130+func (p *eventsProxy) Dispatch(e *wlturbo.Event) {
131+ p.opcodes = append(p.opcodes, e.Opcode)
132+ if e.Opcode != uint16(wayland.KeyboardEventKeymap) {
133+ return
134+ }
135+ e.Uint32()
136+ fd := int(e.Fd())
137+ size := e.Uint32()
138+ defer unix.Close(fd)
139+ data := make([]byte, size)
140+ if _, err := unix.Pread(fd, data, 0); err == nil {
141+ p.keymaps = append(p.keymaps, strings.TrimRight(string(data), "\x00"))
142+ }
143+}
144+
145+func TestSetKeymap(t *testing.T) {
146+ s, _, commands, dir := keyboardServer(t)
147+ c := protocolClient(t, s, dir)
148+ // Version 4 so the keyboard receives repeat_info.
149+ g, ok := c.Registry().FindGlobal("wl_seat")
150+ if !ok {
151+ t.Fatal("missing wl_seat")
152+ }
153+ seat, err := c.Registry().BindID(g.Name, g.Interface, 4)
154+ if err != nil {
155+ t.Fatal(err)
156+ }
157+ registerProtocol(t, c, seat)
158+ id := c.AllocateID()
159+ proxy := &eventsProxy{}
160+ proxy.SetID(id)
161+ c.Context().Register(proxy)
162+ requestProtocol(t, c, seat, wayland.SeatRequestGetKeyboard, id)
163+ if err := c.Roundtrip(); err != nil {
164+ t.Fatal(err)
165+ }
166+ proxy.opcodes, proxy.keymaps = nil, nil
167+ sync := func() {
168+ t.Helper()
169+ // Commands apply on the display goroutine; wait until it has run them.
170+ deadline := time.Now().Add(time.Second)
171+ for len(commands) > 0 && time.Now().Before(deadline) {
172+ time.Sleep(5 * time.Millisecond)
173+ }
174+ time.Sleep(20 * time.Millisecond)
175+ if err := c.Roundtrip(); err != nil {
176+ t.Fatal(err)
177+ }
178+ }
179+
180+ // Repeat-only: one repeat_info, no keymap.
181+ commands <- ports.SetKeymap{RepeatRate: 40, RepeatDelay: 300}
182+ sync()
183+ if len(proxy.opcodes) != 1 || proxy.opcodes[0] != uint16(wayland.KeyboardEventRepeatInfo) {
184+ t.Fatalf("repeat-only events %v", proxy.opcodes)
185+ }
186+
187+ // New keymap: keymap then repeat_info, and the new content.
188+ proxy.opcodes = nil
189+ next := strings.Replace(keymapText(t), "xkb_keymap", "xkb_keymap ", 1)
190+ commands <- ports.SetKeymap{Keymap: next, RepeatRate: 40, RepeatDelay: 300}
191+ sync()
192+ want := []uint16{uint16(wayland.KeyboardEventKeymap), uint16(wayland.KeyboardEventRepeatInfo)}
193+ if !slices.Equal(proxy.opcodes, want) {
194+ t.Fatalf("keymap events %v, want %v", proxy.opcodes, want)
195+ }
196+ if len(proxy.keymaps) != 1 || proxy.keymaps[0] != next {
197+ t.Fatalf("keymap content not replaced (%d keymaps)", len(proxy.keymaps))
198+ }
199+ // A keyboard created afterwards gets the new keymap.
200+ id2 := c.AllocateID()
201+ late := &eventsProxy{}
202+ late.SetID(id2)
203+ c.Context().Register(late)
204+ requestProtocol(t, c, seat, wayland.SeatRequestGetKeyboard, id2)
205+ if err := c.Roundtrip(); err != nil {
206+ t.Fatal(err)
207+ }
208+ if len(late.keymaps) != 1 || late.keymaps[0] != next {
209+ t.Fatal("late keyboard got the old keymap")
210+ }
211+}
212+
121 func TestFootKeyboard(t *testing.T) {213 func TestFootKeyboard(t *testing.T) {
122 tool, err := exec.LookPath("foot")214 tool, err := exec.LookPath("foot")
123 if err != nil {215 if err != nil {
M internal/adapters/wayland/server.go +15 -3
@@ -438,9 +438,22 @@ func keymapFile(keymap string) (int, uint32, error) {
438438 return fd, uint32(n), nil
439439 }
440440
441-// setKeymap sends a new keymap and repeat info to every keyboard. Held keys and
442-// modifiers are reset: the focused client gets leave, then enter with no keys.
441+// setKeymap sends repeat info, and a new keymap when one is given, to every keyboard.
442+// A new keymap resets held keys and modifiers: the focused client gets leave, then
443+// enter with no keys.
443444 func (s *Server) setKeymap(c ports.SetKeymap) {
445+ s.repeatRate, s.repeatDelay = c.RepeatRate, c.RepeatDelay
446+ if c.Keymap == "" {
447+ for _, list := range s.keyboards {
448+ for _, k := range list {
449+ if k.Version() >= 4 {
450+ k.SendRepeatInfo(int32(s.repeatRate), int32(s.repeatDelay))
451+ }
452+ }
453+ }
454+ s.log.Info().Int("rate", s.repeatRate).Int("delay", s.repeatDelay).Msg("repeat updated")
455+ return
456+ }
444457 fd, size, err := keymapFile(c.Keymap)
445458 if err != nil {
446459 s.log.Warn().Err(err).Msg("keymap update failed")
@@ -450,7 +463,6 @@ func (s *Server) setKeymap(c ports.SetKeymap) {
450463 unix.Close(s.keymapFD)
451464 }
452465 s.keymapFD, s.keymapSize = fd, size
453- s.repeatRate, s.repeatDelay = c.RepeatRate, c.RepeatDelay
454466 focused := s.focused
455467 s.changeFocus(0)
456468 s.heldKeys = nil
@@ -438,9 +438,22 @@ func keymapFile(keymap string) (int, uint32, error) {
438 return fd, uint32(n), nil438 return fd, uint32(n), nil
439 }439 }
440 440
441-// setKeymap sends a new keymap and repeat info to every keyboard. Held keys and441+// setKeymap sends repeat info, and a new keymap when one is given, to every keyboard.
442-// modifiers are reset: the focused client gets leave, then enter with no keys.442+// A new keymap resets held keys and modifiers: the focused client gets leave, then
443+// enter with no keys.
443 func (s *Server) setKeymap(c ports.SetKeymap) {444 func (s *Server) setKeymap(c ports.SetKeymap) {
445+ s.repeatRate, s.repeatDelay = c.RepeatRate, c.RepeatDelay
446+ if c.Keymap == "" {
447+ for _, list := range s.keyboards {
448+ for _, k := range list {
449+ if k.Version() >= 4 {
450+ k.SendRepeatInfo(int32(s.repeatRate), int32(s.repeatDelay))
451+ }
452+ }
453+ }
454+ s.log.Info().Int("rate", s.repeatRate).Int("delay", s.repeatDelay).Msg("repeat updated")
455+ return
456+ }
444 fd, size, err := keymapFile(c.Keymap)457 fd, size, err := keymapFile(c.Keymap)
445 if err != nil {458 if err != nil {
446 s.log.Warn().Err(err).Msg("keymap update failed")459 s.log.Warn().Err(err).Msg("keymap update failed")
@@ -450,7 +463,6 @@ func (s *Server) setKeymap(c ports.SetKeymap) {
450 unix.Close(s.keymapFD)463 unix.Close(s.keymapFD)
451 }464 }
452 s.keymapFD, s.keymapSize = fd, size465 s.keymapFD, s.keymapSize = fd, size
453- s.repeatRate, s.repeatDelay = c.RepeatRate, c.RepeatDelay
454 focused := s.focused466 focused := s.focused
455 s.changeFocus(0)467 s.changeFocus(0)
456 s.heldKeys = nil468 s.heldKeys = nil
M internal/app/app.go +28 -13
@@ -194,6 +194,12 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
194194 }
195195 cancel()
196196 workers.Wait()
197+ // A keymap the input goroutine never took is still ours to free.
198+ select {
199+ case km := <-keymaps:
200+ km.Close()
201+ default:
202+ }
197203 for len(done) > 0 {
198204 err := <-done
199205 if result == nil && err != nil {
@@ -206,9 +212,9 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
206212 return result
207213 }
208214
209-// relayConfig forwards reloads to core. When keyboard settings change it builds the
210-// new keymap, hands it to the input goroutine and sends it to clients, so layout and
211-// repeat changes apply live. A keymap that fails to build keeps the previous one.
215+// relayConfig forwards reloads to core. A layout change builds a new keymap, hands it
216+// to the input goroutine and sends it to clients; a repeat-only change just updates
217+// clients. A keymap that fails to build keeps the previous layout.
212218 func relayConfig(ctx context.Context, cur ports.Config, in <-chan ports.ConfigChanged, out chan<- ports.ConfigChanged, keymaps chan *xkb.Keymap, commands chan<- ports.ClientCommand, log zerowrap.Logger) {
213219 kb := cur.Keyboard
214220 for {
@@ -219,13 +225,17 @@ func relayConfig(ctx context.Context, cur ports.Config, in <-chan ports.ConfigCh
219225 case ev = <-in:
220226 }
221227 next := ev.Config.Keyboard
222- if next.Layout != kb.Layout || next.Variant != kb.Variant || next.Options != kb.Options || next.RepeatRate != kb.RepeatRate || next.RepeatDelay != kb.RepeatDelay {
228+ layoutChanged := next.Layout != kb.Layout || next.Variant != kb.Variant || next.Options != kb.Options
229+ repeatChanged := next.RepeatRate != kb.RepeatRate || next.RepeatDelay != kb.RepeatDelay
230+ cmd := ports.SetKeymap{RepeatRate: next.RepeatRate, RepeatDelay: next.RepeatDelay}
231+ if layoutChanged {
223232 km, err := xkb.New(xkb.RMLVO{Layout: next.Layout, Variant: next.Variant, Options: next.Options})
224233 if err != nil {
225- log.Warn().Err(err).Str("layout", next.Layout).Str("variant", next.Variant).Msg("keymap rejected; keeping previous keyboard")
234+ log.Warn().Err(err).Str("layout", next.Layout).Str("variant", next.Variant).Msg("keymap rejected; keeping previous layout")
226235 ev.Config.Keyboard.Layout, ev.Config.Keyboard.Variant, ev.Config.Keyboard.Options = kb.Layout, kb.Variant, kb.Options
236+ layoutChanged = false
227237 } else {
228- text := km.String()
238+ cmd.Keymap = km.String()
229239 // Drop a keymap the input goroutine has not taken yet; the newest wins.
230240 select {
231241 case old := <-keymaps:
@@ -238,15 +248,20 @@ func relayConfig(ctx context.Context, cur ports.Config, in <-chan ports.ConfigCh
238248 km.Close()
239249 return
240250 }
241- select {
242- case commands <- ports.SetKeymap{Keymap: text, RepeatRate: next.RepeatRate, RepeatDelay: next.RepeatDelay}:
243- case <-ctx.Done():
244- return
245- }
246- kb = next
247- log.Info().Str("layout", next.Layout).Str("variant", next.Variant).Str("options", next.Options).Msg("keyboard reloaded")
248251 }
249252 }
253+ if layoutChanged || repeatChanged {
254+ select {
255+ case commands <- cmd:
256+ case <-ctx.Done():
257+ return
258+ }
259+ if layoutChanged {
260+ kb.Layout, kb.Variant, kb.Options = next.Layout, next.Variant, next.Options
261+ }
262+ kb.RepeatRate, kb.RepeatDelay = next.RepeatRate, next.RepeatDelay
263+ log.Info().Str("layout", kb.Layout).Str("variant", kb.Variant).Str("options", kb.Options).Int("rate", kb.RepeatRate).Int("delay", kb.RepeatDelay).Bool("keymap", layoutChanged).Msg("keyboard reloaded")
264+ }
250265 select {
251266 case out <- ev:
252267 case <-ctx.Done():
@@ -194,6 +194,12 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
194 }194 }
195 cancel()195 cancel()
196 workers.Wait()196 workers.Wait()
197+ // A keymap the input goroutine never took is still ours to free.
198+ select {
199+ case km := <-keymaps:
200+ km.Close()
201+ default:
202+ }
197 for len(done) > 0 {203 for len(done) > 0 {
198 err := <-done204 err := <-done
199 if result == nil && err != nil {205 if result == nil && err != nil {
@@ -206,9 +212,9 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
206 return result212 return result
207 }213 }
208 214
209-// relayConfig forwards reloads to core. When keyboard settings change it builds the215+// relayConfig forwards reloads to core. A layout change builds a new keymap, hands it
210-// new keymap, hands it to the input goroutine and sends it to clients, so layout and216+// to the input goroutine and sends it to clients; a repeat-only change just updates
211-// repeat changes apply live. A keymap that fails to build keeps the previous one.217+// clients. A keymap that fails to build keeps the previous layout.
212 func relayConfig(ctx context.Context, cur ports.Config, in <-chan ports.ConfigChanged, out chan<- ports.ConfigChanged, keymaps chan *xkb.Keymap, commands chan<- ports.ClientCommand, log zerowrap.Logger) {218 func relayConfig(ctx context.Context, cur ports.Config, in <-chan ports.ConfigChanged, out chan<- ports.ConfigChanged, keymaps chan *xkb.Keymap, commands chan<- ports.ClientCommand, log zerowrap.Logger) {
213 kb := cur.Keyboard219 kb := cur.Keyboard
214 for {220 for {
@@ -219,13 +225,17 @@ func relayConfig(ctx context.Context, cur ports.Config, in <-chan ports.ConfigCh
219 case ev = <-in:225 case ev = <-in:
220 }226 }
221 next := ev.Config.Keyboard227 next := ev.Config.Keyboard
222- if next.Layout != kb.Layout || next.Variant != kb.Variant || next.Options != kb.Options || next.RepeatRate != kb.RepeatRate || next.RepeatDelay != kb.RepeatDelay {228+ layoutChanged := next.Layout != kb.Layout || next.Variant != kb.Variant || next.Options != kb.Options
229+ repeatChanged := next.RepeatRate != kb.RepeatRate || next.RepeatDelay != kb.RepeatDelay
230+ cmd := ports.SetKeymap{RepeatRate: next.RepeatRate, RepeatDelay: next.RepeatDelay}
231+ if layoutChanged {
223 km, err := xkb.New(xkb.RMLVO{Layout: next.Layout, Variant: next.Variant, Options: next.Options})232 km, err := xkb.New(xkb.RMLVO{Layout: next.Layout, Variant: next.Variant, Options: next.Options})
224 if err != nil {233 if err != nil {
225- log.Warn().Err(err).Str("layout", next.Layout).Str("variant", next.Variant).Msg("keymap rejected; keeping previous keyboard")234+ log.Warn().Err(err).Str("layout", next.Layout).Str("variant", next.Variant).Msg("keymap rejected; keeping previous layout")
226 ev.Config.Keyboard.Layout, ev.Config.Keyboard.Variant, ev.Config.Keyboard.Options = kb.Layout, kb.Variant, kb.Options235 ev.Config.Keyboard.Layout, ev.Config.Keyboard.Variant, ev.Config.Keyboard.Options = kb.Layout, kb.Variant, kb.Options
236+ layoutChanged = false
227 } else {237 } else {
228- text := km.String()238+ cmd.Keymap = km.String()
229 // Drop a keymap the input goroutine has not taken yet; the newest wins.239 // Drop a keymap the input goroutine has not taken yet; the newest wins.
230 select {240 select {
231 case old := <-keymaps:241 case old := <-keymaps:
@@ -238,15 +248,20 @@ func relayConfig(ctx context.Context, cur ports.Config, in <-chan ports.ConfigCh
238 km.Close()248 km.Close()
239 return249 return
240 }250 }
241- select {
242- case commands <- ports.SetKeymap{Keymap: text, RepeatRate: next.RepeatRate, RepeatDelay: next.RepeatDelay}:
243- case <-ctx.Done():
244- return
245- }
246- kb = next
247- log.Info().Str("layout", next.Layout).Str("variant", next.Variant).Str("options", next.Options).Msg("keyboard reloaded")
248 }251 }
249 }252 }
253+ if layoutChanged || repeatChanged {
254+ select {
255+ case commands <- cmd:
256+ case <-ctx.Done():
257+ return
258+ }
259+ if layoutChanged {
260+ kb.Layout, kb.Variant, kb.Options = next.Layout, next.Variant, next.Options
261+ }
262+ kb.RepeatRate, kb.RepeatDelay = next.RepeatRate, next.RepeatDelay
263+ log.Info().Str("layout", kb.Layout).Str("variant", kb.Variant).Str("options", kb.Options).Int("rate", kb.RepeatRate).Int("delay", kb.RepeatDelay).Bool("keymap", layoutChanged).Msg("keyboard reloaded")
264+ }
250 select {265 select {
251 case out <- ev:266 case out <- ev:
252 case <-ctx.Done():267 case <-ctx.Done():
M internal/app/app_test.go +15 -3
@@ -284,17 +284,29 @@ func TestRelayConfigKeyboard(t *testing.T) {
284284 km := <-keymaps
285285 defer km.Close()
286286 cmd := (<-commands).(ports.SetKeymap)
287- if cmd.RepeatRate != 40 || !strings.Contains(cmd.Keymap, "fr") {
287+ if cmd.RepeatRate != 40 || !strings.Contains(cmd.Keymap, "xkb_keymap") {
288288 t.Fatalf("keymap command: rate=%d", cmd.RepeatRate)
289289 }
290290 if code, _, ok := km.KeycodeFor("a"); !ok || code != 16 {
291291 t.Fatalf("fr keymap: a at %d", code)
292292 }
293293
294+ // Repeat-only changes update clients without a new keymap.
295+ next.Keyboard.RepeatDelay = 300
296+ in <- ports.ConfigChanged{Config: next}
297+ <-out
298+ if cmd := (<-commands).(ports.SetKeymap); cmd.Keymap != "" || cmd.RepeatDelay != 300 || len(keymaps) != 0 {
299+ t.Fatalf("repeat-only: keymap=%t delay=%d", cmd.Keymap != "", cmd.RepeatDelay)
300+ }
301+
302+ // A bad layout keeps the previous one; its repeat change still applies.
294303 bad := next
295- bad.Keyboard.Layout = "no-such-layout"
304+ bad.Keyboard.Layout, bad.Keyboard.RepeatRate = "no-such-layout", 50
296305 in <- ports.ConfigChanged{Config: bad}
297- if got := <-out; got.Config.Keyboard.Layout != "fr" || len(keymaps) != 0 {
306+ if got := <-out; got.Config.Keyboard.Layout != "fr" || got.Config.Keyboard.RepeatRate != 50 || len(keymaps) != 0 {
298307 t.Fatalf("bad layout applied: %+v", got.Config.Keyboard)
299308 }
309+ if cmd := (<-commands).(ports.SetKeymap); cmd.Keymap != "" || cmd.RepeatRate != 50 {
310+ t.Fatalf("bad layout command: keymap=%t rate=%d", cmd.Keymap != "", cmd.RepeatRate)
311+ }
300312 }
@@ -284,17 +284,29 @@ func TestRelayConfigKeyboard(t *testing.T) {
284 km := <-keymaps284 km := <-keymaps
285 defer km.Close()285 defer km.Close()
286 cmd := (<-commands).(ports.SetKeymap)286 cmd := (<-commands).(ports.SetKeymap)
287- if cmd.RepeatRate != 40 || !strings.Contains(cmd.Keymap, "fr") {287+ if cmd.RepeatRate != 40 || !strings.Contains(cmd.Keymap, "xkb_keymap") {
288 t.Fatalf("keymap command: rate=%d", cmd.RepeatRate)288 t.Fatalf("keymap command: rate=%d", cmd.RepeatRate)
289 }289 }
290 if code, _, ok := km.KeycodeFor("a"); !ok || code != 16 {290 if code, _, ok := km.KeycodeFor("a"); !ok || code != 16 {
291 t.Fatalf("fr keymap: a at %d", code)291 t.Fatalf("fr keymap: a at %d", code)
292 }292 }
293 293
294+ // Repeat-only changes update clients without a new keymap.
295+ next.Keyboard.RepeatDelay = 300
296+ in <- ports.ConfigChanged{Config: next}
297+ <-out
298+ if cmd := (<-commands).(ports.SetKeymap); cmd.Keymap != "" || cmd.RepeatDelay != 300 || len(keymaps) != 0 {
299+ t.Fatalf("repeat-only: keymap=%t delay=%d", cmd.Keymap != "", cmd.RepeatDelay)
300+ }
301+
302+ // A bad layout keeps the previous one; its repeat change still applies.
294 bad := next303 bad := next
295- bad.Keyboard.Layout = "no-such-layout"304+ bad.Keyboard.Layout, bad.Keyboard.RepeatRate = "no-such-layout", 50
296 in <- ports.ConfigChanged{Config: bad}305 in <- ports.ConfigChanged{Config: bad}
297- if got := <-out; got.Config.Keyboard.Layout != "fr" || len(keymaps) != 0 {306+ if got := <-out; got.Config.Keyboard.Layout != "fr" || got.Config.Keyboard.RepeatRate != 50 || len(keymaps) != 0 {
298 t.Fatalf("bad layout applied: %+v", got.Config.Keyboard)307 t.Fatalf("bad layout applied: %+v", got.Config.Keyboard)
299 }308 }
309+ if cmd := (<-commands).(ports.SetKeymap); cmd.Keymap != "" || cmd.RepeatRate != 50 {
310+ t.Fatalf("bad layout command: keymap=%t rate=%d", cmd.Keymap != "", cmd.RepeatRate)
311+ }
300 }312 }
M internal/ports/messages.go +2 -1
@@ -181,7 +181,8 @@ type ForwardKey struct {
181181
182182 func (ForwardKey) clientCommand() {}
183183
184-// SetKeymap carries app → wayland a new xkb keymap (text format) and repeat info.
184+// SetKeymap carries app → wayland repeat info and, unless Keymap is empty, a new
185+// xkb keymap in text format.
185186 type SetKeymap struct {
186187 Keymap string
187188 RepeatRate, RepeatDelay int
@@ -181,7 +181,8 @@ type ForwardKey struct {
181 181
182 func (ForwardKey) clientCommand() {}182 func (ForwardKey) clientCommand() {}
183 183
184-// SetKeymap carries app → wayland a new xkb keymap (text format) and repeat info.184+// SetKeymap carries app → wayland repeat info and, unless Keymap is empty, a new
185+// xkb keymap in text format.
185 type SetKeymap struct {186 type SetKeymap struct {
186 Keymap string187 Keymap string
187 RepeatRate, RepeatDelay int188 RepeatRate, RepeatDelay int

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 23

Tags 10

↑↓ moveenter openesc close