SDL: uikit: reworked Apple Pencil code.

From 0ad3a1893790115e926d38a7add534d3712342b1 Mon Sep 17 00:00:00 2001
From: "Ryan C. Gordon" <[EMAIL REDACTED]>
Date: Sat, 28 Dec 2024 14:57:14 -0500
Subject: [PATCH] uikit: reworked Apple Pencil code.

This manages axes correctly across events, sorts out the math to convert from
Apple's data to what SDL expects, and a few other tweaks and corrections.
---
 src/video/uikit/SDL_uikitpen.h  |   8 +-
 src/video/uikit/SDL_uikitpen.m  | 164 +++++++++++++++++++++++++-------
 src/video/uikit/SDL_uikitview.m |   8 +-
 3 files changed, 137 insertions(+), 43 deletions(-)

diff --git a/src/video/uikit/SDL_uikitpen.h b/src/video/uikit/SDL_uikitpen.h
index c63df2c2857b6..ffcfd0f57eab1 100644
--- a/src/video/uikit/SDL_uikitpen.h
+++ b/src/video/uikit/SDL_uikitpen.h
@@ -26,12 +26,14 @@
 #include "SDL_uikitwindow.h"
 
 extern bool UIKit_InitPen(SDL_VideoDevice *_this);
-extern void UIKit_HandlePenEnter();
-extern void UIKit_HandlePenLeave();
-extern void UIKit_HandlePenHover(SDL_uikitview *view, CGPoint point);
 extern void UIKit_HandlePenMotion(SDL_uikitview *view, UITouch *pencil);
 extern void UIKit_HandlePenPress(SDL_uikitview *view, UITouch *pencil);
 extern void UIKit_HandlePenRelease(SDL_uikitview *view, UITouch *pencil);
+
+#if !defined(SDL_PLATFORM_TVOS) && defined(__IPHONE_13_0)
+extern void UIKit_HandlePenHover(SDL_uikitview *view, UIHoverGestureRecognizer *recognizer) API_AVAILABLE(ios(13.0));
+#endif
+
 extern void UIKit_QuitPen(SDL_VideoDevice *_this);
 
 #endif // SDL_uikitpen_h_
diff --git a/src/video/uikit/SDL_uikitpen.m b/src/video/uikit/SDL_uikitpen.m
index bfa62ebc00679..6a26e46cc80e4 100644
--- a/src/video/uikit/SDL_uikitpen.m
+++ b/src/video/uikit/SDL_uikitpen.m
@@ -28,66 +28,162 @@
 
 #include "../../events/SDL_pen_c.h"
 
-SDL_PenID penId;
-
-typedef struct UIKit_PenHandle
-{
-    SDL_PenID pen;
-} UIKit_PenHandle;
+static SDL_PenID apple_pencil_id = 0;
 
 bool UIKit_InitPen(SDL_VideoDevice *_this)
 {
     return true;
 }
 
-void UIKit_HandlePenEnter()
+// we only have one Apple Pencil at a time, and it must be paired to the iOS device.
+// We only know about its existence when it first sends an event, so add an single SDL pen
+// device here if we haven't already.
+static SDL_PenID UIKit_AddPenIfNecesary()
 {
-    SDL_PenInfo penInfo;
-    SDL_zero(penInfo);
-    penInfo.capabilities = SDL_PEN_CAPABILITY_PRESSURE | SDL_PEN_CAPABILITY_ROTATION | SDL_PEN_CAPABILITY_XTILT | SDL_PEN_CAPABILITY_YTILT | SDL_PEN_CAPABILITY_TANGENTIAL_PRESSURE;
-    penInfo.max_tilt = 90.0f;
-    penInfo.num_buttons = 0;
-    penInfo.subtype = SDL_PEN_TYPE_PENCIL;
-
-    // probably make this better
-    penId = SDL_AddPenDevice(0, [@"Apple Pencil" UTF8String], &penInfo, calloc(1, sizeof(UIKit_PenHandle)));
+    if (!apple_pencil_id) {
+        SDL_PenInfo info;
+        SDL_zero(info);
+        info.capabilities = SDL_PEN_CAPABILITY_PRESSURE | SDL_PEN_CAPABILITY_XTILT | SDL_PEN_CAPABILITY_YTILT;
+        info.max_tilt = 90.0f;
+        info.num_buttons = 0;
+        info.subtype = SDL_PEN_TYPE_PENCIL;
+
+        if (@available(iOS 17.5, *)) {  // need rollAngle method.
+            info.capabilities |= SDL_PEN_CAPABILITY_ROTATION;
+        }
+
+        if (@available(ios 16.1, *)) {  // need zOffset method.
+            info.capabilities |= SDL_PEN_CAPABILITY_DISTANCE;
+        }
+
+        // Apple Pencil and iOS can report when the pencil is being "squeezed" but it's a boolean thing,
+        // so we can't use it for tangential pressure.
+
+        // There's only ever one Apple Pencil at most, so we just pass a non-zero value for the handle.
+        apple_pencil_id = SDL_AddPenDevice(0, "Apple Pencil", &info, (void *) (size_t) 0x1);
+    }
+
+    return apple_pencil_id;
 }
 
-void UIKit_HandlePenHover(SDL_uikitview *view, CGPoint point)
+static void UIKit_HandlePenAxes(SDL_Window *window, NSTimeInterval nstimestamp, float zOffset, const CGPoint *point, float force,
+                                float maximumPossibleForce, float azimuthAngleInView, float altitudeAngle, float rollAngle)
 {
-    SDL_SendPenMotion(0, penId, [view getSDLWindow], point.x, point.y);
+    const SDL_PenID penId = UIKit_AddPenIfNecesary();
+    if (penId) {
+        const Uint64 timestamp = UIKit_GetEventTimestamp(nstimestamp);
+        const float radians_to_degrees = 180.0f / SDL_PI_F;
+
+        // Normalize force to 0.0f ... 1.0f range.
+        const float pressure = force / maximumPossibleForce;
+
+        // azimuthAngleInView is in radians, with 0 being the pen's back end pointing due east on the screen when the
+        // tip is touching the screen, and negative when heading north from there, positive to the south.
+        // So convert to degrees, 0 being due east, etc.
+        const float azimuth_angle = azimuthAngleInView * radians_to_degrees;
+
+        // altitudeAngle is in radians, with 0 being the pen laying flat on (parallel to) the device
+        //  screen and PI/2 being it pointing straight up from (perpendicular to) the device screen.
+        // So convert to degrees, 0 being flat and 90 being straight up.
+        const float altitude_angle = altitudeAngle * radians_to_degrees;
+
+        // the azimuth_angle goes from -180 to 180 (with abs(angle) moving from 180 to 0, left to right), but SDL wants
+        // it from -90 (back facing west) to 90 (back facing east).
+        const float xtilt = (180.0f - SDL_fabsf(azimuth_angle)) - 90.0f;
+
+        // the altitude_angle goes from 0 to 90 regardless of which direction the pen is lifting off the device, but SDL wants
+        // it from -90 (flat facing north) to 90 (flat facing south).
+        const float ytilt = (azimuth_angle < 0.0f) ? -(90.0f - altitude_angle) : (90.0f - altitude_angle);
+
+        // rotation is in radians, and only available on a later iOS.
+        const float rotation = rollAngle * radians_to_degrees;  // !!! FIXME: this might need adjustment, I don't have a pencil that supports it.
+
+        SDL_SendPenMotion(timestamp, penId, window, point->x, point->y);
+        SDL_SendPenAxis(timestamp, penId, window, SDL_PEN_AXIS_PRESSURE, pressure);
+        SDL_SendPenAxis(timestamp, penId, window, SDL_PEN_AXIS_XTILT, xtilt);
+        SDL_SendPenAxis(timestamp, penId, window, SDL_PEN_AXIS_YTILT, ytilt);
+        SDL_SendPenAxis(timestamp, penId, window, SDL_PEN_AXIS_ROTATION, rotation);
+        SDL_SendPenAxis(timestamp, penId, window, SDL_PEN_AXIS_DISTANCE, zOffset);
+    }
 }
 
-void UIKit_HandlePenMotion(SDL_uikitview *view, UITouch *pencil)
+#if !defined(SDL_PLATFORM_TVOS) && defined(__IPHONE_13_0)
+extern void UIKit_HandlePenHover(SDL_uikitview *view, UIHoverGestureRecognizer *recognizer)
 {
-    CGPoint point = [pencil locationInView:view];
-    SDL_SendPenMotion(UIKit_GetEventTimestamp([pencil timestamp]), penId, [view getSDLWindow], point.x, point.y);
-    SDL_SendPenAxis(UIKit_GetEventTimestamp([pencil timestamp]), penId, [view getSDLWindow], SDL_PEN_AXIS_PRESSURE, [pencil force] / [pencil maximumPossibleForce]);
-    NSLog(@"ALTITUDE: %f", [pencil altitudeAngle]);
-    NSLog(@"AZIMUTH VECTOR: %@", [NSValue valueWithCGVector: [pencil azimuthUnitVectorInView:view]]);
-    NSLog(@"AZIMUTH ANGLE: %f", [pencil azimuthAngleInView:view]);
-    // hold it
-    //    SDL_SendPenAxis(0, penId, [view getSDLWindow], SDL_PEN_AXIS_XTILT, [pencil altitudeAngle] / M_PI);
+    float zOffset = 0.0f;
+    if (@available(iOS 16.1, *)) {
+        zOffset = (float) [recognizer zOffset];
+    }
+
+    float azimuthAngleInView = 0.0f;
+    if (@available(iOS 16.4, *)) {
+        azimuthAngleInView = (float) [recognizer azimuthAngleInView:view];
+    }
+
+    float altitudeAngle = 0.0f;
+    if (@available(iOS 16.4, *)) {
+        altitudeAngle = (float) [recognizer altitudeAngle];
+    }
+
+    float rollAngle = 0.0f;
+    if (@available(iOS 17.5, *)) {
+        rollAngle = (float) [recognizer rollAngle];
+    }
+
+    SDL_Window *window = [view getSDLWindow];
+    const CGPoint point = [recognizer locationInView:view];
+
+    // force is zero here; if you're here, you're not touching.
+    // !!! FIXME: no timestamp on these...?
+    UIKit_HandlePenAxes(window, 0, zOffset, &point, 0.0f, 1.0f, azimuthAngleInView, altitudeAngle, rollAngle);
 }
+#endif
 
-void UIKit_HandlePenPress(SDL_uikitview *view, UITouch *pencil)
+static void UIKit_HandlePenAxesFromUITouch(SDL_uikitview *view, UITouch *pencil)
 {
-    SDL_SendPenTouch(UIKit_GetEventTimestamp([pencil timestamp]), penId, [view getSDLWindow], false, true);
+    float rollAngle = 0.0f;
+    #if !defined(SDL_PLATFORM_TVOS)
+    if (@available(iOS 17.5, *)) {
+        rollAngle = (float) [pencil rollAngle];
+    }
+    #endif
+
+    SDL_Window *window = [view getSDLWindow];
+    const CGPoint point = [pencil locationInView:view];
+
+    // zOffset is zero here; if you're here, you're touching.
+    UIKit_HandlePenAxes(window, [pencil timestamp], 0.0f, &point, [pencil force], [pencil maximumPossibleForce], [pencil azimuthAngleInView:view], [pencil altitudeAngle], rollAngle);
 }
 
-void UIKit_HandlePenRelease(SDL_uikitview *view, UITouch *pencil)
+void UIKit_HandlePenMotion(SDL_uikitview *view, UITouch *pencil)
 {
-    SDL_SendPenTouch(UIKit_GetEventTimestamp([pencil timestamp]), penId, [view getSDLWindow], false, false);
+    UIKit_HandlePenAxesFromUITouch(view, pencil);
 }
 
-void UIKit_HandlePenLeave()
+void UIKit_HandlePenPress(SDL_uikitview *view, UITouch *pencil)
+{
+    const SDL_PenID penId = UIKit_AddPenIfNecesary();
+    if (penId) {
+        UIKit_HandlePenAxesFromUITouch(view, pencil);
+        SDL_SendPenTouch(UIKit_GetEventTimestamp([pencil timestamp]), penId, [view getSDLWindow], false, true);
+    }
+}
+
+void UIKit_HandlePenRelease(SDL_uikitview *view, UITouch *pencil)
 {
-    SDL_RemovePenDevice(0, penId);
-    penId = 0;
+    const SDL_PenID penId = UIKit_AddPenIfNecesary();
+    if (penId) {
+        SDL_SendPenTouch(UIKit_GetEventTimestamp([pencil timestamp]), penId, [view getSDLWindow], false, false);
+        UIKit_HandlePenAxesFromUITouch(view, pencil);
+    }
 }
 
 void UIKit_QuitPen(SDL_VideoDevice *_this)
 {
+    if (apple_pencil_id) {
+        SDL_RemovePenDevice(0, apple_pencil_id);
+        apple_pencil_id = 0;
+    }
 }
 
 #endif // SDL_VIDEO_DRIVER_UIKIT
diff --git a/src/video/uikit/SDL_uikitview.m b/src/video/uikit/SDL_uikitview.m
index b5629517ea711..82d66adef2346 100644
--- a/src/video/uikit/SDL_uikitview.m
+++ b/src/video/uikit/SDL_uikitview.m
@@ -261,17 +261,13 @@ - (void)pencilHovering:(UIHoverGestureRecognizer *)recognizer API_AVAILABLE(ios(
 {
     switch (recognizer.state) {
         case UIGestureRecognizerStateBegan:
-            UIKit_HandlePenEnter();
-            UIKit_HandlePenHover(self, [recognizer locationInView:self]);
-            break;
-
         case UIGestureRecognizerStateChanged:
-            UIKit_HandlePenHover(self, [recognizer locationInView:self]);
+            UIKit_HandlePenHover(self, recognizer);
             break;
 
         case UIGestureRecognizerStateEnded:
         case UIGestureRecognizerStateCancelled:
-            UIKit_HandlePenLeave();
+            // we track touches elsewhere, so if a hover "ends" we'll deal with that there.
             break;
 
         default: