SDL: Feature add hint to remap option as alt key (#12021)

From 7133969e3a5ce2a4457eb3243420bdccacac49d1 Mon Sep 17 00:00:00 2001
From: William Hou <[EMAIL REDACTED]>
Date: Sun, 19 Jan 2025 23:34:04 -0500
Subject: [PATCH] Feature add hint to remap option as alt key (#12021)

---
 include/SDL3/SDL_hints.h            | 25 ++++++++++
 src/video/cocoa/SDL_cocoakeyboard.m | 71 +++++++++++++++++++++++++++++
 src/video/cocoa/SDL_cocoavideo.h    |  9 ++++
 3 files changed, 105 insertions(+)

diff --git a/include/SDL3/SDL_hints.h b/include/SDL3/SDL_hints.h
index 994143cc1c496..e5af037469c7f 100644
--- a/include/SDL3/SDL_hints.h
+++ b/include/SDL3/SDL_hints.h
@@ -2348,6 +2348,31 @@ extern "C" {
  */
 #define SDL_HINT_MAC_OPENGL_ASYNC_DISPATCH "SDL_MAC_OPENGL_ASYNC_DISPATCH"
 
+/**
+ * A variable controlling whether the Option (⌥) key on macOS should be remapped
+ * to act as the Alt key.
+ *
+ * The variable can be set to the following values:
+ *
+ * - "none": The Option key is not remapped to Alt. (default)
+ * - "only_left": Only the left Option key is remapped to Alt.
+ * - "only_right": Only the right Option key is remapped to Alt.
+ * - "both": Both Option keys are remapped to Alt.
+ *
+ * This will prevent the triggering of key compositions that rely on the Option
+ * key, but will still send the Alt modifier for keyboard events. In the case
+ * that both Alt and Option are pressed, the Option key will be ignored. This is
+ * particularly useful for applications like terminal emulators and graphical
+ * user interfaces (GUIs) that rely on Alt key functionality for shortcuts or
+ * navigation. This does not apply to SDL_GetKeyFromScancode and only has an
+ * effect if IME is enabled.
+ *
+ * This hint can be set anytime.
+ *
+ * \since This hint is available since 3.2.0
+ */
+#define SDL_HINT_MAC_OPTION_AS_ALT "SDL_MAC_OPTION_AS_ALT"
+
 /**
  * A variable controlling whether SDL_EVENT_MOUSE_WHEEL event values will have
  * momentum on macOS.
diff --git a/src/video/cocoa/SDL_cocoakeyboard.m b/src/video/cocoa/SDL_cocoakeyboard.m
index f73572da59c4a..550f533f18d4a 100644
--- a/src/video/cocoa/SDL_cocoakeyboard.m
+++ b/src/video/cocoa/SDL_cocoakeyboard.m
@@ -369,6 +369,26 @@ static void UpdateKeymap(SDL_CocoaVideoData *data, bool send_event)
     SDL_SetKeymap(keymap, send_event);
 }
 
+static void SDLCALL SDL_MacOptionAsAltChanged(void *userdata, const char *name, const char *oldValue, const char *hint)
+{
+    SDL_VideoDevice *_this = (SDL_VideoDevice *)userdata;
+    SDL_CocoaVideoData *data = (__bridge SDL_CocoaVideoData *)_this->internal;
+
+    if (hint && *hint) {
+        if (SDL_strcmp(hint, "none") == 0) {
+            data.option_as_alt = OptionAsAltNone;
+        } else if (SDL_strcmp(hint, "only_left") == 0) {
+            data.option_as_alt = OptionAsAltOnlyLeft;
+        } else if (SDL_strcmp(hint, "only_right") == 0) {
+            data.option_as_alt = OptionAsAltOnlyRight;
+        } else if (SDL_strcmp(hint, "both") == 0) {
+            data.option_as_alt = OptionAsAltBoth;
+        }
+    } else {
+        data.option_as_alt = OptionAsAltNone;
+    }
+}
+
 void Cocoa_InitKeyboard(SDL_VideoDevice *_this)
 {
     SDL_CocoaVideoData *data = (__bridge SDL_CocoaVideoData *)_this->internal;
@@ -385,6 +405,8 @@ void Cocoa_InitKeyboard(SDL_VideoDevice *_this)
 
     data.modifierFlags = (unsigned int)[NSEvent modifierFlags];
     SDL_ToggleModState(SDL_KMOD_CAPS, (data.modifierFlags & NSEventModifierFlagCapsLock) ? true : false);
+
+    SDL_AddHintCallback(SDL_HINT_MAC_OPTION_AS_ALT, SDL_MacOptionAsAltChanged, _this);
 }
 
 bool Cocoa_StartTextInput(SDL_VideoDevice *_this, SDL_Window *window, SDL_PropertiesID props)
@@ -437,6 +459,51 @@ bool Cocoa_UpdateTextInputArea(SDL_VideoDevice *_this, SDL_Window *window)
     return true;
 }
 
+static NSEvent *ReplaceEvent(NSEvent *event, OptionAsAlt option_as_alt)
+{
+    if (option_as_alt == OptionAsAltNone) {
+        return event;
+    }
+
+    const unsigned int modflags = (unsigned int)[event modifierFlags];
+
+    bool ignore_alt_characters = false;
+
+    bool lalt_pressed = IsModifierKeyPressed(modflags, NX_DEVICELALTKEYMASK,
+                                             NX_DEVICERALTKEYMASK, NX_ALTERNATEMASK);
+    bool ralt_pressed = IsModifierKeyPressed(modflags, NX_DEVICERALTKEYMASK,
+                                             NX_DEVICELALTKEYMASK, NX_ALTERNATEMASK);
+
+    if (option_as_alt == OptionAsAltOnlyLeft && lalt_pressed) {
+        ignore_alt_characters = true;
+    } else if (option_as_alt == OptionAsAltOnlyRight && ralt_pressed) {
+        ignore_alt_characters = true;
+    } else if (option_as_alt == OptionAsAltBoth && (lalt_pressed || ralt_pressed)) {
+        ignore_alt_characters = true;
+    }
+
+    bool cmd_pressed = modflags & NX_COMMANDMASK;
+    bool ctrl_pressed = modflags & NX_CONTROLMASK;
+
+    ignore_alt_characters = ignore_alt_characters && !cmd_pressed && !ctrl_pressed;
+
+    if (ignore_alt_characters) {
+        NSString *charactersIgnoringModifiers = [event charactersIgnoringModifiers];
+        return [NSEvent keyEventWithType:[event type]
+                                location:[event locationInWindow]
+                           modifierFlags:modflags
+                               timestamp:[event timestamp]
+                            windowNumber:[event windowNumber]
+                                 context:nil
+                              characters:charactersIgnoringModifiers
+             charactersIgnoringModifiers:charactersIgnoringModifiers
+                               isARepeat:[event isARepeat]
+                                 keyCode:[event keyCode]];
+    }
+
+    return event;
+}
+
 void Cocoa_HandleKeyEvent(SDL_VideoDevice *_this, NSEvent *event)
 {
     unsigned short scancode;
@@ -446,6 +513,10 @@ void Cocoa_HandleKeyEvent(SDL_VideoDevice *_this, NSEvent *event)
         return; // can happen when returning from fullscreen Space on shutdown
     }
 
+    if ([event type] == NSEventTypeKeyDown || [event type] == NSEventTypeKeyUp) {
+        event = ReplaceEvent(event, data.option_as_alt);
+    }
+
     scancode = [event keyCode];
 
     if ((scancode == 10 || scancode == 50) && KBGetLayoutType(LMGetKbdType()) == kKeyboardISO) {
diff --git a/src/video/cocoa/SDL_cocoavideo.h b/src/video/cocoa/SDL_cocoavideo.h
index 75c1ec79f0490..353fb43509d2c 100644
--- a/src/video/cocoa/SDL_cocoavideo.h
+++ b/src/video/cocoa/SDL_cocoavideo.h
@@ -44,6 +44,14 @@
 
 @class SDL3TranslatorResponder;
 
+typedef enum
+{
+    OptionAsAltNone,
+    OptionAsAltOnlyLeft,
+    OptionAsAltOnlyRight,
+    OptionAsAltBoth,
+} OptionAsAlt;
+
 @interface SDL_CocoaVideoData : NSObject
 @property(nonatomic) int allow_spaces;
 @property(nonatomic) int trackpad_is_touch_only;
@@ -53,6 +61,7 @@
 @property(nonatomic) NSInteger clipboard_count;
 @property(nonatomic) IOPMAssertionID screensaver_assertion;
 @property(nonatomic) SDL_Mutex *swaplock;
+@property(nonatomic) OptionAsAlt option_as_alt;
 @end
 
 // Utility functions