SDL: Add SDL Pinch events (#9445)

From 71bf56c9e49e94078c443196af13b809edc86e55 Mon Sep 17 00:00:00 2001
From: Sylvain Becker <[EMAIL REDACTED]>
Date: Sun, 12 Oct 2025 23:44:23 +0200
Subject: [PATCH] Add SDL Pinch events (#9445)

---
 .../main/java/org/libsdl/app/SDLActivity.java |   3 +
 .../main/java/org/libsdl/app/SDLSurface.java  |  30 ++-
 cmake/sdlchecks.cmake                         |  17 ++
 include/SDL3/SDL_events.h                     |  18 ++
 include/build_config/SDL_build_config.h.cmake |   1 +
 src/core/android/SDL_android.c                |  50 ++++
 src/events/SDL_events.c                       |  17 +-
 src/events/SDL_touch.c                        |  16 ++
 src/events/SDL_touch_c.h                      |   3 +
 src/test/SDL_test_common.c                    |  11 +
 src/video/cocoa/SDL_cocoawindow.h             |   1 +
 src/video/cocoa/SDL_cocoawindow.m             |  21 ++
 src/video/uikit/SDL_uikitview.h               |   3 +
 src/video/uikit/SDL_uikitview.m               |  43 +++
 src/video/wayland/SDL_waylandevents.c         |  48 ++++
 src/video/wayland/SDL_waylandevents_c.h       |   1 +
 src/video/wayland/SDL_waylandvideo.c          |   8 +
 src/video/wayland/SDL_waylandvideo.h          |   1 +
 src/video/x11/SDL_x11xinput2.c                |  51 +++-
 test/testgeometry.c                           |  22 +-
 .../pointer-gestures-unstable-v1.xml          | 253 ++++++++++++++++++
 21 files changed, 605 insertions(+), 13 deletions(-)
 create mode 100644 wayland-protocols/pointer-gestures-unstable-v1.xml

diff --git a/android-project/app/src/main/java/org/libsdl/app/SDLActivity.java b/android-project/app/src/main/java/org/libsdl/app/SDLActivity.java
index 796417588aff3..da7bbee7930db 100644
--- a/android-project/app/src/main/java/org/libsdl/app/SDLActivity.java
+++ b/android-project/app/src/main/java/org/libsdl/app/SDLActivity.java
@@ -1084,6 +1084,9 @@ public static native void onNativeTouch(int touchDevId, int pointerFingerId,
     public static native boolean nativeAllowRecreateActivity();
     public static native int nativeCheckSDLThreadCounter();
     public static native void onNativeFileDialog(int requestCode, String[] filelist, int filter);
+    public static native void onNativePinchStart();
+    public static native void onNativePinchUpdate(float scale);
+    public static native void onNativePinchEnd();
 
     /**
      * This method is called by SDL using JNI.
diff --git a/android-project/app/src/main/java/org/libsdl/app/SDLSurface.java b/android-project/app/src/main/java/org/libsdl/app/SDLSurface.java
index 8cd12621595dd..b8fae21e27ac8 100644
--- a/android-project/app/src/main/java/org/libsdl/app/SDLSurface.java
+++ b/android-project/app/src/main/java/org/libsdl/app/SDLSurface.java
@@ -23,6 +23,7 @@
 import android.view.WindowInsets;
 import android.view.WindowManager;
 
+import android.view.ScaleGestureDetector;
 
 /**
     SDLSurface. This is what we draw on, so we need to know when it's created
@@ -31,7 +32,8 @@
     Because of this, that's where we set up the SDL thread
 */
 public class SDLSurface extends SurfaceView implements SurfaceHolder.Callback,
-    View.OnApplyWindowInsetsListener, View.OnKeyListener, View.OnTouchListener, SensorEventListener  {
+    View.OnApplyWindowInsetsListener, View.OnKeyListener, View.OnTouchListener,
+    SensorEventListener, ScaleGestureDetector.OnScaleGestureListener {
 
     // Sensors
     protected SensorManager mSensorManager;
@@ -43,11 +45,16 @@ public class SDLSurface extends SurfaceView implements SurfaceHolder.Callback,
     // Is SurfaceView ready for rendering
     protected boolean mIsSurfaceReady;
 
+    // Pinch events
+    private final ScaleGestureDetector scaleGestureDetector;
+
     // Startup
     protected SDLSurface(Context context) {
         super(context);
         getHolder().addCallback(this);
 
+        scaleGestureDetector = new ScaleGestureDetector(context, this);
+
         setFocusable(true);
         setFocusableInTouchMode(true);
         requestFocus();
@@ -294,6 +301,8 @@ public boolean onTouch(View v, MotionEvent event) {
                 break;
         } while (++i < pointerCount);
 
+        scaleGestureDetector.onTouchEvent(event);
+
         return true;
     }
 
@@ -415,4 +424,23 @@ public boolean onCapturedPointerEvent(MotionEvent event)
 
         return false;
     }
+
+    @Override
+    public boolean onScale(ScaleGestureDetector detector) {
+        float scale = detector.getScaleFactor();
+        SDLActivity.onNativePinchUpdate(scale);
+        return true;
+    }
+
+    @Override
+    public boolean onScaleBegin(ScaleGestureDetector detector) {
+        SDLActivity.onNativePinchStart();
+        return true;
+    }
+
+    @Override
+    public void onScaleEnd(ScaleGestureDetector detector) {
+        SDLActivity.onNativePinchEnd();
+    }
+
 }
diff --git a/cmake/sdlchecks.cmake b/cmake/sdlchecks.cmake
index cd8576d44b6d3..d444c4cb166a7 100644
--- a/cmake/sdlchecks.cmake
+++ b/cmake/sdlchecks.cmake
@@ -441,6 +441,23 @@ macro(CheckX11)
         if(HAVE_XINPUT2_MULTITOUCH)
           set(SDL_VIDEO_DRIVER_X11_XINPUT2_SUPPORTS_MULTITOUCH 1)
         endif()
+
+        # Check for gesture
+        check_c_source_compiles("
+            #include <X11/Xlib.h>
+            #include <X11/Xproto.h>
+            #include <X11/extensions/XInput2.h>
+            int event_type = XI_GesturePinchBegin;
+            XITouchClassInfo *t;
+            Status XIAllowTouchEvents(Display *a,int b,unsigned int c,Window d,int f) {
+              return (Status)0;
+            }
+            int main(int argc, char **argv) { return 0; }" HAVE_XINPUT2_GESTURE)
+        if(HAVE_XINPUT2_GESTURE)
+          set(SDL_VIDEO_DRIVER_X11_XINPUT2_SUPPORTS_GESTURE 1)
+        endif()
+
+
       endif()
 
       # check along with XInput2.h because we use Xfixes with XIBarrierReleasePointer
diff --git a/include/SDL3/SDL_events.h b/include/SDL3/SDL_events.h
index 999ede9beefbe..11924cf082a88 100644
--- a/include/SDL3/SDL_events.h
+++ b/include/SDL3/SDL_events.h
@@ -218,6 +218,11 @@ typedef enum SDL_EventType
     SDL_EVENT_FINGER_MOTION,
     SDL_EVENT_FINGER_CANCELED,
 
+    /* Pinch events */
+    SDL_EVENT_PINCH_BEGIN      = 0x710,     /**< Pinch gesture started */
+    SDL_EVENT_PINCH_UPDATE,                 /**< Pinch gesture updated */
+    SDL_EVENT_PINCH_END,                    /**< Pinch gesture ended */
+
     /* 0x800, 0x801, and 0x802 were the Gesture events from SDL2. Do not reuse these values! sdl2-compat needs them! */
 
     /* Clipboard events */
@@ -788,6 +793,18 @@ typedef struct SDL_TouchFingerEvent
     SDL_WindowID windowID; /**< The window underneath the finger, if any */
 } SDL_TouchFingerEvent;
 
+/**
+ *  Pinch event structure (event.pinch.*)
+ */
+typedef struct SDL_PinchFingerEvent
+{
+    SDL_EventType type; /**< ::SDL_EVENT_PINCH_BEGIN or ::SDL_EVENT_PINCH_UPDATE or ::SDL_EVENT_PINCH_END */
+    Uint32 reserved;
+    Uint64 timestamp;   /**< In nanoseconds, populated using SDL_GetTicksNS() */
+    float scale;        /**< The scale change since the last SDL_EVENT_PINCH_UPDATE. Scale < 1 is "zoom out". Scale > 1 is "zoom in". */
+    SDL_WindowID windowID; /**< The window underneath the finger, if any */
+} SDL_PinchFingerEvent;
+
 /**
  * Pressure-sensitive pen proximity event structure (event.pproximity.*)
  *
@@ -1025,6 +1042,7 @@ typedef union SDL_Event
     SDL_QuitEvent quit;                     /**< Quit request event data */
     SDL_UserEvent user;                     /**< Custom event data */
     SDL_TouchFingerEvent tfinger;           /**< Touch finger event data */
+    SDL_PinchFingerEvent pinch;             /**< Pinch event data */
     SDL_PenProximityEvent pproximity;       /**< Pen proximity event data */
     SDL_PenTouchEvent ptouch;               /**< Pen tip touching event data */
     SDL_PenMotionEvent pmotion;             /**< Pen motion event data */
diff --git a/include/build_config/SDL_build_config.h.cmake b/include/build_config/SDL_build_config.h.cmake
index 14dd86deaff64..b4e721ca6b215 100644
--- a/include/build_config/SDL_build_config.h.cmake
+++ b/include/build_config/SDL_build_config.h.cmake
@@ -436,6 +436,7 @@
 #cmakedefine SDL_VIDEO_DRIVER_X11_XINPUT2 1
 #cmakedefine SDL_VIDEO_DRIVER_X11_XINPUT2_SUPPORTS_MULTITOUCH 1
 #cmakedefine SDL_VIDEO_DRIVER_X11_XINPUT2_SUPPORTS_SCROLLINFO 1
+#cmakedefine SDL_VIDEO_DRIVER_X11_XINPUT2_SUPPORTS_GESTURE @SDL_VIDEO_DRIVER_X11_XINPUT2_SUPPORTS_GESTURE@
 #cmakedefine SDL_VIDEO_DRIVER_X11_XRANDR 1
 #cmakedefine SDL_VIDEO_DRIVER_X11_XSCRNSAVER 1
 #cmakedefine SDL_VIDEO_DRIVER_X11_XSHAPE 1
diff --git a/src/core/android/SDL_android.c b/src/core/android/SDL_android.c
index c5c3683e262a2..88a2754d6d132 100644
--- a/src/core/android/SDL_android.c
+++ b/src/core/android/SDL_android.c
@@ -121,6 +121,16 @@ JNIEXPORT void JNICALL SDL_JAVA_INTERFACE(onNativeTouch)(
     jint touch_device_id_in, jint pointer_finger_id_in,
     jint action, jfloat x, jfloat y, jfloat p);
 
+JNIEXPORT void JNICALL SDL_JAVA_INTERFACE(onNativePinchStart)(
+    JNIEnv *env, jclass jcls);
+
+JNIEXPORT void JNICALL SDL_JAVA_INTERFACE(onNativePinchUpdate)(
+    JNIEnv *env, jclass jcls,
+    jfloat scale);
+
+JNIEXPORT void JNICALL SDL_JAVA_INTERFACE(onNativePinchEnd)(
+    JNIEnv *env, jclass jcls);
+
 JNIEXPORT void JNICALL SDL_JAVA_INTERFACE(onNativeMouse)(
     JNIEnv *env, jclass jcls,
     jint button, jint action, jfloat x, jfloat y, jboolean relative);
@@ -221,6 +231,9 @@ static JNINativeMethod SDLActivity_tab[] = {
     { "onNativeSoftReturnKey", "()Z", SDL_JAVA_INTERFACE(onNativeSoftReturnKey) },
     { "onNativeKeyboardFocusLost", "()V", SDL_JAVA_INTERFACE(onNativeKeyboardFocusLost) },
     { "onNativeTouch", "(IIIFFF)V", SDL_JAVA_INTERFACE(onNativeTouch) },
+    { "onNativePinchStart", "()V", SDL_JAVA_INTERFACE(onNativePinchStart) },
+    { "onNativePinchUpdate", "(F)V", SDL_JAVA_INTERFACE(onNativePinchUpdate) },
+    { "onNativePinchEnd", "()V", SDL_JAVA_INTERFACE(onNativePinchEnd) },
     { "onNativeMouse", "(IIFFZ)V", SDL_JAVA_INTERFACE(onNativeMouse) },
     { "onNativePen", "(IIIFFF)V", SDL_JAVA_INTERFACE(onNativePen) },
     { "onNativeAccel", "(FFF)V", SDL_JAVA_INTERFACE(onNativeAccel) },
@@ -1366,6 +1379,43 @@ JNIEXPORT void JNICALL SDL_JAVA_INTERFACE(onNativeTouch)(
     SDL_UnlockMutex(Android_ActivityMutex);
 }
 
+// Pinch
+JNIEXPORT void JNICALL SDL_JAVA_INTERFACE(onNativePinchStart)(
+    JNIEnv *env, jclass jcls)
+{
+    SDL_LockMutex(Android_ActivityMutex);
+
+    if (Android_Window) {
+        SDL_SendPinch(SDL_EVENT_PINCH_BEGIN, 0, Android_Window, 0);
+    }
+
+    SDL_UnlockMutex(Android_ActivityMutex);
+}
+
+JNIEXPORT void JNICALL SDL_JAVA_INTERFACE(onNativePinchUpdate)(
+    JNIEnv *env, jclass jcls, jfloat scale)
+{
+    SDL_LockMutex(Android_ActivityMutex);
+
+    if (Android_Window) {
+        SDL_SendPinch(SDL_EVENT_PINCH_UPDATE, 0, Android_Window, scale);
+    }
+
+    SDL_UnlockMutex(Android_ActivityMutex);
+}
+
+JNIEXPORT void JNICALL SDL_JAVA_INTERFACE(onNativePinchEnd)(
+    JNIEnv *env, jclass jcls)
+{
+    SDL_LockMutex(Android_ActivityMutex);
+
+    if (Android_Window) {
+        SDL_SendPinch(SDL_EVENT_PINCH_END, 0, Android_Window, 0);
+    }
+
+    SDL_UnlockMutex(Android_ActivityMutex);
+}
+
 // Mouse
 JNIEXPORT void JNICALL SDL_JAVA_INTERFACE(onNativeMouse)(
     JNIEnv *env, jclass jcls,
diff --git a/src/events/SDL_events.c b/src/events/SDL_events.c
index adff57e895626..bf7cedaf2ce14 100644
--- a/src/events/SDL_events.c
+++ b/src/events/SDL_events.c
@@ -770,6 +770,20 @@ int SDL_GetEventDescription(const SDL_Event *event, char *buf, int buflen)
         break;
 #undef PRINT_FINGER_EVENT
 
+#define PRINT_PINCH_EVENT(event)                                                                                                                      \
+    (void)SDL_snprintf(details, sizeof(details), " (timestamp=%u scale=%f)", \
+                       (uint)event->pinch.timestamp, event->pinch.scale)
+        SDL_EVENT_CASE(SDL_EVENT_PINCH_BEGIN)
+        PRINT_PINCH_EVENT(event);
+        break;
+        SDL_EVENT_CASE(SDL_EVENT_PINCH_UPDATE)
+        PRINT_PINCH_EVENT(event);
+        break;
+        SDL_EVENT_CASE(SDL_EVENT_PINCH_END)
+        PRINT_PINCH_EVENT(event);
+        break;
+#undef PRINT_PINCH_EVENT
+
 #define PRINT_PTOUCH_EVENT(event)                                                                             \
     (void)SDL_snprintf(details, sizeof(details), " (timestamp=%u windowid=%u which=%u pen_state=%u x=%g y=%g eraser=%s state=%s)", \
                        (uint)event->ptouch.timestamp, (uint)event->ptouch.windowID, (uint)event->ptouch.which, (uint)event->ptouch.pen_state, event->ptouch.x, event->ptouch.y, \
@@ -902,12 +916,13 @@ static void SDL_LogEvent(const SDL_Event *event)
         return;
     }
 
-    // sensor/mouse/pen/finger motion are spammy, ignore these if they aren't demanded.
+    // sensor/mouse/pen/finger/pinch motion are spammy, ignore these if they aren't demanded.
     if ((SDL_EventLoggingVerbosity < 2) &&
         ((event->type == SDL_EVENT_MOUSE_MOTION) ||
          (event->type == SDL_EVENT_FINGER_MOTION) ||
          (event->type == SDL_EVENT_PEN_AXIS) ||
          (event->type == SDL_EVENT_PEN_MOTION) ||
+         (event->type == SDL_EVENT_PINCH_UPDATE) ||
          (event->type == SDL_EVENT_GAMEPAD_AXIS_MOTION) ||
          (event->type == SDL_EVENT_GAMEPAD_SENSOR_UPDATE) ||
          (event->type == SDL_EVENT_GAMEPAD_TOUCHPAD_MOTION) ||
diff --git a/src/events/SDL_touch.c b/src/events/SDL_touch.c
index e825117c82a7e..6c0388e7f70cf 100644
--- a/src/events/SDL_touch.c
+++ b/src/events/SDL_touch.c
@@ -500,3 +500,19 @@ void SDL_QuitTouch(void)
     SDL_free(SDL_touchDevices);
     SDL_touchDevices = NULL;
 }
+
+int SDL_SendPinch(SDL_EventType type, Uint64 timestamp, SDL_Window *window, float scale)
+{
+    /* Post the event, if desired */
+    int posted = 0;
+    if (SDL_EventEnabled(type)) {
+        SDL_Event event;
+        event.type = type;
+        event.common.timestamp = timestamp;
+        event.pinch.scale = scale;
+        event.pinch.windowID = window ? SDL_GetWindowID(window) : 0;
+        posted = (SDL_PushEvent(&event) > 0);
+    }
+    return posted;
+}
+
diff --git a/src/events/SDL_touch_c.h b/src/events/SDL_touch_c.h
index db2d64b85f004..4c64a04a9beee 100644
--- a/src/events/SDL_touch_c.h
+++ b/src/events/SDL_touch_c.h
@@ -57,4 +57,7 @@ extern void SDL_DelTouch(SDL_TouchID id);
 // Shutdown the touch subsystem
 extern void SDL_QuitTouch(void);
 
+// Send Gesture events
+extern int SDL_SendPinch(SDL_EventType type, Uint64 timestamp, SDL_Window *window, float scale);
+
 #endif // SDL_touch_c_h_
diff --git a/src/test/SDL_test_common.c b/src/test/SDL_test_common.c
index b5ffc9a46f75a..c1158df7002f0 100644
--- a/src/test/SDL_test_common.c
+++ b/src/test/SDL_test_common.c
@@ -1928,6 +1928,16 @@ void SDLTest_PrintEvent(const SDL_Event *event)
                 event->tfinger.dx, event->tfinger.dy, event->tfinger.pressure);
         break;
 
+    case SDL_EVENT_PINCH_BEGIN:
+        SDL_Log("SDL EVENT: Pinch Begin");
+        break;
+    case SDL_EVENT_PINCH_UPDATE:
+        SDL_Log("SDL EVENT: Pinch Update, scale=%f", event->pinch.scale);
+        break;
+    case SDL_EVENT_PINCH_END:
+        SDL_Log("SDL EVENT: Pinch End");
+        break;
+
     case SDL_EVENT_RENDER_TARGETS_RESET:
         SDL_Log("SDL EVENT: render targets reset in window %" SDL_PRIu32, event->render.windowID);
         break;
@@ -2238,6 +2248,7 @@ SDL_AppResult SDLTest_CommonEventMainCallbacks(SDLTest_CommonState *state, const
              event->type != SDL_EVENT_FINGER_MOTION &&
              event->type != SDL_EVENT_PEN_MOTION &&
              event->type != SDL_EVENT_PEN_AXIS &&
+             event->type != SDL_EVENT_PINCH_UPDATE &&
              event->type != SDL_EVENT_JOYSTICK_AXIS_MOTION) ||
             (state->verbose & VERBOSE_MOTION)) {
             SDLTest_PrintEvent(event);
diff --git a/src/video/cocoa/SDL_cocoawindow.h b/src/video/cocoa/SDL_cocoawindow.h
index e4ab6efed4e19..cce02c2dab558 100644
--- a/src/video/cocoa/SDL_cocoawindow.h
+++ b/src/video/cocoa/SDL_cocoawindow.h
@@ -122,6 +122,7 @@ typedef enum
 - (void)touchesMovedWithEvent:(NSEvent *)theEvent;
 - (void)touchesEndedWithEvent:(NSEvent *)theEvent;
 - (void)touchesCancelledWithEvent:(NSEvent *)theEvent;
+- (void)magnifyWithEvent:(NSEvent *) theEvent;
 
 // Touch event handling
 - (void)handleTouches:(NSTouchPhase)phase withEvent:(NSEvent *)theEvent;
diff --git a/src/video/cocoa/SDL_cocoawindow.m b/src/video/cocoa/SDL_cocoawindow.m
index 72741fc6ff243..2fee316dfcfb6 100644
--- a/src/video/cocoa/SDL_cocoawindow.m
+++ b/src/video/cocoa/SDL_cocoawindow.m
@@ -1990,6 +1990,27 @@ - (void)touchesCancelledWithEvent:(NSEvent *)theEvent
     [self handleTouches:NSTouchPhaseCancelled withEvent:theEvent];
 }
 
+- (void)magnifyWithEvent:(NSEvent *)theEvent
+{
+    switch ([theEvent phase]) {
+    case NSEventPhaseBegan:
+        SDL_SendPinch(SDL_EVENT_PINCH_BEGIN, Cocoa_GetEventTimestamp([theEvent timestamp]), NULL, 0);
+        break;
+    case NSEventPhaseChanged:
+        {
+            CGFloat scale = 1.0f + [theEvent magnification];
+            SDL_SendPinch(SDL_EVENT_PINCH_UPDATE, Cocoa_GetEventTimestamp([theEvent timestamp]), NULL, scale);
+        }
+        break;
+    case NSEventPhaseEnded:
+    case NSEventPhaseCancelled:
+        SDL_SendPinch(SDL_EVENT_PINCH_END, Cocoa_GetEventTimestamp([theEvent timestamp]), NULL, 0);
+        break;
+    default:
+        break;
+    }
+}
+
 - (void)handleTouches:(NSTouchPhase)phase withEvent:(NSEvent *)theEvent
 {
     NSSet *touches = [theEvent touchesMatchingPhase:phase inView:nil];
diff --git a/src/video/uikit/SDL_uikitview.h b/src/video/uikit/SDL_uikitview.h
index 78c2beda36157..53425674d2a29 100644
--- a/src/video/uikit/SDL_uikitview.h
+++ b/src/video/uikit/SDL_uikitview.h
@@ -46,6 +46,9 @@
 - (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event;
 - (void)touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event;
 - (void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event;
+#if !defined(SDL_PLATFORM_TVOS)
+- (IBAction)sdlPinchGesture:(UIPinchGestureRecognizer *)sender;
+#endif
 
 - (void)safeAreaInsetsDidChange;
 
diff --git a/src/video/uikit/SDL_uikitview.m b/src/video/uikit/SDL_uikitview.m
index ed649b1fc06bd..da5b6d75d70e6 100644
--- a/src/video/uikit/SDL_uikitview.m
+++ b/src/video/uikit/SDL_uikitview.m
@@ -48,6 +48,7 @@ @implementation SDL_uikitview
 
     SDL_TouchID directTouchId;
     SDL_TouchID indirectTouchId;
+    float pinch_scale;
 
 #if !defined(SDL_PLATFORM_TVOS)
     UIPointerInteraction *indirectPointerInteraction API_AVAILABLE(ios(13.4));
@@ -76,6 +77,15 @@ - (instancetype)initWithFrame:(CGRect)frame
         [self addGestureRecognizer:swipeRight];
 #endif
 
+#if !defined(SDL_PLATFORM_TVOS)
+        /* Pinch gestures */
+        UIPinchGestureRecognizer *pinchGesture = [[UIPinchGestureRecognizer alloc] initWithTarget:self action:@selector(sdlPinchGesture:)];
+        pinchGesture.cancelsTouchesInView = NO;
+        pinchGesture.delaysTouchesBegan = NO;
+        pinchGesture.delaysTouchesEnded = NO;
+        [self addGestureRecognizer:pinchGesture];
+#endif
+
         self.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight;
         self.autoresizesSubviews = YES;
 
@@ -470,6 +480,39 @@ - (void)safeAreaInsetsDidChange
                                 (int)SDL_ceilf(self.safeAreaInsets.bottom));
 }
 
+#if !defined(SDL_PLATFORM_TVOS)
+- (IBAction)sdlPinchGesture:(UIPinchGestureRecognizer *)sender
+{
+    CGFloat scale = sender.scale;
+    UIGestureRecognizerState state = sender.state;
+
+    switch (state) {
+
+        case UIGestureRecognizerStateBegan:
+            pinch_scale = 1.0f;
+            SDL_SendPinch(SDL_EVENT_PINCH_BEGIN, 0, sdlwindow, 0);
+            break;
+
+        case UIGestureRecognizerStateChanged:
+            if (pinch_scale > 0.0f) {
+                SDL_SendPinch(SDL_EVENT_PINCH_UPDATE, 0, sdlwindow, scale / pinch_scale);
+            }
+            pinch_scale = scale;
+            break;
+
+        case UIGestureRecognizerStateFailed:
+        case UIGestureRecognizerStateEnded:
+        case UIGestureRecognizerStateCancelled:
+            SDL_SendPinch(SDL_EVENT_PINCH_END, 0, sdlwindow, 0);
+            break;
+
+        default:
+            break;
+    }
+
+}
+#endif
+
 - (SDL_Scancode)scancodeFromPress:(UIPress *)press
 {
     if (press.key != nil) {
diff --git a/src/video/wayland/SDL_waylandevents.c b/src/video/wayland/SDL_waylandevents.c
index 4fdc9c6df3614..2b89953f4b934 100644
--- a/src/video/wayland/SDL_waylandevents.c
+++ b/src/video/wayland/SDL_waylandevents.c
@@ -45,6 +45,7 @@
 #include "tablet-v2-client-protocol.h"
 #include "primary-selection-unstable-v1-client-protocol.h"
 #include "input-timestamps-unstable-v1-client-protocol.h"
+#include "pointer-gestures-unstable-v1-client-protocol.h"
 
 #ifdef HAVE_LIBDECOR_H
 #include <libdecor.h>
@@ -1416,6 +1417,43 @@ static const struct wl_touch_listener touch_listener = {
     touch_handler_orientation // Version 6
 };
 
+void pinch_begin(void *data,
+          struct zwp_pointer_gesture_pinch_v1 *zwp_pointer_gesture_pinch_v1,
+          uint32_t serial,
+          uint32_t time,
+          struct wl_surface *surface,
+          uint32_t fingers)
+{
+    SDL_SendPinch(SDL_EVENT_PINCH_BEGIN, 0, NULL, 0);
+}
+void pinch_update(void *data,
+           struct zwp_pointer_gesture_pinch_v1 *zwp_pointer_gesture_pinch_v1,
+           uint32_t time,
+           wl_fixed_t dx,
+           wl_fixed_t dy,
+           wl_fixed_t scale,
+           wl_fixed_t rotation)
+{
+
+    float s = (float)(wl_fixed_to_double(scale));
+    SDL_SendPinch(SDL_EVENT_PINCH_UPDATE, 0, NULL, s);
+}
+
+void pinch_end(void *data,
+        struct zwp_pointer_gesture_pinch_v1 *zwp_pointer_gesture_pinch_v1,
+        uint32_t serial,
+        uint32_t time,
+        int32_t cancelled)
+{
+    SDL_SendPinch(SDL_EVENT_PINCH_END, 0, NULL, 0);
+}
+
+static const struct zwp_pointer_gesture_pinch_v1_listener gesture_pinch_listener = {
+    pinch_begin,
+    pinch_update,
+    pinch_end
+};
+
 // Fallback for xkb_keymap_key_get_mods_for_level(), which is only available from 1.0.0, while the SDL minimum is 0.5.0.
 #if !SDL_XKBCOMMON_CHECK_VERSION(1, 0, 0)
 static size_t xkb_legacy_get_mods_for_level(SDL_WaylandSeat *seat, xkb_keycode_t key, xkb_layout_index_t layout, xkb_level_index_t level, xkb_mod_mask_t *masks_out, size_t masks_size)
@@ -2387,6 +2425,10 @@ static void Wayland_SeatDestroyTouch(SDL_WaylandSeat *seat)
         }
     }
 
+    if (seat->touch.gesture_pinch) {
+        zwp_pointer_gesture_pinch_v1_destroy(seat->touch.gesture_pinch);
+    }
+
     SDL_zero(seat->touch);
     WAYLAND_wl_list_init(&seat->touch.points);
 }
@@ -2430,6 +2472,12 @@ static void seat_handle_capabilities(void *data, struct wl_seat *wl_seat, enum w
         }
 
         SDL_AddTouch((SDL_TouchID)(uintptr_t)seat->touch.wl_touch, SDL_TOUCH_DEVICE_DIRECT, name_fmt);
+   
+        /* Pinch gesture */
+        seat->touch.gesture_pinch = zwp_pointer_gestures_v1_get_pinch_gesture(seat->display->zwp_pointer_gestures, seat->pointer.wl_pointer);
+        zwp_pointer_gesture_pinch_v1_set_user_data(seat->touch.gesture_pinch, seat);
+        zwp_pointer_gesture_pinch_v1_add_listener(seat->touch.gesture_pinch, &gesture_pinch_listener, seat);
+
     } else if (!(capabilities & WL_SEAT_CAPABILITY_TOUCH) && seat->touch.wl_touch) {
         Wayland_SeatDestroyTouch(seat);
     }
diff --git a/src/video/wayland/SDL_waylandevents_c.h b/src/video/wayland/SDL_waylandevents_c.h
index c56a3adf0609b..70da05763ace5 100644
--- a/src/video/wayland/SDL_waylandevents_c.h
+++ b/src/video/wayland/SDL_waylandevents_c.h
@@ -192,6 +192,7 @@ typedef struct SDL_WaylandSeat
         struct zwp_input_timestamps_v1 *timestamps;
         Uint64 highres_timestamp_ns;
         struct wl_list points;
+        struct zwp_pointer_gesture_pinch_v1 *gesture_pinch;
     } touch;
 
     struct
diff --git a/src/video/wayland/SDL_waylandvideo.c b/src/video/wayland/SDL_waylandvideo.c
index 158a0e541bece..cdfe4227b9e91 100644
--- a/src/video/wayland/SDL_waylandvideo.c
+++ b/src/video/wayland/SDL_waylandvideo.c
@@ -67,6 +67,7 @@
 #include "xdg-toplevel-icon-v1-client-protocol.h"
 #include "color-management-v1-client-protocol.h"
 #include "pointer-warp-v1-client-protocol.h"
+#include "pointer-gestures-unstable-v1-client-protocol.h"
 
 #ifdef HAVE_LIBDECOR_H
 #include <libdecor.h>
@@ -1322,6 +1323,8 @@ static void handle_registry_global(void *data, struct wl_registry *registry, uin
         Wayland_InitColorManager(d);
     } else if (SDL_strcmp(interface, "wp_pointer_warp_v1") == 0) {
         d->wp_pointer_warp_v1 = wl_registry_bind(d->registry, id, &wp_pointer_warp_v1_interface, 1);
+    } else if (SDL_strcmp(interface, "zwp_pointer_gestures_v1") == 0) {
+        d->zwp_pointer_gestures = wl_registry_bind(d->registry, id, &zwp_pointer_gestures_v1_interface, 1);
     }
 #ifdef SDL_WL_FIXES_VERSION
     else if (SDL_strcmp(interface, "wl_fixes") == 0) {
@@ -1645,6 +1648,11 @@ static void Wayland_VideoCleanup(SDL_VideoDevice *_this)
         data->wp_pointer_warp_v1 = NULL;
     }
 
+    if (data->zwp_pointer_gestures) {
+        zwp_pointer_gestures_v1_destroy(data->zwp_pointer_gestures);
+        data->zwp_pointer_gestures = NULL;
+    }
+
     if (data->compositor) {
         wl_compositor_destroy(data->compositor);
         data->compositor = NULL;
diff --git a/src/video/wayland/SDL_waylandvideo.h b/src/video/wayland/SDL_waylandvideo.h
index 837df3e4a41c8..644264a4a89e9 100644
--- a/src/video/wayland/SDL_waylandvideo.h
+++ b/src/video/wayland/SDL_waylandvideo.h
@@ -86,6 +86,7 @@ struct SDL_VideoData
     struct wp_color_manager_v1 *wp_color_manager_v1;
     struct zwp_tablet_manager_v2 *tablet_manager;
     struct wl_fixes *wl_fixes;
+    struct zwp_pointer_gestures_v1 *zwp_pointer_gestures;
 
     struct xkb_context *xkb_context;
 
diff --git a/src/video/x11/SDL_x11xinput2.c b/src/video/x11/SDL_x11xinput2.c
index e889aa248867e..9b3c7c543ff02 100644
--- a/src/video/x11/SDL_x11xinput2.c
+++ b/src/video/x11/SDL_x11xinput2.c
@@ -38,6 +38,11 @@ static bool xinput2_initialized;
 #ifdef SDL_VIDEO_DRIVER_X11_XINPUT2_SUPPORTS_MULTITOUCH
 static bool xinput2_multitouch_supported;
 #endif
+#ifdef SDL_VIDEO_DRIVER_X11_XINPUT2_SUPPORTS_GESTURE
+static int xinput2_gesture_supported = 0;
+#endif
+
+static int X11_Xinput2IsGestureSupported(void);
 
 /* Opcode returned X11_XQueryExtension
  * It will be used in event processing
@@ -272,8 +277,8 @@ bool X11_InitXinput2(SDL_VideoDevice *_this)
         return false; // X server does not have XInput at all
     }
 
-    // We need at least 2.2 for Multitouch, 2.0 otherwise.
-    version = query_xinput2_version(data->display, 2, 2);
+    // We need at least 2.4 for Gesture, 2.2 for Multitouch, 2.0 otherwise.
+    version = query_xinput2_version(data->display, 2, 4);
     if (!xinput2_version_atleast(version, 2, 0)) {
         return false; // X server does not support the version we want at all.
     }
@@ -287,6 +292,9 @@ bool X11_InitXinput2(SDL_VideoDevice *_this)
 #ifdef SDL_VIDEO_DRIVER_X11_XINPUT2_SUPPORTS_MULTITOUCH // Multitouch needs XInput 2.2
     xinput2_multitouch_supported = xinput2_version_atleast(version, 2, 2);
 #endif
+#ifdef SDL_VIDEO_DRIVER_X11_XINPUT2_SUPPORTS_GESTURE // Gesture needs XInput 2.4
+    xinput2_gesture_supported = xinput2_version_atleast(version, 2, 4);
+#endif
 
     // Populate the atoms for finding relative axes
     xinput2_rel_x_atom = X11_XInternAtom(data->display, "Rel X", False);
@@ -735,6 +743,28 @@ void X11_HandleXinput2Event(SDL_VideoDevice *_this, XGenericEventCookie *cookie)
         SDL_SendTouchMotion(0, xev->sourceid, xev->detail, window, x, y, 1.0);
     } break;
 #endif // SDL_VIDEO_DRIVER_X11_XINPUT2_SUPPORTS_MULTITOUCH
+
+#ifdef SDL_VIDEO_DRIVER_X11_XINPUT2_SUPPORTS_GESTURE
+    case XI_GesturePinchBegin:
+    case XI_GesturePinchUpdate:
+    case XI_GesturePinchEnd:
+    {
+        const XIGesturePinchEvent *xev = (const XIGesturePinchEvent *)cookie->data;
+        float x, y;
+        SDL_Window *window = xinput2_get_sdlwindow(videodata, xev->event);
+        xinput2_normalize_touch_coordinates(window, xev->event_x, xev->event_y, &x, &y);
+
+        if (cookie->evtype == XI_GesturePinchBegin) {
+            SDL_SendPinch(SDL_EVENT_PINCH_BEGIN, 0, window, 0);
+        } else if (cookie->evtype == XI_GesturePinchUpdate) {
+            SDL_SendPinch(SDL_EVENT_PINCH_UPDATE, 0, window, (float)xev->scale);
+        } else {
+            SDL_SendPinch(SDL_EVENT_PINCH_END, 0, window, 0);
+        }
+    } break;
+
+#endif // SDL_VIDEO_DRIVER_X11_XINPUT2_SUPPORTS_GESTURE
+
     }
 #endif // SDL_VIDEO_DRIVER_X11_XINPUT2
 }
@@ -774,6 +804,14 @@ void X11_Xinput2Select(SDL_VideoDevice *_this, SDL_Window *window)
         XISetMask(mask, XI_Motion);
     }
 
+    if (X11_Xinput2IsGestureSupported()) {
+#ifdef SDL_VIDEO_DRIVER_X11_XINPUT2_SUPPORTS_GESTURE
+        XISetMask(mask, XI_GesturePinchBegin);
+        XISetMask(mask, XI_GesturePinchUpdate);
+        XISetMask(mask, XI_GesturePinchEnd);
+#endif
+    }
+
     X11_XISelectEvents(data->display, window_data->xwindow, &eventmask, 1);
 #endif
 }
@@ -836,6 +874,15 @@ bool X11_Xinput2SelectMouseAndKeyboard(SDL_VideoDevice *_this, SDL_Window *windo
     return false;
 }
 
+int X11_Xinput2IsGestureSupported(void)
+{
+#ifdef SDL_VIDEO_DRIVER_X11_XINPUT2_SUPPORTS_GESTURE
+    return xinput2_initialized && xinput2_gesture_supported;
+#else
+    return 0;
+#endif
+}
+
 void X11_Xinput2GrabTouch(SDL_VideoDevice *_this, SDL_Window *window)
 {
 #ifdef SDL_VIDEO_DRIVER_X11_XINPUT2_SUPPORTS_MULTITOUCH
diff --git a/test/testgeometry.c b/test/testgeometry.c
index 1c33fbdb41411..a3befff652fa6 100644
--- a/test/testgeometry.c
+++ b/test/testgeometry.c
@@ -30,6 +30,7 @@ static SDL_BlendMode blendMode = SDL_BLENDMODE_NONE;
 static float angle = 0.0f;
 static int translate_cx = 0;
 static int translate_cy = 0;
+static float pinch_scale = 1.0f;
 
 static int done;
 
@@ -103,11 +104,14 @@ static void loop(void)
             } else if (event.key.key == SDLK_DOWN) {
                 translate_cy += 1;
             } else {
-                SDLTest_CommonEvent(state, &event, &done);
+
             }
-        } else {
-            SDLTest_CommonEvent(state, &event, &done);
+        } else if (event.type == SDL_EVENT_PINCH_BEGIN) {
+        } else if (event.type == SDL_EVENT_PINCH_UPDATE) {
+            pinch_scale *= event.pinch.scale;
+        } else if (event.type == SDL_EVENT_PINCH_END) {
         }
+        SDLTest_CommonEvent(state, &event, &done);
     }
 
     for (i = 0; i < state->num_windows; ++i) {
@@ -136,24 +140,24 @@ static void loop(void)
             cy += translate_cy;
 
             a = (angle * SDL_PI_F) / 180.0f;
-            verts[0].position.x = cx + d * SDL_cosf(a);
-            verts[0].position.y = cy + d * SDL_sinf(a);
+            verts[0].position.x = cx + (d * SDL_cosf(a)) * pinch_scale;
+            verts[0].position.y = cy + (d * SDL_sinf(a)) * pinch_scale;
             verts[0].color.r = 1.0f;
             verts[0].color.g = 0;
             verts[0].color.b = 0;
             verts[0].color.a = 1.0f;
 
             a = ((angle + 120) * SDL_PI_F) / 180.0f;
-            verts[1].position.x = cx + d * SDL_cosf(a);
-            verts[1].position.y = cy + d * SDL_sinf(a);
+            verts[1].position.x = cx + (d * SDL_cosf(a)) * pinch_scale;
+            verts[1].position.y = cy + (d * SDL_sinf(a)) * pinch_scale;
             verts[1].color.r = 0;
         

(Patch may be truncated, please check the link at the top of this post.)