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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
8 files · +211 -41
M cmd/nefertty/main.go +4 -1
| @@ -15,6 +15,7 @@ import ( | |||
| 15 | 15 | "github.com/bnema/nefertty/internal/adapters/config" | |
| 16 | 16 | "github.com/bnema/nefertty/internal/app" | |
| 17 | 17 | "github.com/bnema/nefertty/internal/logging" | |
| 18 | + | "github.com/bnema/nefertty/internal/ports" | |
| 18 | 19 | ) | |
| 19 | 20 | ||
| 20 | 21 | type usageError struct{ error } | |
| @@ -50,10 +51,12 @@ func run() error { | |||
| 50 | 51 | return err | |
| 51 | 52 | } | |
| 52 | 53 | path := config.DefaultPath() | |
| 54 | + | load := config.LoadDefault | |
| 53 | 55 | if len(os.Args) == 3 { | |
| 54 | 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 | 60 | if err != nil { | |
| 58 | 61 | fmt.Fprintln(os.Stderr, err) | |
| 59 | 62 | 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 err | 51 | 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 err | 62 | return err | ||
M internal/adapters/config/config.go +29 -17
| @@ -12,6 +12,7 @@ import ( | |||
| 12 | 12 | "sort" | |
| 13 | 13 | "strconv" | |
| 14 | 14 | "strings" | |
| 15 | + | "unicode" | |
| 15 | 16 | ||
| 16 | 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 | 158 | c.Binds[canon] = value | |
| 158 | 159 | continue | |
| 159 | 160 | } | |
| 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 | |
| 162 | 167 | } | |
| 163 | - | seen[key] = n | |
| 164 | 168 | if name, ok := strings.CutPrefix(key, "output."); ok { | |
| 165 | 169 | o := ports.OutputConfig{Name: name} | |
| 166 | 170 | if value == "off" { | |
| @@ -171,7 +175,7 @@ func parse(r io.Reader) (ports.Config, map[string]string, []Warning, error) { | |||
| 171 | 175 | } else if value != "preferred" { | |
| 172 | 176 | o.Mode = value | |
| 173 | 177 | } | |
| 174 | - | raw[key] = value | |
| 178 | + | override() | |
| 175 | 179 | if i, ok := outputs[name]; ok { | |
| 176 | 180 | c.Outputs[i] = o | |
| 177 | 181 | } else { | |
| @@ -184,7 +188,7 @@ func parse(r io.Reader) (ports.Config, map[string]string, []Warning, error) { | |||
| 184 | 188 | warn("%s: %v", key, err) | |
| 185 | 189 | continue | |
| 186 | 190 | } | |
| 187 | - | raw[key] = value | |
| 191 | + | override() | |
| 188 | 192 | } | |
| 189 | 193 | if err := scanner.Err(); err != nil { | |
| 190 | 194 | return c, raw, warnings, err | |
| @@ -195,15 +199,15 @@ func parse(r io.Reader) (ports.Config, map[string]string, []Warning, error) { | |||
| 195 | 199 | delete(c.Binds, combo) | |
| 196 | 200 | } | |
| 197 | 201 | } | |
| 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. | |
| 200 | 204 | combos := make([]string, 0, len(c.Binds)) | |
| 201 | 205 | for combo := range c.Binds { | |
| 202 | 206 | combos = append(combos, combo) | |
| 203 | 207 | } | |
| 204 | 208 | order := func(combo string) int { | |
| 205 | 209 | if n := seen["bind."+combo]; n > 0 { | |
| 206 | - | return n | |
| 210 | + | return -n | |
| 207 | 211 | } | |
| 208 | 212 | return math.MaxInt | |
| 209 | 213 | } | |
| @@ -215,7 +219,7 @@ func parse(r io.Reader) (ports.Config, map[string]string, []Warning, error) { | |||
| 215 | 219 | for _, combo := range combos { | |
| 216 | 220 | r, err := expandCmd(combo, c.Keyboard.CmdKey) | |
| 217 | 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 | 224 | if err != nil { | |
| 221 | 225 | if n := seen["bind."+combo]; n > 0 { | |
| @@ -442,26 +446,34 @@ func parseCombo(s string) (string, error) { | |||
| 442 | 446 | mods = append(mods, m) | |
| 443 | 447 | } | |
| 444 | 448 | 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 | |
| 446 | 454 | } | |
| 447 | 455 | ||
| 448 | 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 | 458 | 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) | |
| 453 | 465 | } | |
| 454 | - | return strings.ToLower(k) | |
| 466 | + | return strings.ToLower(k), nil | |
| 455 | 467 | } | |
| 456 | 468 | if name, ok := namedKeys[strings.ToLower(k)]; ok { | |
| 457 | - | return name | |
| 469 | + | return name, nil | |
| 458 | 470 | } | |
| 459 | 471 | if len(k) <= 3 && (k[0] == 'f' || k[0] == 'F') { | |
| 460 | 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 k | |
| 476 | + | return k, nil | |
| 465 | 477 | } | |
| 466 | 478 | ||
| 467 | 479 | // 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] = value | 158 | c.Binds[canon] = value | ||
| 158 | continue | 159 | 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 = value | 176 | o.Mode = value | ||
| 173 | } | 177 | } | ||
| 174 | - | raw[key] = value | 178 | + | override() |
| 175 | if i, ok := outputs[name]; ok { | 179 | if i, ok := outputs[name]; ok { | ||
| 176 | c.Outputs[i] = o | 180 | 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 | continue | 189 | continue | ||
| 186 | } | 190 | } | ||
| 187 | - | raw[key] = value | 191 | + | override() |
| 188 | } | 192 | } | ||
| 189 | if err := scanner.Err(); err != nil { | 193 | if err := scanner.Err(); err != nil { | ||
| 190 | return c, raw, warnings, err | 194 | 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 over | 202 | + | // 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 n | 210 | + | return -n |
| 207 | } | 211 | } | ||
| 208 | return math.MaxInt | 212 | 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)), "+"), nil | 449 | + | 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 name | 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) | |||
| 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 name | 469 | + | 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 k | 476 | + | 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) { | |||
| 158 | 158 | } | |
| 159 | 159 | ||
| 160 | 160 | func TestBindResolvedCollision(t *testing.T) { | |
| 161 | + | // The later line wins, like any duplicate key. | |
| 161 | 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 | 164 | t.Fatal(c.Binds) | |
| 164 | 165 | } | |
| 165 | - | if _, ok := c.Binds["Cmd+x"]; ok { | |
| 166 | + | if _, ok := c.Binds["Ctrl+x"]; ok { | |
| 166 | 167 | t.Fatal(c.Binds) | |
| 167 | 168 | } | |
| 168 | 169 | if _, ok := c.Binds["Cmd+Ctrl+y"]; ok { | |
| 169 | 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 | 195 | t.Fatal(w) | |
| 173 | 196 | } | |
| 174 | 197 | } | |
| @@ -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 ( | |||
| 5 | 5 | "os" | |
| 6 | 6 | "os/exec" | |
| 7 | 7 | "regexp" | |
| 8 | + | "slices" | |
| 8 | 9 | "strings" | |
| 9 | 10 | "testing" | |
| 10 | 11 | "time" | |
| @@ -118,6 +119,97 @@ func TestKeyboardKeymapProtocol(t *testing.T) { | |||
| 118 | 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 | 213 | func TestFootKeyboard(t *testing.T) { | |
| 122 | 214 | tool, err := exec.LookPath("foot") | |
| 123 | 215 | 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) { | |||
| 438 | 438 | return fd, uint32(n), nil | |
| 439 | 439 | } | |
| 440 | 440 | ||
| 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. | |
| 443 | 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 | 457 | fd, size, err := keymapFile(c.Keymap) | |
| 445 | 458 | if err != nil { | |
| 446 | 459 | s.log.Warn().Err(err).Msg("keymap update failed") | |
| @@ -450,7 +463,6 @@ func (s *Server) setKeymap(c ports.SetKeymap) { | |||
| 450 | 463 | unix.Close(s.keymapFD) | |
| 451 | 464 | } | |
| 452 | 465 | s.keymapFD, s.keymapSize = fd, size | |
| 453 | - | s.repeatRate, s.repeatDelay = c.RepeatRate, c.RepeatDelay | |
| 454 | 466 | focused := s.focused | |
| 455 | 467 | s.changeFocus(0) | |
| 456 | 468 | s.heldKeys = nil | |
| @@ -438,9 +438,22 @@ func keymapFile(keymap string) (int, uint32, error) { | |||||
| 438 | return fd, uint32(n), nil | 438 | return fd, uint32(n), nil | ||
| 439 | } | 439 | } | ||
| 440 | 440 | ||||
| 441 | - | // setKeymap sends a new keymap and repeat info to every keyboard. Held keys and | 441 | + | // 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, size | 465 | s.keymapFD, s.keymapSize = fd, size | ||
| 453 | - | s.repeatRate, s.repeatDelay = c.RepeatRate, c.RepeatDelay | |||
| 454 | focused := s.focused | 466 | focused := s.focused | ||
| 455 | s.changeFocus(0) | 467 | s.changeFocus(0) | ||
| 456 | s.heldKeys = nil | 468 | 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) | |||
| 194 | 194 | } | |
| 195 | 195 | cancel() | |
| 196 | 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 | 203 | for len(done) > 0 { | |
| 198 | 204 | err := <-done | |
| 199 | 205 | if result == nil && err != nil { | |
| @@ -206,9 +212,9 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent) | |||
| 206 | 212 | return result | |
| 207 | 213 | } | |
| 208 | 214 | ||
| 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. | |
| 212 | 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 | 219 | kb := cur.Keyboard | |
| 214 | 220 | for { | |
| @@ -219,13 +225,17 @@ func relayConfig(ctx context.Context, cur ports.Config, in <-chan ports.ConfigCh | |||
| 219 | 225 | case ev = <-in: | |
| 220 | 226 | } | |
| 221 | 227 | 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 | 232 | km, err := xkb.New(xkb.RMLVO{Layout: next.Layout, Variant: next.Variant, Options: next.Options}) | |
| 224 | 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 | 235 | ev.Config.Keyboard.Layout, ev.Config.Keyboard.Variant, ev.Config.Keyboard.Options = kb.Layout, kb.Variant, kb.Options | |
| 236 | + | layoutChanged = false | |
| 227 | 237 | } else { | |
| 228 | - | text := km.String() | |
| 238 | + | cmd.Keymap = km.String() | |
| 229 | 239 | // Drop a keymap the input goroutine has not taken yet; the newest wins. | |
| 230 | 240 | select { | |
| 231 | 241 | case old := <-keymaps: | |
| @@ -238,15 +248,20 @@ func relayConfig(ctx context.Context, cur ports.Config, in <-chan ports.ConfigCh | |||
| 238 | 248 | km.Close() | |
| 239 | 249 | 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 | 265 | select { | |
| 251 | 266 | case out <- ev: | |
| 252 | 267 | 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 := <-done | 204 | 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 result | 212 | return result | ||
| 207 | } | 213 | } | ||
| 208 | 214 | ||||
| 209 | - | // relayConfig forwards reloads to core. When keyboard settings change it builds the | 215 | + | // 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 and | 216 | + | // 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.Keyboard | 219 | 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.Keyboard | 227 | 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.Options | 235 | 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 | return | 249 | 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) { | |||
| 284 | 284 | km := <-keymaps | |
| 285 | 285 | defer km.Close() | |
| 286 | 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 | 288 | t.Fatalf("keymap command: rate=%d", cmd.RepeatRate) | |
| 289 | 289 | } | |
| 290 | 290 | if code, _, ok := km.KeycodeFor("a"); !ok || code != 16 { | |
| 291 | 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 | 303 | bad := next | |
| 295 | - | bad.Keyboard.Layout = "no-such-layout" | |
| 304 | + | bad.Keyboard.Layout, bad.Keyboard.RepeatRate = "no-such-layout", 50 | |
| 296 | 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 | 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 | } | |
| @@ -284,17 +284,29 @@ func TestRelayConfigKeyboard(t *testing.T) { | |||||
| 284 | km := <-keymaps | 284 | 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 := next | 303 | 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 { | |||
| 181 | 181 | ||
| 182 | 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 | 186 | type SetKeymap struct { | |
| 186 | 187 | Keymap string | |
| 187 | 188 | 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 string | 187 | Keymap string | ||
| 187 | RepeatRate, RepeatDelay int | 188 | RepeatRate, RepeatDelay int | ||