fix(core): drop releases of keys a bind consumed before shortcuts inhibit began
- commit
- 9fd9758f2e139003b874a02d1e5214a4c288aef8
- author
- brice · · 2026-09-26 12:35 UTC
- parent
- bb97d3a
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
3 files · +55 -6
M internal/core/core.go +16 -4
| @@ -852,6 +852,12 @@ func (c *Core) Run(ctx context.Context) error { | |||
| 852 | 852 | // no bind runs (emergency quit and VT switch are handled by | |
| 853 | 853 | // input before core). | |
| 854 | 854 | if c.inhibiting != 0 && c.inhibiting == c.keyboardFocus() { | |
| 855 | + | if held := heldKey(key); !key.Pressed && c.pressed[held] { | |
| 856 | + | // Its press ran a bind before inhibiting began: the | |
| 857 | + | // window never saw it. | |
| 858 | + | delete(c.pressed, held) | |
| 859 | + | continue | |
| 860 | + | } | |
| 855 | 861 | if err := c.command(ctx, ports.ForwardKey{ID: c.inhibiting, Key: key}); err != nil { | |
| 856 | 862 | return nil | |
| 857 | 863 | } | |
| @@ -868,10 +874,7 @@ func (c *Core) Run(ctx context.Context) error { | |||
| 868 | 874 | action, bound = c.binds[binding{key: "code:" + strconv.FormatUint(uint64(key.Keycode), 10), mods: key.Mods}] | |
| 869 | 875 | } | |
| 870 | 876 | // Track presses by physical key: Shift may be released before the key. | |
| 871 | - | held := name | |
| 872 | - | if key.Keycode != 0 { | |
| 873 | - | held = "#" + strconv.FormatUint(uint64(key.Keycode), 10) | |
| 874 | - | } | |
| 877 | + | held := heldKey(key) | |
| 875 | 878 | if !key.Pressed { | |
| 876 | 879 | consumed := c.pressed[held] | |
| 877 | 880 | delete(c.pressed, held) | |
| @@ -962,3 +965,12 @@ func (c *Core) activate(ctx context.Context, id WindowID) error { | |||
| 962 | 965 | c.focusScreen = c.screenIndex(s.name()) | |
| 963 | 966 | return c.afterShow(ctx, before) | |
| 964 | 967 | } | |
| 968 | + | ||
| 969 | + | // heldKey names a key for press tracking: by physical key, since Shift | |
| 970 | + | // may be released before the key. | |
| 971 | + | func heldKey(key ports.KeyEvent) string { | |
| 972 | + | if key.Keycode != 0 { | |
| 973 | + | return "#" + strconv.FormatUint(uint64(key.Keycode), 10) | |
| 974 | + | } | |
| 975 | + | return keyName(key.Keysym) | |
| 976 | + | } | |
| @@ -852,6 +852,12 @@ func (c *Core) Run(ctx context.Context) error { | |||||
| 852 | // no bind runs (emergency quit and VT switch are handled by | 852 | // no bind runs (emergency quit and VT switch are handled by | ||
| 853 | // input before core). | 853 | // input before core). | ||
| 854 | if c.inhibiting != 0 && c.inhibiting == c.keyboardFocus() { | 854 | if c.inhibiting != 0 && c.inhibiting == c.keyboardFocus() { | ||
| 855 | + | if held := heldKey(key); !key.Pressed && c.pressed[held] { | |||
| 856 | + | // Its press ran a bind before inhibiting began: the | |||
| 857 | + | // window never saw it. | |||
| 858 | + | delete(c.pressed, held) | |||
| 859 | + | continue | |||
| 860 | + | } | |||
| 855 | if err := c.command(ctx, ports.ForwardKey{ID: c.inhibiting, Key: key}); err != nil { | 861 | if err := c.command(ctx, ports.ForwardKey{ID: c.inhibiting, Key: key}); err != nil { | ||
| 856 | return nil | 862 | return nil | ||
| 857 | } | 863 | } | ||
| @@ -868,10 +874,7 @@ func (c *Core) Run(ctx context.Context) error { | |||||
| 868 | action, bound = c.binds[binding{key: "code:" + strconv.FormatUint(uint64(key.Keycode), 10), mods: key.Mods}] | 874 | action, bound = c.binds[binding{key: "code:" + strconv.FormatUint(uint64(key.Keycode), 10), mods: key.Mods}] | ||
| 869 | } | 875 | } | ||
| 870 | // Track presses by physical key: Shift may be released before the key. | 876 | // Track presses by physical key: Shift may be released before the key. | ||
| 871 | - | held := name | 877 | + | held := heldKey(key) |
| 872 | - | if key.Keycode != 0 { | |||
| 873 | - | held = "#" + strconv.FormatUint(uint64(key.Keycode), 10) | |||
| 874 | - | } | |||
| 875 | if !key.Pressed { | 878 | if !key.Pressed { | ||
| 876 | consumed := c.pressed[held] | 879 | consumed := c.pressed[held] | ||
| 877 | delete(c.pressed, held) | 880 | delete(c.pressed, held) | ||
| @@ -962,3 +965,12 @@ func (c *Core) activate(ctx context.Context, id WindowID) error { | |||||
| 962 | c.focusScreen = c.screenIndex(s.name()) | 965 | c.focusScreen = c.screenIndex(s.name()) | ||
| 963 | return c.afterShow(ctx, before) | 966 | return c.afterShow(ctx, before) | ||
| 964 | } | 967 | } | ||
| 968 | + | ||||
| 969 | + | // heldKey names a key for press tracking: by physical key, since Shift | |||
| 970 | + | // may be released before the key. | |||
| 971 | + | func heldKey(key ports.KeyEvent) string { | |||
| 972 | + | if key.Keycode != 0 { | |||
| 973 | + | return "#" + strconv.FormatUint(uint64(key.Keycode), 10) | |||
| 974 | + | } | |||
| 975 | + | return keyName(key.Keysym) | |||
| 976 | + | } | |||
M internal/core/inhibit_test.go +37 -0
| @@ -88,3 +88,40 @@ func TestShortcutsInhibit(t *testing.T) { | |||
| 88 | 88 | } | |
| 89 | 89 | } | |
| 90 | 90 | } | |
| 91 | + | ||
| 92 | + | // A key whose press ran a bind before inhibiting began is not released to | |
| 93 | + | // the window: it never saw the press. | |
| 94 | + | func TestShortcutsInhibitDropsBoundRelease(t *testing.T) { | |
| 95 | + | cfg := config.Defaults() | |
| 96 | + | input := make(chan ports.InputEvent, 4) | |
| 97 | + | client := make(chan ports.ClientEvent, 4) | |
| 98 | + | commands := make(chan ports.ClientCommand, 64) | |
| 99 | + | scenes := make(chan []ports.Scene, 1) | |
| 100 | + | spawn := make(chan ports.SpawnRequest, 4) | |
| 101 | + | output := make(chan ports.OutputEvent, 1) | |
| 102 | + | c, err := core.New(cfg, core.Channels{Input: input, Client: client, Output: output, Commands: commands, Scenes: scenes, Spawn: spawn}) | |
| 103 | + | if err != nil { | |
| 104 | + | t.Fatal(err) | |
| 105 | + | } | |
| 106 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 107 | + | done := make(chan error, 1) | |
| 108 | + | go func() { done <- c.Run(ctx) }() | |
| 109 | + | defer func() { cancel(); receive(t, done) }() | |
| 110 | + | output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 100, Height: 80}} | |
| 111 | + | scene(t, scenes) | |
| 112 | + | client <- ports.WindowMapped{ID: 1} | |
| 113 | + | scene(t, scenes) | |
| 114 | + | press := ports.KeyEvent{Keysym: "Return", Keycode: 36, Mods: ports.ModSuper, Pressed: true} | |
| 115 | + | input <- press | |
| 116 | + | receive(t, spawn) | |
| 117 | + | client <- ports.ShortcutsInhibit{Window: 1, Active: true} | |
| 118 | + | commandOf[ports.ShortcutsInhibitState](t, commands) | |
| 119 | + | release := press | |
| 120 | + | release.Pressed = false | |
| 121 | + | input <- release | |
| 122 | + | other := ports.KeyEvent{Keysym: "a", Keycode: 38, Pressed: true} | |
| 123 | + | input <- other | |
| 124 | + | if v := commandOf[ports.ForwardKey](t, commands); v.Key != other { | |
| 125 | + | t.Fatalf("forwarded %+v before %+v", v.Key, other) | |
| 126 | + | } | |
| 127 | + | } | |
| @@ -88,3 +88,40 @@ func TestShortcutsInhibit(t *testing.T) { | |||||
| 88 | } | 88 | } | ||
| 89 | } | 89 | } | ||
| 90 | } | 90 | } | ||
| 91 | + | ||||
| 92 | + | // A key whose press ran a bind before inhibiting began is not released to | |||
| 93 | + | // the window: it never saw the press. | |||
| 94 | + | func TestShortcutsInhibitDropsBoundRelease(t *testing.T) { | |||
| 95 | + | cfg := config.Defaults() | |||
| 96 | + | input := make(chan ports.InputEvent, 4) | |||
| 97 | + | client := make(chan ports.ClientEvent, 4) | |||
| 98 | + | commands := make(chan ports.ClientCommand, 64) | |||
| 99 | + | scenes := make(chan []ports.Scene, 1) | |||
| 100 | + | spawn := make(chan ports.SpawnRequest, 4) | |||
| 101 | + | output := make(chan ports.OutputEvent, 1) | |||
| 102 | + | c, err := core.New(cfg, core.Channels{Input: input, Client: client, Output: output, Commands: commands, Scenes: scenes, Spawn: spawn}) | |||
| 103 | + | if err != nil { | |||
| 104 | + | t.Fatal(err) | |||
| 105 | + | } | |||
| 106 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 107 | + | done := make(chan error, 1) | |||
| 108 | + | go func() { done <- c.Run(ctx) }() | |||
| 109 | + | defer func() { cancel(); receive(t, done) }() | |||
| 110 | + | output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 100, Height: 80}} | |||
| 111 | + | scene(t, scenes) | |||
| 112 | + | client <- ports.WindowMapped{ID: 1} | |||
| 113 | + | scene(t, scenes) | |||
| 114 | + | press := ports.KeyEvent{Keysym: "Return", Keycode: 36, Mods: ports.ModSuper, Pressed: true} | |||
| 115 | + | input <- press | |||
| 116 | + | receive(t, spawn) | |||
| 117 | + | client <- ports.ShortcutsInhibit{Window: 1, Active: true} | |||
| 118 | + | commandOf[ports.ShortcutsInhibitState](t, commands) | |||
| 119 | + | release := press | |||
| 120 | + | release.Pressed = false | |||
| 121 | + | input <- release | |||
| 122 | + | other := ports.KeyEvent{Keysym: "a", Keycode: 38, Pressed: true} | |||
| 123 | + | input <- other | |||
| 124 | + | if v := commandOf[ports.ForwardKey](t, commands); v.Key != other { | |||
| 125 | + | t.Fatalf("forwarded %+v before %+v", v.Key, other) | |||
| 126 | + | } | |||
| 127 | + | } | |||
M internal/ports/messages.go +2 -2
| @@ -388,8 +388,6 @@ type ConfigChanged struct{ Config Config } | |||
| 388 | 388 | // ClientCommand carries core → wayland commands. | |
| 389 | 389 | type ClientCommand interface{ clientCommand() } | |
| 390 | 390 | ||
| 391 | - | // ConfigureWindow carries core → wayland geometry and state. Output is the | |
| 392 | - | // connector showing the window, empty while it is hidden. | |
| 393 | 391 | // ShortcutsInhibitState carries core → wayland whether a window's | |
| 394 | 392 | // shortcuts inhibitor is in effect (it has keyboard focus). | |
| 395 | 393 | type ShortcutsInhibitState struct { | |
| @@ -399,6 +397,8 @@ type ShortcutsInhibitState struct { | |||
| 399 | 397 | ||
| 400 | 398 | func (ShortcutsInhibitState) clientCommand() {} | |
| 401 | 399 | ||
| 400 | + | // ConfigureWindow carries core → wayland geometry and state. Output is the | |
| 401 | + | // connector showing the window, empty while it is hidden. | |
| 402 | 402 | type ConfigureWindow struct { | |
| 403 | 403 | ID WindowID | |
| 404 | 404 | Width, Height int | |
| @@ -388,8 +388,6 @@ type ConfigChanged struct{ Config Config } | |||||
| 388 | // ClientCommand carries core → wayland commands. | 388 | // ClientCommand carries core → wayland commands. | ||
| 389 | type ClientCommand interface{ clientCommand() } | 389 | type ClientCommand interface{ clientCommand() } | ||
| 390 | 390 | ||||
| 391 | - | // ConfigureWindow carries core → wayland geometry and state. Output is the | |||
| 392 | - | // connector showing the window, empty while it is hidden. | |||
| 393 | // ShortcutsInhibitState carries core → wayland whether a window's | 391 | // ShortcutsInhibitState carries core → wayland whether a window's | ||
| 394 | // shortcuts inhibitor is in effect (it has keyboard focus). | 392 | // shortcuts inhibitor is in effect (it has keyboard focus). | ||
| 395 | type ShortcutsInhibitState struct { | 393 | type ShortcutsInhibitState struct { | ||
| @@ -399,6 +397,8 @@ type ShortcutsInhibitState struct { | |||||
| 399 | 397 | ||||
| 400 | func (ShortcutsInhibitState) clientCommand() {} | 398 | func (ShortcutsInhibitState) clientCommand() {} | ||
| 401 | 399 | ||||
| 400 | + | // ConfigureWindow carries core → wayland geometry and state. Output is the | |||
| 401 | + | // connector showing the window, empty while it is hidden. | |||
| 402 | type ConfigureWindow struct { | 402 | type ConfigureWindow struct { | ||
| 403 | ID WindowID | 403 | ID WindowID | ||
| 404 | Width, Height int | 404 | Width, Height int | ||