From bde7e3fe45ada4d9ab96c1ce71f893f0502b2aa8 Mon Sep 17 00:00:00 2001 From: lxpollitt <630494+lxpollitt@users.noreply.github.com> Date: Thu, 4 Jun 2026 08:21:16 +0100 Subject: [PATCH] Inject synthetic keyup events to recover from macOS swallowing them while a Meta key is held macOS suppresses keyUp events for non-modifier keys whose keyUp would occur while a Meta (CMD) key is held - an OS-level behaviour inherited by Chrome, Safari and Firefox. GwtKeyboardMatrix's UI-thread constructor now installs a DOM-level keydown/keyup listener (capture phase, so it runs before libGDX's own document-level bubble-phase listener). The listener tracks which non- Meta keys have unreleased keydowns and, on Meta release, dispatches a synthetic keyup event for each. The synthetic events flow through libGDX's normal keyup handler and clear its internal pressedKeys[] state. --- .../java/emu/joric/gwt/GwtKeyboardMatrix.java | 86 ++++++++++++++++++- 1 file changed, 85 insertions(+), 1 deletion(-) diff --git a/html/src/main/java/emu/joric/gwt/GwtKeyboardMatrix.java b/html/src/main/java/emu/joric/gwt/GwtKeyboardMatrix.java index 9c9934e..8792677 100644 --- a/html/src/main/java/emu/joric/gwt/GwtKeyboardMatrix.java +++ b/html/src/main/java/emu/joric/gwt/GwtKeyboardMatrix.java @@ -20,6 +20,12 @@ public class GwtKeyboardMatrix extends KeyboardMatrix { */ public GwtKeyboardMatrix() { keyMatrix = createKeyMatrixArray(); + // Install a DOM-level Meta-key watcher. See + // registerMetaKeyWatcher's Javadoc for the macOS keyup- + // suppression behaviour it works around. Only the UI-thread + // constructor calls this - the web-worker constructor below + // has no DOM to listen to. + registerMetaKeyWatcher(); } /** @@ -44,7 +50,85 @@ public native JavaScriptObject getSharedArrayBuffer()/*-{ var keyMatrix = this.@emu.joric.gwt.GwtKeyboardMatrix::keyMatrix; return keyMatrix.buffer; }-*/; - + + /** + * Installs DOM-level keydown/keyup listeners that synthesise + * keyup events for non-Meta keys whose natural keyup was + * swallowed by macOS while a Meta key was held. + * + * macOS suppresses keyUp events for non-modifier keys whose keyUp + * would occur while a Meta (CMD) key is held. THis is an OS-level + * behaviour inherited by Chrome, Safari and Firefox (open + * browser bugs dating back to 2011, still unresolved as of + * writing). The consequence in libGDX's GWT backend + * (DefaultGwtInput.java) is that its internal pressedKeys[] + * array gets stuck `true` for the un-released key. Typical + * symptom for Oric input behaviour: after CMD+X, X stays pressed, + * generating auto-repeat X inputs until the next plain X press. + * + * The workaround is to listen at the DOM level (capture phase, so + * we run before libGDX's own document-level bubble-phase listener). + * On every keydown/keyup, observe event.metaKey to track Meta + * pressed/released. On the released transition, dispatch a + * synthetic keyup for every non-Meta key whose keydown we saw + * without a subsequent keyup. The synthetic events flow through + * libGDX's normal keyup handler, which clears its internal + * pressedKeys[] state. (Note that in the case where a non-Meta + * key is still held down when Meta is released, MacOS appears + * to generate suitable key events so they effectively appear as + * new key presses without the Meta modifier.) + */ + private native void registerMetaKeyWatcher()/*-{ + var metaPressed = false; + // DOM keyCodes of non-Meta keys we've seen keydown but not + // (yet) keyup for. Excludes the Meta key DOM codes (91, 92, + // 93 - values vary slightly across browsers and key + // locations - plus 224 for some Firefox variants). + var trackedKeys = {}; + + var update = function(e) { + var wasMetaPressed = metaPressed; + var isMetaPressed = !!e.metaKey; + metaPressed = isMetaPressed; + var code = e.keyCode; + + // Track DOM press/release for non-Meta keys. + if (code !== 91 && code !== 92 && code !== 93 && code !== 224) { + if (e.type === 'keydown') { + trackedKeys[code] = true; + } else if (e.type === 'keyup') { + delete trackedKeys[code]; + } + } + + // Meta key just released. Synthesise keyup events for any + // still-tracked keys so libGDX's internal pressedKeys[] + // gate is cleared. Snapshot before iterating because the + // synthetic dispatch re-enters this handler (the keyup + // branch above) and would otherwise mutate trackedKeys + // mid-iteration. + if (wasMetaPressed && !isMetaPressed) { + var snapshot = []; + for (var k in trackedKeys) snapshot.push(k); + trackedKeys = {}; + for (var i = 0; i < snapshot.length; i++) { + var kc = +snapshot[i]; + // keyCode and which aren't part of KeyboardEvent's init dict, + // so passing them to the constructor has no effect. Override + // via Object.defineProperty after construction. libGDX's + // keyForCode reads keyCode via NativeEvent.getKeyCode(). + var ke = new KeyboardEvent('keyup', { bubbles: true, cancelable: true }); + Object.defineProperty(ke, 'keyCode', { value: kc }); + Object.defineProperty(ke, 'which', { value: kc }); + $doc.dispatchEvent(ke); + } + } + }; + + $doc.addEventListener('keydown', update, true); + $doc.addEventListener('keyup', update, true); + }-*/; + @Override public int getKeyMatrixRow(int row) { return keyMatrix.get(row);