fix(macOS): make Mission Control and Spaces hotkeys work (#154)

This commit is contained in:
Seth Moore authored and GitHub committed 2026-09-30 10:34:17 -04:00
1 parent 90eef324ad
commit b814a3310c
4 files changed
+269 -20

No files matched your search

+119 -20
View File
@@ -285,6 +285,86 @@ namespace lvh::detail {
}
}
/// Keys a physical keyboard reports with the secondary-Fn flag: arrows, navigation keys, and F1 to F20.
constexpr std::array<CGKeyCode, 30> secondary_fn_keys {
kVK_LeftArrow,
kVK_RightArrow,
kVK_UpArrow,
kVK_DownArrow,
kVK_Home,
kVK_End,
kVK_PageUp,
kVK_PageDown,
kVK_ForwardDelete,
kVK_Help,
kVK_F1,
kVK_F2,
kVK_F3,
kVK_F4,
kVK_F5,
kVK_F6,
kVK_F7,
kVK_F8,
kVK_F9,
kVK_F10,
kVK_F11,
kVK_F12,
kVK_F13,
kVK_F14,
kVK_F15,
kVK_F16,
kVK_F17,
kVK_F18,
kVK_F19,
kVK_F20,
};
/// Keys a physical keyboard reports with the numeric-pad flag: arrows and the keypad.
constexpr std::array<CGKeyCode, 22> numeric_pad_keys {
kVK_LeftArrow,
kVK_RightArrow,
kVK_UpArrow,
kVK_DownArrow,
kVK_ANSI_Keypad0,
kVK_ANSI_Keypad1,
kVK_ANSI_Keypad2,
kVK_ANSI_Keypad3,
kVK_ANSI_Keypad4,
kVK_ANSI_Keypad5,
kVK_ANSI_Keypad6,
kVK_ANSI_Keypad7,
kVK_ANSI_Keypad8,
kVK_ANSI_Keypad9,
kVK_ANSI_KeypadDecimal,
kVK_ANSI_KeypadMultiply,
kVK_ANSI_KeypadPlus,
kVK_ANSI_KeypadClear,
kVK_ANSI_KeypadDivide,
kVK_ANSI_KeypadEnter,
kVK_ANSI_KeypadMinus,
kVK_ANSI_KeypadEquals,
};
/**
* @brief Resolve the flags macOS attaches to a non-modifier key by itself.
*
* The system hotkey layer matches on these flags, so a synthetic Control+Up posted without
* them never reaches Mission Control even though the same event reaches ordinary applications.
*
* @param key macOS virtual key code.
* @return Flags to add to the posted event for this key only.
*/
inline CGEventFlags implicit_key_flags(CGKeyCode key) {
CGEventFlags flags {};
if (std::ranges::find(secondary_fn_keys, key) != secondary_fn_keys.end()) {
flags |= kCGEventFlagMaskSecondaryFn;
}
if (std::ranges::find(numeric_pad_keys, key) != numeric_pad_keys.end()) {
flags |= kCGEventFlagMaskNumericPad;
}
return flags;
}
/**
* @brief Convert a scroll-wheel slider value to logical lines per detent.
*
@@ -443,6 +523,43 @@ namespace lvh::detail {
std::mutex keyboard_mutex; ///< Guards shared keyboard modifier state.
};
/**
* @brief Build the CoreGraphics event for one key transition and update the shared modifier state.
*
* The caller must hold `state.keyboard_mutex`.
*
* @param state Shared backend state whose modifier flags are read and updated.
* @param key macOS virtual key code.
* @param pressed Whether the key is going down.
* @return Event ready to post, or `nullptr` when CoreGraphics cannot create one. The caller releases it.
*/
inline CGEventRef create_keyboard_event(MacosInputState &state, CGKeyCode key, bool pressed) {
const auto keyboard_event = CGEventCreateKeyboardEvent(state.keyboard_source, key, pressed);
if (!keyboard_event) {
return nullptr;
}
CGEventSetIntegerValueField(keyboard_event, kCGKeyboardEventKeycode, key);
ModifierFlags modifier_flags;
if (modifier_flags_for_key(key, modifier_flags)) {
if (pressed) {
state.keyboard_flags |= modifier_flags.generic | modifier_flags.device;
} else {
state.keyboard_flags &= ~modifier_flags.device;
if ((state.keyboard_flags & modifier_flags.all_devices) == 0) {
state.keyboard_flags &= ~modifier_flags.generic;
}
}
CGEventSetType(keyboard_event, kCGEventFlagsChanged);
} else {
CGEventSetType(keyboard_event, pressed ? kCGEventKeyDown : kCGEventKeyUp);
}
CGEventSetFlags(keyboard_event, state.keyboard_flags | implicit_key_flags(key));
return keyboard_event;
}
/**
* @brief Backend keyboard backed by CoreGraphics keyboard events.
*/
@@ -471,30 +588,12 @@ namespace lvh::detail {
return OperationStatus::failure(backend_failure, "macOS keyboard event source is unavailable");
}
const auto keyboard_event = CGEventCreateKeyboardEvent(state_->keyboard_source, *key, event.pressed);
std::lock_guard lock {state_->keyboard_mutex};
const auto keyboard_event = create_keyboard_event(*state_, *key, event.pressed);
if (!keyboard_event) {
return OperationStatus::failure(backend_failure, "create macOS keyboard event");
}
std::lock_guard lock {state_->keyboard_mutex};
CGEventSetIntegerValueField(keyboard_event, kCGKeyboardEventKeycode, *key);
ModifierFlags modifier_flags;
if (modifier_flags_for_key(*key, modifier_flags)) {
if (event.pressed) {
state_->keyboard_flags |= modifier_flags.generic | modifier_flags.device;
} else {
state_->keyboard_flags &= ~modifier_flags.device;
if ((state_->keyboard_flags & modifier_flags.all_devices) == 0) {
state_->keyboard_flags &= ~modifier_flags.generic;
}
}
CGEventSetType(keyboard_event, kCGEventFlagsChanged);
} else {
CGEventSetType(keyboard_event, event.pressed ? kCGEventKeyDown : kCGEventKeyUp);
}
CGEventSetFlags(keyboard_event, state_->keyboard_flags);
CGEventPost(kCGSessionEventTap, keyboard_event);
CFRelease(keyboard_event);
return OperationStatus::success();
@@ -7,6 +7,8 @@
// standard includes
#include <cstdint>
#include <optional>
#include <utility>
#include <vector>
// lib includes
#include <libvirtualhid/types.hpp>
@@ -29,6 +31,16 @@ namespace lvh::detail::test {
std::uint32_t event_type {}; ///< CoreGraphics mouse event type value.
};
/**
* @brief Portable view of one CoreGraphics keyboard event the macOS backend built.
*/
struct MacosKeyEventResult {
std::uint32_t event_type {}; ///< CoreGraphics event type value.
std::int64_t key_code {}; ///< macOS virtual key code stored on the event.
std::uint64_t flags {}; ///< Flags stored on the event.
std::uint64_t tracked_flags {}; ///< Shared modifier state after the event was built.
};
/**
* @brief Result set for macOS backend lifecycle utility coverage.
*/
@@ -68,6 +80,22 @@ namespace lvh::detail::test {
*/
bool macos_backend_is_modifier_key(KeyboardKeyCode key_code);
/**
* @brief Resolve the flags the macOS backend adds to a key event for the key itself.
*
* @param key_code Portable key code.
* @return CoreGraphics event flags, or zero when the key is unmapped or carries none.
*/
std::uint64_t macos_backend_implicit_key_flags(KeyboardKeyCode key_code);
/**
* @brief Build, without posting, the events the macOS backend would send for a key sequence.
*
* @param transitions Portable key codes paired with `true` for press and `false` for release.
* @return One result per transition whose key code the backend maps.
*/
std::vector<MacosKeyEventResult> macos_backend_key_events(const std::vector<std::pair<KeyboardKeyCode, bool>> &transitions);
/**
* @brief Convert a macOS scroll-wheel scaling value to lines per detent.
*
+37
View File
@@ -31,6 +31,43 @@ namespace lvh::detail::test {
return macos::modifier_flags_for_key(*mapped, flags);
}
std::uint64_t macos_backend_implicit_key_flags(KeyboardKeyCode key_code) {
const auto mapped = macos::macos_key_code(key_code);
if (!mapped) {
return 0;
}
return static_cast<std::uint64_t>(macos::implicit_key_flags(*mapped));
}
std::vector<MacosKeyEventResult> macos_backend_key_events(const std::vector<std::pair<KeyboardKeyCode, bool>> &transitions) {
macos::MacosInputState state;
std::lock_guard lock {state.keyboard_mutex};
std::vector<MacosKeyEventResult> results;
for (const auto &[key_code, pressed] : transitions) {
const auto mapped = macos::macos_key_code(key_code);
if (!mapped) {
continue;
}
const auto event = macos::create_keyboard_event(state, *mapped, pressed);
if (!event) {
continue;
}
results.push_back({
.event_type = static_cast<std::uint32_t>(CGEventGetType(event)),
.key_code = CGEventGetIntegerValueField(event, kCGKeyboardEventKeycode),
.flags = static_cast<std::uint64_t>(CGEventGetFlags(event)),
.tracked_flags = static_cast<std::uint64_t>(state.keyboard_flags),
});
CFRelease(event);
}
return results;
}
int macos_backend_scroll_lines_per_detent(double scale) {
return macos::scroll_lines_per_detent(scale);
}
+85
View File
@@ -63,6 +63,91 @@ TEST_F(MacosBackendTest, IdentifiesModifierKeys) {
EXPECT_FALSE(lvh::detail::test::macos_backend_is_modifier_key(0x13));
}
TEST_F(MacosBackendTest, AddsImplicitFlagsForFunctionAndKeypadKeys) {
using lvh::detail::test::macos_backend_implicit_key_flags;
constexpr std::uint64_t fn = kCGEventFlagMaskSecondaryFn;
constexpr std::uint64_t numeric_pad = kCGEventFlagMaskNumericPad;
EXPECT_EQ(macos_backend_implicit_key_flags(0x25), fn | numeric_pad); // VKEY_LEFT
EXPECT_EQ(macos_backend_implicit_key_flags(0x26), fn | numeric_pad); // VKEY_UP
EXPECT_EQ(macos_backend_implicit_key_flags(0x27), fn | numeric_pad); // VKEY_RIGHT
EXPECT_EQ(macos_backend_implicit_key_flags(0x28), fn | numeric_pad); // VKEY_DOWN
EXPECT_EQ(macos_backend_implicit_key_flags(0x21), fn); // VKEY_PRIOR
EXPECT_EQ(macos_backend_implicit_key_flags(0x22), fn); // VKEY_NEXT
EXPECT_EQ(macos_backend_implicit_key_flags(0x23), fn); // VKEY_END
EXPECT_EQ(macos_backend_implicit_key_flags(0x24), fn); // VKEY_HOME
EXPECT_EQ(macos_backend_implicit_key_flags(0x2D), fn); // VKEY_INSERT
EXPECT_EQ(macos_backend_implicit_key_flags(0x2E), fn); // VKEY_DELETE
EXPECT_EQ(macos_backend_implicit_key_flags(0x70), fn); // VKEY_F1
EXPECT_EQ(macos_backend_implicit_key_flags(0x83), fn); // VKEY_F20
EXPECT_EQ(macos_backend_implicit_key_flags(0x60), numeric_pad); // VKEY_NUMPAD0
EXPECT_EQ(macos_backend_implicit_key_flags(0x6B), numeric_pad); // VKEY_ADD
EXPECT_EQ(macos_backend_implicit_key_flags(0x6E), numeric_pad); // VKEY_DECIMAL
EXPECT_EQ(macos_backend_implicit_key_flags(0x0D), 0U); // VKEY_RETURN
EXPECT_EQ(macos_backend_implicit_key_flags(0x41), 0U); // VKEY_A
EXPECT_EQ(macos_backend_implicit_key_flags(0xA2), 0U); // VKEY_LCONTROL
EXPECT_EQ(macos_backend_implicit_key_flags(0x13), 0U); // unmapped VKEY_PAUSE
EXPECT_EQ(macos_backend_implicit_key_flags(0xFFFF), 0U);
}
TEST_F(MacosBackendTest, BuildsControlArrowEventsWithFnFlags) {
using lvh::detail::test::macos_backend_key_events;
constexpr std::uint64_t control = kCGEventFlagMaskControl;
constexpr std::uint64_t fn = kCGEventFlagMaskSecondaryFn;
constexpr std::uint64_t numeric_pad = kCGEventFlagMaskNumericPad;
constexpr std::uint64_t checked = kCGEventFlagMaskShift | kCGEventFlagMaskControl | kCGEventFlagMaskAlternate |
kCGEventFlagMaskCommand | fn | numeric_pad;
// Control+Up as a client sends it, then Control+A for contrast.
const auto events = macos_backend_key_events({
{0xA2, true}, // VKEY_LCONTROL down
{0x26, true}, // VKEY_UP down
{0x26, false}, // VKEY_UP up
{0x41, true}, // VKEY_A down
{0x41, false}, // VKEY_A up
{0xA2, false}, // VKEY_LCONTROL up
});
ASSERT_EQ(events.size(), 6U);
EXPECT_EQ(events[0].event_type, kCGEventFlagsChanged);
EXPECT_EQ(events[0].flags & checked, control);
// The arrow event carries the flags a physical arrow key reports, on top of the held Control.
EXPECT_EQ(events[1].event_type, kCGEventKeyDown);
EXPECT_EQ(events[1].key_code, kVK_UpArrow);
EXPECT_EQ(events[1].flags & checked, control | fn | numeric_pad);
EXPECT_EQ(events[2].event_type, kCGEventKeyUp);
EXPECT_EQ(events[2].flags & checked, control | fn | numeric_pad);
// The per-key flags never enter the shared modifier state, so Control+A is unchanged.
EXPECT_EQ(events[1].tracked_flags & checked, control);
EXPECT_EQ(events[2].tracked_flags & checked, control);
EXPECT_EQ(events[3].event_type, kCGEventKeyDown);
EXPECT_EQ(events[3].key_code, kVK_ANSI_A);
EXPECT_EQ(events[3].flags & checked, control);
EXPECT_EQ(events[4].flags & checked, control);
EXPECT_EQ(events[5].event_type, kCGEventFlagsChanged);
EXPECT_EQ(events[5].flags & checked, 0U);
EXPECT_EQ(events[5].tracked_flags & checked, 0U);
}
TEST_F(MacosBackendTest, BuildsKeypadEventsWithNumericPadFlag) {
using lvh::detail::test::macos_backend_key_events;
constexpr std::uint64_t fn = kCGEventFlagMaskSecondaryFn;
constexpr std::uint64_t numeric_pad = kCGEventFlagMaskNumericPad;
const auto events = macos_backend_key_events({
{0x67, true}, // VKEY_NUMPAD7 down
{0x0D, true}, // VKEY_RETURN down
});
ASSERT_EQ(events.size(), 2U);
EXPECT_EQ(events[0].key_code, kVK_ANSI_Keypad7);
EXPECT_EQ(events[0].flags & (fn | numeric_pad), numeric_pad);
EXPECT_EQ(events[1].key_code, kVK_Return);
EXPECT_EQ(events[1].flags & (fn | numeric_pad), 0U);
}
TEST_F(MacosBackendTest, ConvertsScrollSettings) {
EXPECT_EQ(lvh::detail::test::macos_backend_scroll_lines_per_detent(0.0), 1);
EXPECT_EQ(lvh::detail::test::macos_backend_scroll_lines_per_detent(0.3125), 5);