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 1/2] 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); From 53d4216515985911d490749555fc5befbad4beb0 Mon Sep 17 00:00:00 2001 From: lxpollitt <630494+lxpollitt@users.noreply.github.com> Date: Sat, 6 Jun 2026 18:35:53 +0100 Subject: [PATCH 2/2] Treat Sym/Meta+key as a no-op on the emulated keyboard matrix --- .../main/java/emu/joric/KeyboardMatrix.java | 75 ++++++++++++++++++- .../java/emu/joric/gwt/GwtKeyboardMatrix.java | 70 ++++++++++------- 2 files changed, 112 insertions(+), 33 deletions(-) diff --git a/core/src/main/java/emu/joric/KeyboardMatrix.java b/core/src/main/java/emu/joric/KeyboardMatrix.java index cd45e1e..2515f03 100644 --- a/core/src/main/java/emu/joric/KeyboardMatrix.java +++ b/core/src/main/java/emu/joric/KeyboardMatrix.java @@ -152,7 +152,38 @@ public abstract class KeyboardMatrix extends InputAdapter { private TreeMap delayedReleaseKeys = new TreeMap(); private int lastKeyDownKeycode; - + + /** + * Sym/Meta-key (libGDX Keys.SYM) state. "Meta" is the DOM / cross-platform + * name for the Command key on Mac, the Windows key on Windows, and the + * Super key on Linux. libGDX's lwjgl3 backend maps all three to + * Keys.SYM. The Oric had no equivalent key, and Sym/Meta+key combinations + * are conventionally OS / application shortcuts on every modern + * platform, so we deliberately drop them at the matrix level: while + * a Sym/Meta key is held, non-Sym/non-Meta keyDowns are suppressed (so + * they never set a matrix bit). + * + * State is updated by: + * - lwjgl3 desktop: keyDown(Keys.SYM) / keyUp(Keys.SYM). The desktop + * backend correctly translates GLFW_KEY_LEFT/RIGHT_SUPER into + * Keys.SYM, so these paths fire naturally. + * - GWT/web: a DOM-level listener installed by GwtKeyboardMatrix + * that reads event.metaKey directly and funnels through the same + * setSymPressed entry point. Necessary because libGDX's GWT + * backend currently maps the Meta key to Keys.UNKNOWN (see + * DefaultGwtInput.java's `// FIXME` on KEY_LEFT_WINDOW_KEY), so + * the keyDown(Keys.SYM) path never fires in the browser. + * + * Note: on Android, libGDX's Keys.SYM actually corresponds to the + * historical Symbol key (a Function-style modifier on early hardware + * Android keyboards such as the HTC G1 and Motorola Droid), now very + * rare (essentially extinct?) hardware. Treating it in the same way as + * a Meta key seems logical: pressing Sym+A on such hardware would otherwise + * produce a stray A on the Oric (just like CMD+A on Mac would if we did + * not have this code to suppress it). + */ + private boolean symPressed; + /** * Constructor for UserInput. */ @@ -168,8 +199,24 @@ public KeyboardMatrix() { } public boolean keyDown(int keycode) { + // Track Sym/Meta-key press. See the symPressed field comment for why. + // Keys.SYM is not in keyConvHashMap (the Oric has no Sym/Meta keys), + // so without this special-casing the early return below would skip + // the tracking. This path fires on the lwjgl3 desktop backend; the + // GWT backend delivers Keys.UNKNOWN instead, so for GWT we use + // a JSNI listener in GwtKeyboardMatrix to set this state. + if (keycode == Keys.SYM) { + setSymPressed(true); + return false; + } + // While the Sym/Meta key is held, suppress the keyDown so the matrix + // bit is never set in the first place. + if (symPressed) { + return false; + } + if (!keyConvHashMap.containsKey(keycode)) return false; - + if (keycode == 0) { // The framework wasn't able to identify the key, so we'll have to // deduce it from the key typed character. @@ -192,8 +239,15 @@ public boolean keyDown(int keycode) { } public boolean keyUp(int keycode) { + // Track Sym/Meta-key release. See keyDown's comment on why this + // special-case sits above the early return. + if (keycode == Keys.SYM) { + setSymPressed(false); + return false; + } + if (!keyConvHashMap.containsKey(keycode)) return false; - + if (keycode != 0) { long currentTime = TimeUtils.nanoTime(); long minKeyReleaseTime = minKeyReleaseTimes[keycode]; @@ -269,7 +323,20 @@ public void checkDelayedReleaseKeys() { } } + /** + * Sets the Sym/Meta-key pressed state. Funnel point for the two code + * paths that detect Sym/Meta changes: lwjgl3 desktop's keyDown/keyUp for + * Keys.SYM, and the GWT JSNI DOM listener reading event.metaKey. + * + * Visibility is protected so the GWT subclass's JSNI block can + * invoke it directly (JSNI ignores Java visibility but protected + * documents the intent). + */ + protected void setSymPressed(boolean pressed) { + symPressed = pressed; + } + public abstract int getKeyMatrixRow(int row); - + public abstract void setKeyMatrixRow(int row, int value); } diff --git a/html/src/main/java/emu/joric/gwt/GwtKeyboardMatrix.java b/html/src/main/java/emu/joric/gwt/GwtKeyboardMatrix.java index 8792677..517a49c 100644 --- a/html/src/main/java/emu/joric/gwt/GwtKeyboardMatrix.java +++ b/html/src/main/java/emu/joric/gwt/GwtKeyboardMatrix.java @@ -21,9 +21,10 @@ 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 + // registerMetaKeyWatcher's Javadoc for the libGDX GWT backend + // gaps it works around (Meta-key tracking and, on macOS, + // recovery from swallowed keyups). Only the UI-thread + // constructor calls this; the web-worker constructor below // has no DOM to listen to. registerMetaKeyWatcher(); } @@ -52,34 +53,45 @@ public native JavaScriptObject getSharedArrayBuffer()/*-{ }-*/; /** - * 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. + * Installs DOM-level keydown/keyup listeners that work around two + * Meta-key issues in libGDX's GWT backend. The first applies on + * every OS; the second is macOS-specific: * - * 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. + * 1. The GWT backend maps the Meta key to Keys.UNKNOWN instead of + * Keys.SYM like the lwjgl3 desktop does (see the long- + * standing // FIXME in DefaultGwtInput.java). As a result + * KeyboardMatrix's keyUp/keyDown Keys.SYM-driven Meta tracking + * never fires on web. So here we read event.metaKey directly on + * every DOM-level keydown/keyup and funnel it to the base class + * via setSymPressed(boolean), driving the "suppress non-Meta + * keyDowns while the Meta key is held" logic that lives there. * - * 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.) + * 2. 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. + * + * Listen with useCapture=true so this handler runs before + * libGDX's own document-level listener (which uses + * useCapture=false per DefaultGwtInput.hookEvents), ensuring the + * base class's symPressed flag is up to date by the time the + * libGDX-translated keyDown reaches the base class's suppress + * check. + * + * (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. So we don't need any special code to handle + * that case.) */ private native void registerMetaKeyWatcher()/*-{ - var metaPressed = false; + var self = this; // 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 @@ -87,9 +99,9 @@ private native void registerMetaKeyWatcher()/*-{ var trackedKeys = {}; var update = function(e) { - var wasMetaPressed = metaPressed; + var wasMetaPressed = self.@emu.joric.KeyboardMatrix::symPressed; var isMetaPressed = !!e.metaKey; - metaPressed = isMetaPressed; + self.@emu.joric.KeyboardMatrix::setSymPressed(Z)(isMetaPressed); var code = e.keyCode; // Track DOM press/release for non-Meta keys.