SDL: Guarantee that pens are in proximity before motion and button events (59641)

From 5964104910ac0a820688c383f17e08d6e4930535 Mon Sep 17 00:00:00 2001
From: Sam Lantinga <[EMAIL REDACTED]>
Date: Thu, 5 Feb 2026 10:21:08 -0800
Subject: [PATCH] Guarantee that pens are in proximity before motion and button
 events

This also delays pen proximity out events to make sure that the pen is really gone before delivering them. On Android, you get a HOVER_EXIT event when the pen contacts the surface, which we don't want to treat as the pen leaving proximity.

(cherry picked from commit bddf6d3e2aa76e71335ea2eb62a468b84975d91b)
---
 src/events/SDL_events.c                     |  2 +
 src/events/SDL_pen.c                        | 73 +++++++++++++++++----
 src/events/SDL_pen_c.h                      |  5 +-
 src/video/android/SDL_androidpen.c          |  4 +-
 src/video/cocoa/SDL_cocoapen.m              |  4 +-
 src/video/emscripten/SDL_emscriptenevents.c |  4 +-
 src/video/wayland/SDL_waylandevents.c       |  4 +-
 src/video/windows/SDL_windowsevents.c       |  4 +-
 src/video/x11/SDL_x11pen.c                  |  2 +-
 9 files changed, 77 insertions(+), 25 deletions(-)

diff --git a/src/events/SDL_events.c b/src/events/SDL_events.c
index 4d6fa0e170ce5..a8ac576bc449d 100644
--- a/src/events/SDL_events.c
+++ b/src/events/SDL_events.c
@@ -1484,6 +1484,8 @@ void SDL_PumpEventMaintenance(void)
     }
 #endif
 
+    SDL_SendPendingPenProximity();
+
     SDL_UpdateCursorAnimation();
 
     SDL_UpdateTrays();
diff --git a/src/events/SDL_pen.c b/src/events/SDL_pen.c
index 79bf93b19df3f..61d41e1fea49b 100644
--- a/src/events/SDL_pen.c
+++ b/src/events/SDL_pen.c
@@ -37,6 +37,8 @@ typedef struct SDL_Pen
     float x;
     float y;
     SDL_PenInputFlags input_state;
+    bool pending_proximity_out;
+    SDL_WindowID pending_proximity_window_id;
     void *driverdata;
 } SDL_Pen;
 
@@ -45,6 +47,7 @@ typedef struct SDL_Pen
 static SDL_RWLock *pen_device_rwlock = NULL;
 static SDL_Pen *pen_devices SDL_GUARDED_BY(pen_device_rwlock) = NULL;
 static int pen_device_count SDL_GUARDED_BY(pen_device_rwlock) = 0;
+static SDL_AtomicInt pending_proximity_out;
 
 // You must hold pen_device_rwlock before calling this, and result is only safe while lock is held!
 // If SDL isn't initialized, grabbing the NULL lock is a no-op and there will be zero devices, so
@@ -248,8 +251,8 @@ SDL_PenID SDL_AddPenDevice(Uint64 timestamp, const char *name, SDL_Window *windo
         SDL_free(namecpy);
     }
 
-    if (result) {
-        SDL_SendPenProximity(timestamp, result, window, in_proximity);
+    if (result && in_proximity) {
+        SDL_SendPenProximity(timestamp, result, window, true, true);
     }
 
     return result;
@@ -261,7 +264,7 @@ void SDL_RemovePenDevice(Uint64 timestamp, SDL_Window *window, SDL_PenID instanc
         return;
     }
 
-    SDL_SendPenProximity(timestamp, instance_id, window, false);  // bye bye
+    SDL_SendPenProximity(timestamp, instance_id, window, false, true);  // bye bye
 
     SDL_LockRWLockForWriting(pen_device_rwlock);
     SDL_Pen *pen = FindPenByInstanceId(instance_id);
@@ -453,6 +456,15 @@ void SDL_SendPenAxis(Uint64 timestamp, SDL_PenID instance_id, SDL_Window *window
     }
 }
 
+static void EnsurePenProximity(Uint64 timestamp, SDL_Pen *pen, SDL_Window *window)
+{
+    if (pen->pending_proximity_out) {
+        pen->pending_proximity_out = false;
+    } else if (!(pen->input_state & SDL_PEN_INPUT_IN_PROXIMITY)) {
+        SDL_SendPenProximity(timestamp, pen->instance_id, window, true, true);
+    }
+}
+
 void SDL_SendPenMotion(Uint64 timestamp, SDL_PenID instance_id, SDL_Window *window, float x, float y)
 {
     bool send_event = false;
@@ -465,6 +477,8 @@ void SDL_SendPenMotion(Uint64 timestamp, SDL_PenID instance_id, SDL_Window *wind
     SDL_LockRWLockForReading(pen_device_rwlock);
     SDL_Pen *pen = FindPenByInstanceId(instance_id);
     if (pen) {
+        EnsurePenProximity(timestamp, pen, window);
+
         if ((pen->x != x) || (pen->y != y)) {
             pen->x = x;  // we could do an SDL_SetAtomicInt here if we run into trouble...
             pen->y = y;  // we could do an SDL_SetAtomicInt here if we run into trouble...
@@ -528,6 +542,8 @@ void SDL_SendPenButton(Uint64 timestamp, SDL_PenID instance_id, SDL_Window *wind
     SDL_LockRWLockForReading(pen_device_rwlock);
     SDL_Pen *pen = FindPenByInstanceId(instance_id);
     if (pen) {
+        EnsurePenProximity(timestamp, pen, window);
+
         input_state = pen->input_state;
         const Uint32 flag = (Uint32) (1u << button);
         const bool current = ((input_state & flag) != 0);
@@ -579,7 +595,7 @@ void SDL_SendPenButton(Uint64 timestamp, SDL_PenID instance_id, SDL_Window *wind
     }
 }
 
-void SDL_SendPenProximity(Uint64 timestamp, SDL_PenID instance_id, SDL_Window *window, bool in)
+void SDL_SendPenProximity(Uint64 timestamp, SDL_PenID instance_id, SDL_Window *window, bool in, bool immediate)
 {
     bool send_event = false;
     SDL_PenInputFlags input_state = 0;
@@ -591,16 +607,23 @@ void SDL_SendPenProximity(Uint64 timestamp, SDL_PenID instance_id, SDL_Window *w
     SDL_LockRWLockForReading(pen_device_rwlock);
     SDL_Pen *pen = FindPenByInstanceId(instance_id);
     if (pen) {
-        input_state = pen->input_state;
-        const bool in_proximity = ((input_state & SDL_PEN_INPUT_IN_PROXIMITY) != 0);
-        if (in_proximity != in) {
-            if (in) {
-                input_state |= SDL_PEN_INPUT_IN_PROXIMITY;
-            } else {
-                input_state &= ~SDL_PEN_INPUT_IN_PROXIMITY;
+        if (in || immediate) {
+            input_state = pen->input_state;
+            const bool in_proximity = ((input_state & SDL_PEN_INPUT_IN_PROXIMITY) != 0);
+            if (in_proximity != in) {
+                if (in) {
+                    input_state |= SDL_PEN_INPUT_IN_PROXIMITY;
+                } else {
+                    input_state &= ~SDL_PEN_INPUT_IN_PROXIMITY;
+                }
+                send_event = true;
+                pen->input_state = input_state;  // we could do an SDL_SetAtomicInt here if we run into trouble...
             }
-            send_event = true;
-            pen->input_state = input_state;  // we could do an SDL_SetAtomicInt here if we run into trouble...
+            pen->pending_proximity_out = false;
+        } else {
+            pen->pending_proximity_out = true;
+            pen->pending_proximity_window_id = (window ? window->id : 0);
+            SDL_SetAtomicInt(&pending_proximity_out, true);
         }
     }
     SDL_UnlockRWLock(pen_device_rwlock);
@@ -617,3 +640,27 @@ void SDL_SendPenProximity(Uint64 timestamp, SDL_PenID instance_id, SDL_Window *w
     }
 }
 
+void SDL_SendPendingPenProximity(void)
+{
+    if (SDL_CompareAndSwapAtomicInt(&pending_proximity_out, true, false)) {
+        SDL_LockRWLockForReading(pen_device_rwlock);
+        for (int i = 0; i < pen_device_count; i++) {
+            SDL_Pen *pen = &pen_devices[i];
+            if (pen->pending_proximity_out) {
+                pen->pending_proximity_out = false;
+
+                SDL_Window *window = NULL;
+                if (pen->pending_proximity_window_id) {
+                    window = SDL_GetWindowFromID(pen->pending_proximity_window_id);
+                    if (!window) {
+                        // The window is already gone, ignore this event
+                        continue;
+                    }
+                }
+                SDL_SendPenProximity(0, pen->instance_id, window, false, true);
+            }
+        }
+        SDL_UnlockRWLock(pen_device_rwlock);
+    }
+}
+
diff --git a/src/events/SDL_pen_c.h b/src/events/SDL_pen_c.h
index 0d4a6a0939b45..0aca9a6db9ac1 100644
--- a/src/events/SDL_pen_c.h
+++ b/src/events/SDL_pen_c.h
@@ -82,7 +82,10 @@ extern void SDL_SendPenAxis(Uint64 timestamp, SDL_PenID instance_id, SDL_Window
 extern void SDL_SendPenButton(Uint64 timestamp, SDL_PenID instance_id, SDL_Window *window, Uint8 button, bool down);
 
 // Backend calls this when a pen's proximity changes, to generate events and update state.
-extern void SDL_SendPenProximity(Uint64 timestamp, SDL_PenID instance_id, SDL_Window *window, bool in);
+extern void SDL_SendPenProximity(Uint64 timestamp, SDL_PenID instance_id, SDL_Window *window, bool in, bool immediate);
+
+// Pumping events calls this to generate pending pen proximity events
+extern void SDL_SendPendingPenProximity(void);
 
 // Backend can optionally use this to find the SDL_PenID for the `handle` that was passed to SDL_AddPenDevice.
 extern SDL_PenID SDL_FindPenByHandle(void *handle);
diff --git a/src/video/android/SDL_androidpen.c b/src/video/android/SDL_androidpen.c
index e47d1d6ab2b9c..02127bafb6592 100644
--- a/src/video/android/SDL_androidpen.c
+++ b/src/video/android/SDL_androidpen.c
@@ -78,12 +78,12 @@ void Android_OnPen(SDL_Window *window, int pen_id_in, SDL_PenDeviceType device_t
     // we don't compare tip flags above because MotionEvent.getButtonState doesn't return stylus tip/eraser state.
     switch (action) {
     case ACTION_HOVER_ENTER:
-        SDL_SendPenProximity(0, pen, window, true);
+        SDL_SendPenProximity(0, pen, window, true, true);
         break;
 
     case ACTION_CANCEL:
     case ACTION_HOVER_EXIT:  // strictly speaking, this can mean both "proximity out" and "left the View" but close enough.
-        SDL_SendPenProximity(0, pen, window, false);
+        SDL_SendPenProximity(0, pen, window, false, false);
         break;
 
     case ACTION_DOWN:
diff --git a/src/video/cocoa/SDL_cocoapen.m b/src/video/cocoa/SDL_cocoapen.m
index cb3fc861b67cf..91429ae7e6430 100644
--- a/src/video/cocoa/SDL_cocoapen.m
+++ b/src/video/cocoa/SDL_cocoapen.m
@@ -87,7 +87,7 @@ static void Cocoa_HandlePenProximityEvent(SDL_CocoaWindowData *_data, NSEvent *e
         Cocoa_PenHandle *handle = Cocoa_FindPenByDeviceID(devid, toolid);
         if (handle) {
             handle->is_eraser = is_eraser;  // in case this changed.
-            SDL_SendPenProximity(Cocoa_GetEventTimestamp([event timestamp]), handle->pen, _data.window, true);
+            SDL_SendPenProximity(Cocoa_GetEventTimestamp([event timestamp]), handle->pen, _data.window, true, true);
             return;  // already have this one.
         }
 
@@ -116,7 +116,7 @@ static void Cocoa_HandlePenProximityEvent(SDL_CocoaWindowData *_data, NSEvent *e
         if (handle) {
             // We never remove pens (until shutdown), since Apple gives no indication when they are actually gone.
             // But unless you are plugging and unplugging a tablet millions of times, generating new device IDs, this shouldn't be a massive memory drain.
-            SDL_SendPenProximity(Cocoa_GetEventTimestamp([event timestamp]), handle->pen, _data.window, false);
+            SDL_SendPenProximity(Cocoa_GetEventTimestamp([event timestamp]), handle->pen, _data.window, false, false);
         }
     }
 }
diff --git a/src/video/emscripten/SDL_emscriptenevents.c b/src/video/emscripten/SDL_emscriptenevents.c
index 3f71edee1baa3..41a172b33a432 100644
--- a/src/video/emscripten/SDL_emscriptenevents.c
+++ b/src/video/emscripten/SDL_emscriptenevents.c
@@ -868,7 +868,7 @@ static void Emscripten_HandlePenEnter(SDL_WindowData *window_data, const Emscrip
 
     SDL_PenID pen = SDL_FindPenByHandle((void *) (size_t) 1);  // something > 0 for the single pen handle.
     if (pen) {
-        SDL_SendPenProximity(0, pen, window_data->window, true);
+        SDL_SendPenProximity(0, pen, window_data->window, true, true);
     } else {
         // Web browsers offer almost none of this information as specifics, but can without warning offer any of these specific things.
         SDL_PenInfo peninfo;
@@ -902,7 +902,7 @@ static void Emscripten_HandlePenLeave(SDL_WindowData *window_data, const Emscrip
     const SDL_PenID pen = SDL_FindPenByHandle((void *) (size_t) 1);   // something > 0 for the single pen handle.
     if (pen) {
         Emscripten_UpdatePointerFromEvent(window_data, event);  // last data updates?
-        SDL_SendPenProximity(0, pen, window_data->window, false);
+        SDL_SendPenProximity(0, pen, window_data->window, false, false);
     }
 }
 
diff --git a/src/video/wayland/SDL_waylandevents.c b/src/video/wayland/SDL_waylandevents.c
index e213f57d0da1c..4ac6d063e495c 100644
--- a/src/video/wayland/SDL_waylandevents.c
+++ b/src/video/wayland/SDL_waylandevents.c
@@ -3432,7 +3432,7 @@ static void tablet_tool_handle_frame(void *data, struct zwp_tablet_tool_v2 *tool
     SDL_Window *window = sdltool->focus ? sdltool->focus->sdlwindow : NULL;
 
     if (sdltool->frame.have_proximity && sdltool->frame.in_proximity) {
-        SDL_SendPenProximity(timestamp, instance_id, window, true);
+        SDL_SendPenProximity(timestamp, instance_id, window, true, true);
         Wayland_TabletToolUpdateCursor(sdltool);
     }
 
@@ -3471,7 +3471,7 @@ static void tablet_tool_handle_frame(void *data, struct zwp_tablet_tool_v2 *tool
     }
 
     if (sdltool->frame.have_proximity && !sdltool->frame.in_proximity) {
-        SDL_SendPenProximity(timestamp, instance_id, window, false);
+        SDL_SendPenProximity(timestamp, instance_id, window, false, false);
         sdltool->focus = NULL;
         Wayland_TabletToolUpdateCursor(sdltool);
     }
diff --git a/src/video/windows/SDL_windowsevents.c b/src/video/windows/SDL_windowsevents.c
index 4f16b2de7d9a8..5be5efbc189f3 100644
--- a/src/video/windows/SDL_windowsevents.c
+++ b/src/video/windows/SDL_windowsevents.c
@@ -1278,7 +1278,7 @@ LRESULT CALLBACK WIN_WindowProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lPara
         void *hpointer = (void *)(size_t)1; // just something > 0. We're using this one ID any possible pen.
         const SDL_PenID pen = SDL_FindPenByHandle(hpointer);
         if (pen) {
-            SDL_SendPenProximity(WIN_GetEventTimestamp(), pen, data->window, true);
+            SDL_SendPenProximity(WIN_GetEventTimestamp(), pen, data->window, true, true);
         } else {
             // one can use GetPointerPenInfo() to get the current state of the pen, and check POINTER_PEN_INFO::penMask,
             //  but the docs aren't clear if these masks are _always_ set for pens with specific features, or if they
@@ -1319,7 +1319,7 @@ LRESULT CALLBACK WIN_WindowProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lPara
         // if this just left the _window_, we don't care. If this is no longer visible to the tablet, time to remove it!
         if ((msg == WM_POINTERCAPTURECHANGED) || !IS_POINTER_INCONTACT_WPARAM(wParam)) {
             // technically this isn't just _proximity_ but maybe just leaving the window. Good enough. WinTab apparently has real proximity info.
-            SDL_SendPenProximity(WIN_GetEventTimestamp(), pen, data->window, false);
+            SDL_SendPenProximity(WIN_GetEventTimestamp(), pen, data->window, false, false);
         }
         returnCode = 0;
     } break;
diff --git a/src/video/x11/SDL_x11pen.c b/src/video/x11/SDL_x11pen.c
index 3294f22c6a2f6..76a542f2cfde0 100644
--- a/src/video/x11/SDL_x11pen.c
+++ b/src/video/x11/SDL_x11pen.c
@@ -328,7 +328,7 @@ void X11_NotifyPenProximityChange(SDL_VideoDevice *_this, SDL_Window *window, in
     bool in_proximity;
     X11_PenHandle *pen = X11_FindPenByDeviceID(deviceid);
     if (pen && X11_XInput2PenIsInProximity(_this, deviceid, &in_proximity)) {
-        SDL_SendPenProximity(0, pen->pen, window, in_proximity);
+        SDL_SendPenProximity(0, pen->pen, window, in_proximity, in_proximity);
     }
 }