From cf13e471140ec1f2dc5b429ef48e6fac2c057d39 Mon Sep 17 00:00:00 2001 From: Sam Windell Date: Thu, 22 Jan 2026 17:00:32 +0000 Subject: [PATCH 1/2] Add function for comprehensive capturing of all keyboard events For audio plugins on Windows, hosts routinely block keyboard messages from reaching plugins. The workaround is for audio plugins is to install a keyboard hook and carefully consume key events that are needed, leaving the unused ones for the host, should they want them. Additionally, it's important for an audio plugin on Windows to return keyboard focus back to the previous window. For example after pressing enter on a text input field on the plugin, return focus back to the host so that keyboard shortcuts work again (spacebar for play, etc.). Neither of these features were possible with the existing puglGrabFocus API. This new API needs some feedback regard how best it should be implemented. On Linux and macOS, it just a simple stud. For macOS it may be necessary to perform a similar 'hooking' feature, but this time using `NSEvent addLocalMonitorForEventsMatchingMask`. --- include/pugl/pugl.h | 28 ++++++++ src/mac.m | 16 +++++ src/win.c | 168 ++++++++++++++++++++++++++++++++++++++++++++ src/win.h | 36 +++++----- src/x11.c | 16 +++++ 5 files changed, 248 insertions(+), 16 deletions(-) diff --git a/include/pugl/pugl.h b/include/pugl/pugl.h index 5ba5a46e..665a5a97 100644 --- a/include/pugl/pugl.h +++ b/include/pugl/pugl.h @@ -1494,6 +1494,34 @@ puglGrabFocus(PuglView* view); PUGL_API bool puglHasFocus(const PuglView* view); +/** + Return true if the event is wanted. Will either be a PUGL_TEXT or PUGL_KEY_*. +*/ +typedef bool (*PuglKeyboardEventFilter)(PuglView* view, const PuglEvent* event); + +/** + Request all keyboard input events. + + This tries to ensure that the view receives keyboard events even when they + might normally be intercepted or consumed by the system or other handlers. + This is particularly useful for audio plugins when the host blocks key + events from reaching the plugin. + + If you use this, you do not need to use puglGrabFocus. + + Use the filter function to allow the exact events that you directly need, + allowing other handlers on the system to consume them if not. + + Note that this will fail if the view is not mapped and so should not, for + example, be called immediately after puglShow(). + + @return #PUGL_SUCCESS if the focus was successfully grabbed, or an error. +*/ +PUGL_API PuglStatus +puglSetWantsAllKeyboardEvents(PuglView* view, + bool wantsEvents, + PuglKeyboardEventFilter filterFunction); + /** Request data from the general copy/paste clipboard. diff --git a/src/mac.m b/src/mac.m index 41d358a2..abfec676 100644 --- a/src/mac.m +++ b/src/mac.m @@ -1451,6 +1451,22 @@ - (void)windowDidExitFullScreen:(NSNotification*)notification [[impl->wrapperView window] firstResponder] == impl->wrapperView); } +PuglStatus +puglSetWantsAllKeyboardEvents(PuglView* view, + bool wantsEvents, + PuglKeyboardEventFilter filterFunction) +{ + (void)filterFunction; + + if (wantsEvents) { + if (!puglHasFocus(view)) { + puglGrabFocus(view); + } + } + + return PUGL_SUCCESS; +} + static bool styleIsMaximized(const PuglViewStyleFlags flags) { diff --git a/src/win.c b/src/win.c index a8cd841e..dd922908 100644 --- a/src/win.c +++ b/src/win.c @@ -42,6 +42,10 @@ #define PUGL_LOCAL_CLIENT_MSG (WM_USER + 52) #define PUGL_USER_TIMER_MIN 9470 +#define PUGL_WINDOW_MAGIC 0x5055474C // "PUGL" in ASCII +#define PUGL_WINDOW_MAGIC_OFFSET 0 +#define PUGL_WINDOW_EXTRA_SIZE sizeof(LONG_PTR) + #ifdef __cplusplus # define PUGL_INIT_STRUCT \ { \ @@ -57,6 +61,9 @@ typedef HRESULT(WINAPI* PFN_GetScaleFactorForMonitor)(HMONITOR, DWORD*); LRESULT CALLBACK wndProc(HWND hwnd, UINT message, WPARAM wParam, LPARAM lParam); +static void +removeKeyboardHook(PuglView* view); + #ifdef UNICODE typedef wchar_t ArgStringChar; @@ -139,6 +146,7 @@ puglRegisterWindowClass(const char* name) wc.cbSize = sizeof(wc); wc.style = CS_OWNDC; wc.lpfnWndProc = wndProc; + wc.cbWndExtra = PUGL_WINDOW_EXTRA_SIZE; wc.hInstance = module; wc.hIcon = LoadIcon(NULL, IDI_APPLICATION); wc.hCursor = LoadCursor(NULL, IDC_ARROW); @@ -332,6 +340,7 @@ puglRealize(PuglView* view) } SetWindowLongPtr(impl->hwnd, GWLP_USERDATA, (LONG_PTR)view); + SetWindowLongPtr(impl->hwnd, PUGL_WINDOW_MAGIC_OFFSET, PUGL_WINDOW_MAGIC); return puglDispatchSimpleEvent(view, PUGL_REALIZE); } @@ -350,6 +359,11 @@ puglUnrealize(PuglView* const view) view->backend->destroy(view); } + removeKeyboardHook(view); + if (view->impl->lastFocus) { + SetFocus(view->impl->lastFocus); + } + memset(&view->lastConfigure, 0, sizeof(PuglConfigureEvent)); ReleaseDC(impl->hwnd, impl->hdc); impl->hdc = NULL; @@ -921,6 +935,9 @@ handleMessage(PuglView* view, UINT message, WPARAM wParam, LPARAM lParam) break; case WM_KILLFOCUS: event.type = PUGL_FOCUS_OUT; + if (view->impl->keyboardHook) { + removeKeyboardHook(view); + } break; case WM_SYSKEYDOWN: initKeyEvent(&event.key, view, true, wParam, lParam); @@ -1647,3 +1664,154 @@ puglWinLeave(PuglView* view, const PuglExposeEvent* expose) return PUGL_SUCCESS; } + +// Returns true if the message was consumed +static bool +handleHookMessage(PuglView* view, const MSG* msg, int code, WPARAM wParam) +{ + if (code != HC_ACTION || wParam != PM_REMOVE || !msg->hwnd) { + return false; + } + + PuglEvent event = PUGL_INIT_STRUCT; + + switch (msg->message) { + case WM_CHAR: + case WM_SYSCHAR: + initCharEvent(&event, view, msg->wParam, msg->lParam); + if (!view->impl->keyboardEventFilter || + view->impl->keyboardEventFilter(view, &event)) { + puglDispatchEvent(view, &event); + return true; + } + return false; + + case WM_KEYDOWN: + case WM_KEYUP: + case WM_SYSKEYDOWN: + case WM_SYSKEYUP: { + bool used = false; + + // TODO: can we get the real CHAR event here rather than a made-up example + event.type = PUGL_TEXT; + event.text.character = 'a'; + event.text.string[0] = 'a'; + if (!view->impl->keyboardEventFilter || + view->impl->keyboardEventFilter(view, &event)) { + // Adds a WM_CHAR message to the queue if the key produces text + TranslateMessage(msg); + + // We check if text was produced (removing the events as we do the check) + MSG peeked = PUGL_INIT_STRUCT; + if (PeekMessage(&peeked, msg->hwnd, WM_CHAR, WM_DEADCHAR, PM_REMOVE) || + PeekMessage( + &peeked, msg->hwnd, WM_SYSCHAR, WM_SYSDEADCHAR, PM_REMOVE)) { + used = true; + } + } + + initKeyEvent(&event.key, + view, + msg->message == WM_KEYDOWN || msg->message == WM_SYSKEYDOWN, + msg->wParam, + msg->lParam); + if (!view->impl->keyboardEventFilter || + view->impl->keyboardEventFilter(view, &event)) { + puglDispatchEvent(view, &event); + return true; + } + + return used; + } + } + + return false; +} + +static LRESULT CALLBACK +keyboardHookProc(int code, WPARAM wParam, LPARAM lParam) +{ + MSG* msg = (MSG*)lParam; + + // Check if this window is actually a pugl window + if (GetClassLongPtr(msg->hwnd, GCL_CBWNDEXTRA) == + (LONG_PTR)PUGL_WINDOW_EXTRA_SIZE && + GetWindowLongPtr(msg->hwnd, PUGL_WINDOW_MAGIC_OFFSET) == + PUGL_WINDOW_MAGIC) { + PuglView* view = (PuglView*)GetWindowLongPtr(msg->hwnd, GWLP_USERDATA); + if (view && view->impl && view->impl->wantsAllKeyboardEvents) { + if (handleHookMessage(view, msg, code, wParam)) { + // Scrub the message so no one else gets it + memset(msg, 0, sizeof(MSG)); + msg->message = WM_USER; + return 0; + } + } + } + + return CallNextHookEx(NULL, code, wParam, lParam); +} + +static void +removeKeyboardHook(PuglView* view) +{ + if (view->impl->keyboardHook) { + UnhookWindowsHookEx(view->impl->keyboardHook); + view->impl->keyboardHook = NULL; + } +} + +static PuglStatus +installKeyboardHook(PuglView* view) +{ + if (view->impl->keyboardHook) { + return PUGL_SUCCESS; + } + + HMODULE module = NULL; + if (!GetModuleHandleEx(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | + GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT, + (LPCTSTR)keyboardHookProc, + &module)) { + module = GetModuleHandle(NULL); + } + + if (!module) { + return PUGL_FAILURE; + } + + view->impl->keyboardHook = SetWindowsHookEx( + WH_GETMESSAGE, keyboardHookProc, module, GetCurrentThreadId()); + + return view->impl->keyboardHook ? PUGL_SUCCESS : PUGL_FAILURE; +} + +PuglStatus +puglSetWantsAllKeyboardEvents(PuglView* view, + bool wantsEvents, + PuglKeyboardEventFilter filterFunction) +{ + if (!view || !view->impl) { + return PUGL_BAD_PARAMETER; + } + + view->impl->wantsAllKeyboardEvents = wantsEvents; + view->impl->keyboardEventFilter = filterFunction; + + if (wantsEvents) { + // Set focus and install hook + HWND hwnd = view->impl->hwnd; + if (GetFocus() != hwnd) { + view->impl->lastFocus = SetFocus(hwnd); + } + return installKeyboardHook(view); + } else { + // Restore previous focus and remove hook + if (view->impl->lastFocus) { + SetFocus(view->impl->lastFocus); + view->impl->lastFocus = NULL; + } + removeKeyboardHook(view); + return PUGL_SUCCESS; + } +} diff --git a/src/win.h b/src/win.h index b37db3fb..dbef02ec 100644 --- a/src/win.h +++ b/src/win.h @@ -19,22 +19,26 @@ struct PuglWorldInternalsImpl { }; struct PuglInternalsImpl { - PuglWinPFD pfd; - int pfId; - HWND hwnd; - HCURSOR cursor; - HDC hdc; - WINDOWPLACEMENT oldPlacement; - PAINTSTRUCT paint; - PuglBlob clipboard; - PuglSurface* surface; - double scaleFactor; - bool mapped; - bool flashing; - bool mouseTracked; - bool minimized; - bool maximized; - bool fullscreen; + PuglWinPFD pfd; + int pfId; + HWND hwnd; + HCURSOR cursor; + HDC hdc; + WINDOWPLACEMENT oldPlacement; + PAINTSTRUCT paint; + PuglBlob clipboard; + PuglSurface* surface; + double scaleFactor; + bool mapped; + bool flashing; + bool mouseTracked; + bool minimized; + bool maximized; + bool fullscreen; + HWND lastFocus; + HHOOK keyboardHook; + bool wantsAllKeyboardEvents; + PuglKeyboardEventFilter keyboardEventFilter; }; PUGL_API PuglWinPFD diff --git a/src/x11.c b/src/x11.c index 6f05c590..b4a1864f 100644 --- a/src/x11.c +++ b/src/x11.c @@ -1357,6 +1357,22 @@ puglHasFocus(const PuglView* const view) return focusedWindow == view->impl->win; } +PuglStatus +puglSetWantsAllKeyboardEvents(PuglView* view, + bool wantsEvents, + PuglKeyboardEventFilter filterFunction) +{ + (void)filterFunction; + + if (wantsEvents) { + if (!puglHasFocus(view)) { + puglGrabFocus(view); + } + } + + return PUGL_SUCCESS; +} + PuglStatus puglStartTimer(PuglView* const view, const uintptr_t id, const double timeout) { From 8a6b26e0f06ec44971faef4f2319a4644067fab1 Mon Sep 17 00:00:00 2001 From: Sam Windell Date: Tue, 26 May 2026 09:08:35 +0100 Subject: [PATCH 2/2] MacOS: capture keyboard input without stealing host shortcuts puglSetWantsAllKeyboardEvents previously called puglGrabFocus, which makes the plugin's NSWindow the key window. In hosts like Logic Pro that left the host unable to receive transport shortcuts (spacebar play/stop, Cmd+K, etc.) for as long as the plugin window was open. It also ignored the filterFunction argument and had no way to release focus again. Reimplement it using the approach from CPLUG [1]: take first-responder status and install a local NSEvent monitor, but never make the host's window key. - Make the wrapper view the first responder (but not the key window). This is what makes the host route key events to our window in the first place; without it the local monitor never fires in hosts like Logic. Avoiding makeKeyWindow is what preserves the host's shortcuts. - The local NSEvent monitor synthesises a PuglEvent for each key down/up and asks filterFunction whether the plugin wants it. Wanted events are dispatched to the plugin and consumed (monitor returns nil); unwanted events are returned so they flow on to the host. As the monitor runs before sendEvent: dispatches the event, the plugin can also see keys the host would otherwise consume from its own responder chain. - -keyDown:/-keyUp: now forward to the host (via super) while the monitor is active, since the monitor already consumed everything wanted; what reaches the responder chain is unwanted and belongs to the host. - Text is synthesised directly from the NSEvent's characters, because consumed events never reach -keyDown: and so never go through -interpretKeyEvents:/NSTextInputContext. The trade-off is that IME composition and dead keys are unsupported on this path. The monitor and filter live on PuglInternals and are torn down both when the plugin stops wanting keyboard events and in puglFreeViewInternals. [1] https://github.com/Tremus/CPLUG/blob/master/src/cplug_extensions/window_osx.m --- src/mac.h | 16 +++-- src/mac.m | 193 ++++++++++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 198 insertions(+), 11 deletions(-) diff --git a/src/mac.h b/src/mac.h index 305cf30b..bafe6b12 100644 --- a/src/mac.h +++ b/src/mac.h @@ -30,13 +30,15 @@ struct PuglWorldInternalsImpl { }; struct PuglInternalsImpl { - NSApplication* app; - PuglWrapperView* wrapperView; - NSView* drawView; - NSCursor* cursor; - PuglWindow* window; - uint32_t mods; - bool mouseTracked; + NSApplication* app; + PuglWrapperView* wrapperView; + NSView* drawView; + NSCursor* cursor; + PuglWindow* window; + uint32_t mods; + bool mouseTracked; + id keyEventMonitor; + PuglKeyboardEventFilter keyboardEventFilter; }; #endif // PUGL_SRC_MAC_H diff --git a/src/mac.m b/src/mac.m index abfec676..58e889e9 100644 --- a/src/mac.m +++ b/src/mac.m @@ -666,6 +666,15 @@ - (void)scrollWheel:(NSEvent*)event - (void)keyDown:(NSEvent*)event { + // When the local key-event monitor is active it is the sole path for events + // the plugin wants (it consumes them before they reach here). Anything that + // reaches the responder chain is something the plugin did not want, so pass + // it to the host instead of dispatching/swallowing it here. + if (puglview->impl->keyboardEventFilter) { + [super keyDown:event]; + return; + } + if (puglview->hints[PUGL_IGNORE_KEY_REPEAT] && [event isARepeat]) { return; } @@ -701,6 +710,12 @@ - (void)keyDown:(NSEvent*)event - (void)keyUp:(NSEvent*)event { + // Forward to the host while the monitor is active, as in -keyDown:. + if (puglview->impl->keyboardEventFilter) { + [super keyUp:event]; + return; + } + const NSPoint wloc = [self eventLocation:event]; const NSPoint rloc = [NSEvent mouseLocation]; const PuglKey spec = keySymToSpecial(event); @@ -1410,6 +1425,12 @@ - (void)windowDidExitFullScreen:(NSNotification*)notification } if (view->impl) { + if (view->impl->keyEventMonitor) { + [NSEvent removeMonitor:view->impl->keyEventMonitor]; + [view->impl->keyEventMonitor release]; + view->impl->keyEventMonitor = nil; + } + if (view->impl->wrapperView) { [view->impl->wrapperView removeFromSuperview]; view->impl->wrapperView->puglview = NULL; @@ -1451,19 +1472,183 @@ - (void)windowDidExitFullScreen:(NSNotification*)notification [[impl->wrapperView window] firstResponder] == impl->wrapperView); } +// Synthesise PUGL_TEXT events directly from an NSEvent's characters. AppKit's +// normal text-input path is -interpretKeyEvents: (called from -keyDown:), which +// routes through NSTextInputContext and replies via -insertText:. But we handle +// wanted key events inside the local event monitor and consume them, so they +// never reach -keyDown:; we therefore produce the text ourselves. This bypasses +// the input context, so IME composition and dead keys are unsupported. +static void +dispatchMonitoredText(PuglWrapperView* const wrapperView, NSEvent* const event) +{ + // Command-modified keys are shortcuts, not text input. + if (getModifiers(event) & PUGL_MOD_SUPER) { + return; + } + + NSString* const characters = [event characters]; + if ([characters length] == 0) { + return; + } + + // Skip AppKit's private-use range for function/arrow keys and the DEL control + // character; these are not text to insert. + if ([characters length] == 1) { + const unichar c = [characters characterAtIndex:0]; + if ((c >= NSUpArrowFunctionKey && c <= 0xF8FF) || c == 0x7F) { + return; + } + } + + const NSPoint wloc = [wrapperView eventLocation:event]; + const NSPoint rloc = [NSEvent mouseLocation]; + for (NSUInteger i = 0; i < [characters length]; ++i) { + const uint32_t code = [characters characterAtIndex:i]; + char utf8[8] = {0}; + NSUInteger len = 0; + + [characters getBytes:utf8 + maxLength:sizeof(utf8) + usedLength:&len + encoding:NSUTF8StringEncoding + options:0 + range:NSMakeRange(i, i + 1) + remainingRange:nil]; + + PuglTextEvent ev = { + PUGL_TEXT, + 0U, + [event timestamp], + wloc.x, + wloc.y, + rloc.x, + [[NSScreen mainScreen] frame].size.height - rloc.y, + getModifiers(event), + [event keyCode], + code, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + }; + memcpy(ev.string, utf8, len); + + PuglEvent textEvent; + textEvent.text = ev; + puglDispatchEvent(wrapperView->puglview, &textEvent); + } +} + +static NSEvent* +handleMonitoredKeyEvent(PuglView* const view, NSEvent* const event) +{ + PuglWrapperView* const wrapperView = view->impl->wrapperView; + PuglKeyboardEventFilter const filter = view->impl->keyboardEventFilter; + if (!wrapperView || !filter) { + return event; + } + + const bool isDown = ([event type] == NSEventTypeKeyDown); + const PuglKey spec = keySymToSpecial(event); + const NSString* chars = [event charactersIgnoringModifiers]; + const char* str = [[chars lowercaseString] UTF8String]; + const uint32_t code = (spec ? spec : puglDecodeUTF8((const uint8_t*)str)); + + const NSPoint wloc = [wrapperView eventLocation:event]; + const NSPoint rloc = [NSEvent mouseLocation]; + + const PuglKeyEvent ev = { + isDown ? PUGL_KEY_PRESS : PUGL_KEY_RELEASE, + 0U, + [event timestamp], + wloc.x, + wloc.y, + rloc.x, + [[NSScreen mainScreen] frame].size.height - rloc.y, + puglFilterMods(getModifiers(event), spec), + [event keyCode], + (code != 0xFFFD) ? code : 0, + }; + + // Ask the plugin whether it wants this event. If not, return it so it flows + // on through the responder chain to the host (transport shortcuts etc.). + PuglEvent keyEvent; + keyEvent.key = ev; + if (!filter(view, &keyEvent)) { + return event; + } + + if (view->hints[PUGL_IGNORE_KEY_REPEAT] && isDown && [event isARepeat]) { + return nil; + } + + puglDispatchEvent(view, &keyEvent); + + // Only generate text while our window is key, i.e. the user is typing into us + // rather than into another window in the host process. + if (isDown && !spec) { + NSWindow* const window = [wrapperView window]; + if (window && [window isKeyWindow]) { + dispatchMonitoredText(wrapperView, event); + } + } + + return nil; // Consume: the host must not also see this event. +} + PuglStatus puglSetWantsAllKeyboardEvents(PuglView* view, bool wantsEvents, PuglKeyboardEventFilter filterFunction) { - (void)filterFunction; + // Capture keyboard input for a plugin window without stealing it from the + // host. Two mechanisms combine (technique adapted from CPLUG, link below): + // + // 1. Make our view the first responder. This is what causes the host to route + // key events to our window at all; without it, in hosts like Logic Pro the + // local monitor below never fires because the events never reach us. We do + // NOT call makeKeyWindow - that is what steals the host's transport + // shortcuts (spacebar play/stop etc.). + // + // 2. Install a process-wide local NSEvent monitor. For each key event we + // synthesise a PuglEvent and ask the filter whether the plugin wants it. + // If so we dispatch it to the plugin and consume it (return nil); if not + // we return it so it flows on through the responder chain. + // + // Because the monitor consumes every event the plugin wants, the only key + // events reaching -keyDown:/-keyUp: are ones the plugin did not want; those + // forward to the host (via super) so its shortcuts keep working. + // + // https://github.com/Tremus/CPLUG/blob/master/src/cplug_extensions/window_osx.m + PuglInternals* const impl = view->impl; + + if (impl->keyEventMonitor) { + [NSEvent removeMonitor:impl->keyEventMonitor]; + [impl->keyEventMonitor release]; + impl->keyEventMonitor = nil; + } + + NSWindow* const window = [impl->wrapperView window]; - if (wantsEvents) { - if (!puglHasFocus(view)) { - puglGrabFocus(view); + if (!wantsEvents || !filterFunction) { + impl->keyboardEventFilter = NULL; + if (window && [window firstResponder] == impl->wrapperView) { + [window makeFirstResponder:nil]; } + return PUGL_SUCCESS; } + impl->keyboardEventFilter = filterFunction; + + if (window && [window firstResponder] != impl->wrapperView) { + [window makeFirstResponder:impl->wrapperView]; + } + + const NSEventMask mask = NSEventMaskKeyDown | NSEventMaskKeyUp; + id monitor = [NSEvent + addLocalMonitorForEventsMatchingMask:mask + handler:^NSEvent*(NSEvent* e) { + return handleMonitoredKeyEvent(view, e); + }]; + + impl->keyEventMonitor = [monitor retain]; return PUGL_SUCCESS; }