SDL: video: Windows keep any position set when in fullscreen after leaving fullscreen

From f31ca02723f57f84dc03fd19b2f5a3e4b8a15bda Mon Sep 17 00:00:00 2001
From: Frank Praznik <[EMAIL REDACTED]>
Date: Sun, 17 May 2026 10:45:32 -0400
Subject: [PATCH] video: Windows keep any position set when in fullscreen after
 leaving fullscreen

Adds an automated test for the behavior as well.
---
 src/video/SDL_video.c             |  8 +++
 src/video/cocoa/SDL_cocoawindow.m | 12 +++--
 src/video/x11/SDL_x11window.c     |  3 ++
 src/video/x11/SDL_x11window.h     |  1 +
 test/testautomation_video.c       | 90 +++++++++++++++++++++++++++++++
 5 files changed, 110 insertions(+), 4 deletions(-)

diff --git a/src/video/SDL_video.c b/src/video/SDL_video.c
index 318f05b1df79f..36f3dc89f5f2c 100644
--- a/src/video/SDL_video.c
+++ b/src/video/SDL_video.c
@@ -3051,6 +3051,14 @@ bool SDL_SetWindowPosition(SDL_Window *window, int x, int y)
 
     window->pending.x = x;
     window->pending.y = y;
+
+    /* Windows are placed at the coordinates received while in fullscreen after leaving fullscreen.
+     * Asynchronous backends need special handling in this case.
+     */
+    if (!_this->SyncWindow && (window->flags & SDL_WINDOW_FULLSCREEN)) {
+        window->floating.x = window->windowed.x = x;
+        window->floating.y = window->windowed.y = y;
+    }
     window->undefined_x = false;
     window->undefined_y = false;
     window->last_position_pending = true;
diff --git a/src/video/cocoa/SDL_cocoawindow.m b/src/video/cocoa/SDL_cocoawindow.m
index 79c17713eb7a9..5b8ffcdc96cf0 100644
--- a/src/video/cocoa/SDL_cocoawindow.m
+++ b/src/video/cocoa/SDL_cocoawindow.m
@@ -1461,7 +1461,6 @@ - (void)windowDidEnterFullScreen:(NSNotification *)aNotification
         }
         SDL_SendWindowEvent(window, SDL_EVENT_WINDOW_ENTER_FULLSCREEN, 0, 0);
 
-        _data.pending_position = NO;
         _data.pending_size = NO;
 
         /* Force the size change event in case it was delivered earlier
@@ -2605,7 +2604,7 @@ bool Cocoa_SetWindowPosition(SDL_VideoDevice *_this, SDL_Window *window)
         BOOL fullscreen = (window->flags & SDL_WINDOW_FULLSCREEN) ? YES : NO;
         int x, y;
 
-        if ([windata.listener isInFullscreenSpaceTransition]) {
+        if (fullscreen || [windata.listener isInFullscreenSpaceTransition]) {
             windata.pending_position = YES;
             return true;
         }
@@ -3030,8 +3029,13 @@ SDL_FullscreenResult Cocoa_SetWindowFullscreen(SDL_VideoDevice *_this, SDL_Windo
 
             SDL_SendWindowEvent(window, SDL_EVENT_WINDOW_LEAVE_FULLSCREEN, 0, 0);
 
-            rect.origin.x = data.was_zoomed ? window->windowed.x : window->floating.x;
-            rect.origin.y = data.was_zoomed ? window->windowed.y : window->floating.y;
+            if (data.pending_position) {
+                rect.origin.x = window->pending.x;
+                rect.origin.y = window->pending.y;
+            } else {
+                rect.origin.x = data.was_zoomed ? window->windowed.x : window->floating.x;
+                rect.origin.y = data.was_zoomed ? window->windowed.y : window->floating.y;
+            }
             rect.size.width = data.was_zoomed ? window->windowed.w : window->floating.w;
             rect.size.height = data.was_zoomed ? window->windowed.h : window->floating.h;
 
diff --git a/src/video/x11/SDL_x11window.c b/src/video/x11/SDL_x11window.c
index b1f982a32e9b7..a0864d71b44e8 100644
--- a/src/video/x11/SDL_x11window.c
+++ b/src/video/x11/SDL_x11window.c
@@ -1200,6 +1200,7 @@ bool X11_SetWindowPosition(SDL_VideoDevice *_this, SDL_Window *window)
         }
         X11_UpdateWindowPosition(window, false);
     } else {
+        window->internal->fs_repositioned = true;
         SDL_UpdateFullscreenMode(window, SDL_FULLSCREEN_OP_UPDATE, true);
     }
     return true;
@@ -1921,6 +1922,8 @@ static SDL_FullscreenResult X11_SetWindowFullscreenViaWM(SDL_VideoDevice *_this,
             }
         } else {
             SDL_zero(data->requested_fullscreen_mode);
+            data->pending_position = data->fs_repositioned;
+            data->fs_repositioned = false;
 
             /* Fullscreen windows sometimes end up being marked maximized by
              * window managers. Force it back to how we expect it to be.
diff --git a/src/video/x11/SDL_x11window.h b/src/video/x11/SDL_x11window.h
index 5335ae8001c1f..1ba725b9bd385 100644
--- a/src/video/x11/SDL_x11window.h
+++ b/src/video/x11/SDL_x11window.h
@@ -111,6 +111,7 @@ struct SDL_WindowData
 
     bool pending_size;
     bool pending_position;
+    bool fs_repositioned;
     bool window_was_maximized;
     bool previous_borders_nonzero;
     bool toggle_borders;
diff --git a/test/testautomation_video.c b/test/testautomation_video.c
index ad1e3b1c8d296..8a784353cfa94 100644
--- a/test/testautomation_video.c
+++ b/test/testautomation_video.c
@@ -843,6 +843,7 @@ static int SDLCALL video_getSetWindowPosition(void *arg)
 {
     const char *title = "video_getSetWindowPosition Test Window";
     SDL_Window *window;
+    SDL_WindowFlags flags;
     int result;
     int maxxVariation, maxyVariation;
     int xVariation, yVariation;
@@ -974,6 +975,95 @@ static int SDLCALL video_getSetWindowPosition(void *arg)
         }
     }
 
+    /* Fullscreen test */
+    desiredX = 100;
+    desiredY = 100;
+    SDL_SetWindowPosition(window, desiredX, desiredY);
+    SDLTest_AssertPass("Call to SDL_SetWindowPosition(...,%d,%d)", desiredX, desiredY);
+
+    result = SDL_SyncWindow(window);
+    SDLTest_AssertPass("SDL_SyncWindow()");
+    SDLTest_AssertCheck(result == true, "Verify return value; expected: true, got: %d", result);
+
+    /* Get position */
+    currentX = desiredX + 1;
+    currentY = desiredY + 1;
+    SDL_GetWindowPosition(window, &currentX, &currentY);
+    SDLTest_AssertPass("Call to SDL_GetWindowPosition()");
+
+    if (desiredX == currentX && desiredY == currentY) {
+        SDLTest_AssertCheck(desiredX == currentX, "Verify returned X position; expected: %d, got: %d", desiredX, currentX);
+        SDLTest_AssertCheck(desiredY == currentY, "Verify returned Y position; expected: %d, got: %d", desiredY, currentY);
+    } else {
+        bool hasEvent;
+        /* SDL_SetWindowPosition() and SDL_SetWindowSize() will make requests of the window manager and set the internal position and size,
+         * and then we get events signaling what actually happened, and they get passed on to the application if they're not what we expect. */
+        currentX = desiredX + 1;
+        currentY = desiredY + 1;
+        hasEvent = getPositionFromEvent(&currentX, &currentY);
+        SDLTest_AssertCheck(hasEvent == true, "Changing position was not honored by WM, checking present of SDL_EVENT_WINDOW_MOVED");
+        if (hasEvent) {
+            SDLTest_AssertCheck(desiredX == currentX, "Verify returned X position is the position from SDL event; expected: %d, got: %d", desiredX, currentX);
+            SDLTest_AssertCheck(desiredY == currentY, "Verify returned Y position is the position from SDL event; expected: %d, got: %d", desiredY, currentY);
+        }
+    }
+
+    /* Test setting position while fullscreen */
+    result = SDL_SetWindowFullscreen(window, true);
+    SDLTest_AssertPass("SDL_SetWindowFullscreen()");
+    SDLTest_AssertCheck(result == true, "Verify return value; expected: true, got: %d", result);
+
+    result = SDL_SyncWindow(window);
+    SDLTest_AssertPass("SDL_SyncWindow()");
+    SDLTest_AssertCheck(result == true, "Verify return value; expected: true, got: %d", result);
+
+    /* Verify that window is in fullscreen */
+    flags = SDL_GetWindowFlags(window);
+    SDLTest_AssertPass("SDL_GetWindowFlags()");
+    SDLTest_AssertCheck(flags & SDL_WINDOW_FULLSCREEN, "Verify the `SDL_WINDOW_FULLSCREEN` flag is set: %s", (flags & SDL_WINDOW_FULLSCREEN) ? "true" : "false");
+
+    /* Set the fullscreen window position */
+    desiredX = desiredX + 10;
+    desiredY = desiredY + 10;
+    SDL_SetWindowPosition(window, desiredX, desiredY);
+    SDLTest_AssertPass("Call to SDL_SetWindowPosition(...,%d,%d)", desiredX, desiredY);
+
+    result = SDL_SetWindowFullscreen(window, false);
+    SDLTest_AssertPass("SDL_SetWindowFullscreen()");
+    SDLTest_AssertCheck(result == true, "Verify return value; expected: true, got: %d", result);
+
+    result = SDL_SyncWindow(window);
+    SDLTest_AssertPass("SDL_SyncWindow()");
+    SDLTest_AssertCheck(result == true, "Verify return value; expected: true, got: %d", result);
+
+    /* Verify that window left fullscreen */
+    flags = SDL_GetWindowFlags(window);
+    SDLTest_AssertPass("SDL_GetWindowFlags()");
+    SDLTest_AssertCheck(!(flags & SDL_WINDOW_FULLSCREEN), "Verify the `SDL_WINDOW_FULLSCREEN` flag is not set: %s", !(flags & SDL_WINDOW_FULLSCREEN) ? "true" : "false");
+
+    /* Get position */
+    currentX = desiredX + 1;
+    currentY = desiredY + 1;
+    SDL_GetWindowPosition(window, &currentX, &currentY);
+    SDLTest_AssertPass("Call to SDL_GetWindowPosition()");
+
+    if (desiredX == currentX && desiredY == currentY) {
+        SDLTest_AssertCheck(desiredX == currentX, "Verify returned X position; expected: %d, got: %d", desiredX, currentX);
+        SDLTest_AssertCheck(desiredY == currentY, "Verify returned Y position; expected: %d, got: %d", desiredY, currentY);
+    } else {
+        bool hasEvent;
+        /* SDL_SetWindowPosition() and SDL_SetWindowSize() will make requests of the window manager and set the internal position and size,
+         * and then we get events signaling what actually happened, and they get passed on to the application if they're not what we expect. */
+        currentX = desiredX + 1;
+        currentY = desiredY + 1;
+        hasEvent = getPositionFromEvent(&currentX, &currentY);
+        SDLTest_AssertCheck(hasEvent == true, "Changing position was not honored by WM, checking present of SDL_EVENT_WINDOW_MOVED");
+        if (hasEvent) {
+            SDLTest_AssertCheck(desiredX == currentX, "Verify returned X position is the position from SDL event; expected: %d, got: %d", desiredX, currentX);
+            SDLTest_AssertCheck(desiredY == currentY, "Verify returned Y position is the position from SDL event; expected: %d, got: %d", desiredY, currentY);
+        }
+    }
+
 null_tests:
 
     /* Dummy call with both pointers NULL */