Skip to content

Commit 16bc38f

Browse files
Fix stale keysym entries when modifier changes while key is held (#4747)
`KeyboardStateTracker` used the keysym from the key-up event to erase from `pressed_keysyms`, but that keysym can differ from what was stored at key-down when a modifier changes mid-hold (e.g. `1` down → Shift down → key-up reports `XKB_KEY_exclam`, leaving `XKB_KEY_1` stranded). Related: #4746 ## What's new? - **`scancode_to_keysym` map replaces the two separate sets** (`pressed_keysyms` / `pressed_scancodes`). Scancode is the stable identity of a physical key; the map stores the keysym recorded at key-down against it. - **Key-up erases by scancode** (`scancode_to_keysym.erase(scancode)`), so mismatched key-up keysyms caused by held modifiers never leave stale entries. - **Shift transitions mutate map values in-place** via a range loop applying `xkb_keysym_to_upper`/`xkb_keysym_to_lower`, replacing the previous set-rebuild approach. - **`keysym_is_pressed`** uses `std::ranges::any_of` over map values; `scancode_is_pressed` uses `map.contains`. The linear scan for keysyms is inconsequential given the physical bound on simultaneously held keys (≤ ~20). - **New test** `key_up_clears_key_when_modifier_changed_while_held` exercises the exact failure scenario: `1` down → Shift down → `!` up → neither `XKB_KEY_1` nor `XKB_KEY_exclam` remains pressed. ## How to test Run the `KeyboardStateTrackerTest` suite: ``` ./bin/mir_unit_tests.bin --gtest_filter="KeyboardStateTrackerTest.*" ``` All 17 tests should pass, including the new regression test. ## Checklist - [x] Tests added and pass - [x] Adequate documentation added - [ ] ~(optional) Added Screenshots or videos~
2 parents 13bf364 + e856a50 commit 16bc38f

File tree

3 files changed

+51
-21
lines changed

3 files changed

+51
-21
lines changed

src/server/frontend_wayland/keyboard_state_tracker.cpp

Lines changed: 17 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020

2121
#include <xkbcommon/xkbcommon-keysyms.h>
2222

23-
#include <ranges>
23+
#include <algorithm>
2424

2525
namespace mf = mir::frontend;
2626

@@ -40,7 +40,7 @@ bool mf::KeyboardStateTracker::process(MirEvent const& event)
4040
auto const action = key_event->action();
4141
auto const modifiers = key_event->modifiers();
4242

43-
auto& [pressed_keysyms, pressed_scancodes, shift_state] = device_states[input_event->device_id()];
43+
auto& [scancode_to_keysym, shift_state] = device_states[input_event->device_id()];
4444

4545
auto const prev_shift_state = shift_state;
4646
shift_state = modifiers & (mir_input_event_modifier_shift | mir_input_event_modifier_shift_left |
@@ -49,47 +49,46 @@ bool mf::KeyboardStateTracker::process(MirEvent const& event)
4949
auto processed = false;
5050
if (action == mir_keyboard_action_down)
5151
{
52-
pressed_keysyms.insert(keysym);
53-
pressed_scancodes.insert(scancode);
52+
scancode_to_keysym[scancode] = keysym;
5453
processed = true;
5554
}
5655
else if (action == mir_keyboard_action_up)
5756
{
58-
pressed_keysyms.erase(keysym);
59-
pressed_scancodes.erase(scancode);
57+
// Remove by scancode so that a mismatched key-up keysym (caused by a
58+
// modifier change while the key was held) does not leave stale entries.
59+
scancode_to_keysym.erase(scancode);
6060
processed = true;
6161
}
6262

6363
// Transitioned from no shift to at least one shift
6464
if (prev_shift_state == 0 && shift_state != 0)
6565
{
66-
auto const uppercase =
67-
std::ranges::views::transform(pressed_keysyms, [](auto key) { return xkb_keysym_to_upper(key); });
68-
pressed_keysyms = std::unordered_set<xkb_keysym_t>(uppercase.begin(), uppercase.end());
66+
for (auto& [sc, ks] : scancode_to_keysym)
67+
ks = xkb_keysym_to_upper(ks);
6968
}
7069
else if (prev_shift_state != 0 && shift_state == 0)
7170
{
7271
// Transitioned from at least one shift to no shift
73-
auto const lowercase =
74-
std::ranges::views::transform(pressed_keysyms, [](auto key) { return xkb_keysym_to_lower(key); });
75-
pressed_keysyms = std::unordered_set<xkb_keysym_t>(lowercase.begin(), lowercase.end());
72+
for (auto& [sc, ks] : scancode_to_keysym)
73+
ks = xkb_keysym_to_lower(ks);
7674
}
7775

7876
return processed;
7977
}
8078

8179
auto mf::KeyboardStateTracker::keysym_is_pressed(MirInputDeviceId device, xkb_keysym_t keysym) const -> bool
8280
{
83-
if(!device_states.contains(device)) return false;
81+
if (!device_states.contains(device))
82+
return false;
8483

85-
auto const& [pressed_keysyms, _, __] = device_states.at(device);
86-
return pressed_keysyms.contains(keysym);
84+
return std::ranges::any_of(
85+
device_states.at(device).scancode_to_keysym, [keysym](auto const& pair) { return pair.second == keysym; });
8786
}
8887

8988
auto mf::KeyboardStateTracker::scancode_is_pressed(MirInputDeviceId device, int32_t scancode) const -> bool
9089
{
91-
if(!device_states.contains(device)) return false;
90+
if (!device_states.contains(device))
91+
return false;
9292

93-
auto const& [_, pressed_scancodes, __] = device_states.at(device);
94-
return pressed_scancodes.contains(scancode);
93+
return device_states.at(device).scancode_to_keysym.contains(scancode);
9594
}

src/server/frontend_wayland/keyboard_state_tracker.h

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919

2020
#include <mir/events/event.h>
2121

22-
#include <unordered_set>
22+
#include <unordered_map>
2323

2424
namespace mir
2525
{
@@ -40,6 +40,11 @@ namespace frontend
4040
/// uppercase equivalents, and when all Shift keys are released they are
4141
/// demoted back. This keeps the stored keysyms consistent with what the
4242
/// keyboard layer reports as the logical key for subsequent events.
43+
///
44+
/// Keysyms are stored per scancode so that a key-up event always removes the
45+
/// keysym that was recorded at key-down, regardless of any modifier changes
46+
/// that occurred while the key was held (e.g. pressing Shift while holding
47+
/// a digit key).
4348
class KeyboardStateTracker
4449
{
4550
public:
@@ -56,8 +61,9 @@ class KeyboardStateTracker
5661
private:
5762
struct DeviceState
5863
{
59-
std::unordered_set<xkb_keysym_t> pressed_keysyms;
60-
std::unordered_set<int32_t> pressed_scancodes;
64+
/// Maps each currently-pressed scancode to the keysym that was recorded
65+
/// when it was pressed (updated on shift-state transitions).
66+
std::unordered_map<uint32_t, uint32_t> scancode_to_keysym;
6167
MirInputEventModifiers shift_state{0};
6268
};
6369

tests/unit-tests/frontend_wayland/test_keyboard_state_tracker.cpp

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ namespace
3333

3434
/// Based on standard US QWERTY layout (PC keyboard / evdev scancodes)
3535

36+
constexpr uint32_t key_1_scancode = 2;
3637
constexpr uint32_t a_scancode = 30;
3738
constexpr uint32_t b_scancode = 48;
3839

@@ -306,4 +307,28 @@ TEST_F(KeyboardStateTrackerTest, shift_release_on_one_device_does_not_demote_key
306307
EXPECT_TRUE(tracker.keysym_is_pressed(other_device_id, XKB_KEY_A));
307308
EXPECT_FALSE(tracker.keysym_is_pressed(other_device_id, XKB_KEY_a));
308309
}
310+
311+
TEST_F(KeyboardStateTrackerTest, key_up_clears_key_when_modifier_changed_while_held)
312+
{
313+
// Simulate: '1' pressed (keysym = XKB_KEY_1), then Shift pressed, then '1'
314+
// released while Shift is held. The key-up event reports XKB_KEY_exclam
315+
// ('!') because Shift is active. The tracker must still clear the pressed
316+
// state using the stored scancode rather than the key-up keysym.
317+
tracker.process(*key_down(XKB_KEY_1, key_1_scancode));
318+
EXPECT_TRUE(tracker.keysym_is_pressed(device_id, XKB_KEY_1));
319+
EXPECT_TRUE(tracker.scancode_is_pressed(device_id, key_1_scancode));
320+
321+
tracker.process(*key_down(XKB_KEY_Shift_L, shift_l_scancode));
322+
323+
// XKB_KEY_1 has no uppercase equivalent so it remains as XKB_KEY_1
324+
EXPECT_TRUE(tracker.keysym_is_pressed(device_id, XKB_KEY_1));
325+
EXPECT_TRUE(tracker.scancode_is_pressed(device_id, key_1_scancode));
326+
327+
// Key-up event reports XKB_KEY_exclam because Shift is still held
328+
tracker.process(*key_up(XKB_KEY_exclam, key_1_scancode));
329+
330+
EXPECT_FALSE(tracker.keysym_is_pressed(device_id, XKB_KEY_1));
331+
EXPECT_FALSE(tracker.keysym_is_pressed(device_id, XKB_KEY_exclam));
332+
EXPECT_FALSE(tracker.scancode_is_pressed(device_id, key_1_scancode));
333+
}
309334
} // namespace

0 commit comments

Comments
 (0)