SDL: wayland: Rework scale-to-display

From df3fea87d6e905d6d8dc40174f10fb167b1a58c5 Mon Sep 17 00:00:00 2001
From: Frank Praznik <[EMAIL REDACTED]>
Date: Mon, 13 May 2024 12:59:37 -0400
Subject: [PATCH] wayland: Rework scale-to-display

This extends the display scaling mode to be global and work in terms of pixels everywhere, with the content scale value set on displays. The per-window property had some issues, and has been removed in favor of retaining only the global hint that changes all coordinates to pixel values, sets the content scale on the displays, and generally makes the Wayland backend behave similarly to Win32 or X11.

Some additional work was needed to fix cases where displays can appear to overlap, since Wayland desktops are always described in logical coordinates, and attempting to adjust the display positions so that they don't overlap can get very ugly in all but the simplest cases, as large gaps between displays can result.
---
 include/SDL3/SDL_hints.h              | 13 ++++-----
 include/SDL3/SDL_video.h              | 13 ---------
 src/video/SDL_sysvideo.h              |  3 ++-
 src/video/SDL_video.c                 |  9 +++++++
 src/video/wayland/SDL_waylandvideo.c  | 39 ++++++++++++++++++---------
 src/video/wayland/SDL_waylandvideo.h  |  2 ++
 src/video/wayland/SDL_waylandwindow.c | 30 ++++++++++-----------
 src/video/wayland/SDL_waylandwindow.h |  1 +
 test/testdisplayinfo.c                |  2 +-
 9 files changed, 62 insertions(+), 50 deletions(-)

diff --git a/include/SDL3/SDL_hints.h b/include/SDL3/SDL_hints.h
index 671a841b641c4..5aa31e8d3a5b2 100644
--- a/include/SDL3/SDL_hints.h
+++ b/include/SDL3/SDL_hints.h
@@ -3093,6 +3093,7 @@ extern "C" {
 
 /**
  * A variable forcing non-DPI-aware Wayland windows to output at 1:1 scaling.
+ * This must be set before initializing the video subsystem.
  *
  * When this hint is set, Wayland windows that are not flagged as being
  * DPI-aware will be output with scaling designed to force 1:1 pixel mapping.
@@ -3102,12 +3103,12 @@ extern "C" {
  * configurations, as this forces the window to behave in a way that Wayland
  * desktops were not designed to accommodate:
  *
- * - Rounding errors can result with odd window sizes and/or desktop scales.
- * - The window may be unusably small.
- * - The window may jump in size at times.
- * - The window may appear to be larger than the desktop size to the
- *   application.
- * - Possible loss of cursor precision.
+ * - Rounding errors can result with odd window sizes and/or desktop scales,
+ *   which can cause the window contents to appear slightly blurry.
+ * - The window may be unusably small on scaled desktops.
+ * - The window may jump in size when moving between displays of different scale factors.
+ * - Displays may appear to overlap when using a multi-monitor setup with scaling enabled.
+ * - Possible loss of cursor precision due to the logical size of the window being reduced.
  *
  * New applications should be designed with proper DPI awareness handling
  * instead of enabling this.
diff --git a/include/SDL3/SDL_video.h b/include/SDL3/SDL_video.h
index 485544e69881a..e84717bc215a6 100644
--- a/include/SDL3/SDL_video.h
+++ b/include/SDL3/SDL_video.h
@@ -958,18 +958,6 @@ extern DECLSPEC SDL_Window *SDLCALL SDL_CreatePopupWindow(SDL_Window *parent, in
  *
  * These are additional supported properties on Wayland:
  *
- * - `SDL_PROP_WINDOW_CREATE_WAYLAND_SCALE_TO_DISPLAY_BOOLEAN` - true if the
- *   window should use forced scaling designed to produce 1:1 pixel mapping if
- *   not flagged as being DPI-aware. This is intended to allow legacy
- *   applications to be displayed without desktop scaling being applied, and
- *   has issues with certain display configurations, as this forces the window
- *   to behave in a way that Wayland desktops were not designed to
- *   accommodate. Potential issues include, but are not limited to: rounding
- *   errors can result when odd window sizes/scales are used, the window may
- *   be unusably small, the window may jump in visible size at times, the
- *   window may appear to be larger than the desktop space, and possible loss
- *   of cursor precision can occur. New applications should be designed with
- *   proper DPI awareness and handling instead of enabling this.
  * - `SDL_PROP_WINDOW_CREATE_WAYLAND_SURFACE_ROLE_CUSTOM_BOOLEAN` - true if
  *   the application wants to use the Wayland surface for a custom role and
  *   does not want it attached to an XDG toplevel window. See
@@ -1038,7 +1026,6 @@ extern DECLSPEC SDL_Window *SDLCALL SDL_CreateWindowWithProperties(SDL_Propertie
 #define SDL_PROP_WINDOW_CREATE_Y_NUMBER                            "y"
 #define SDL_PROP_WINDOW_CREATE_COCOA_WINDOW_POINTER                "cocoa.window"
 #define SDL_PROP_WINDOW_CREATE_COCOA_VIEW_POINTER                  "cocoa.view"
-#define SDL_PROP_WINDOW_CREATE_WAYLAND_SCALE_TO_DISPLAY_BOOLEAN    "wayland.scale_to_display"
 #define SDL_PROP_WINDOW_CREATE_WAYLAND_SURFACE_ROLE_CUSTOM_BOOLEAN "wayland.surface_role_custom"
 #define SDL_PROP_WINDOW_CREATE_WAYLAND_CREATE_EGL_WINDOW_BOOLEAN   "wayland.create_egl_window"
 #define SDL_PROP_WINDOW_CREATE_WAYLAND_WL_SURFACE_POINTER          "wayland.wl_surface"
diff --git a/src/video/SDL_sysvideo.h b/src/video/SDL_sysvideo.h
index 065d11b8ea6f3..bfc85a510521e 100644
--- a/src/video/SDL_sysvideo.h
+++ b/src/video/SDL_sysvideo.h
@@ -157,7 +157,8 @@ typedef enum
     VIDEO_DEVICE_CAPS_MODE_SWITCHING_EMULATED = 0x01,
     VIDEO_DEVICE_CAPS_HAS_POPUP_WINDOW_SUPPORT = 0x02,
     VIDEO_DEVICE_CAPS_SENDS_FULLSCREEN_DIMENSIONS = 0x04,
-    VIDEO_DEVICE_CAPS_FULLSCREEN_ONLY = 0x08
+    VIDEO_DEVICE_CAPS_FULLSCREEN_ONLY = 0x08,
+    VIDEO_DEVICE_CAPS_SENDS_DISPLAY_CHANGES = 0x10
 } DeviceCaps;
 
 struct SDL_VideoDevice
diff --git a/src/video/SDL_video.c b/src/video/SDL_video.c
index cf752dbf1fa2e..fdff61e28543b 100644
--- a/src/video/SDL_video.c
+++ b/src/video/SDL_video.c
@@ -185,6 +185,11 @@ static SDL_bool IsFullscreenOnly(SDL_VideoDevice *_this)
     return !!(_this->device_caps & VIDEO_DEVICE_CAPS_FULLSCREEN_ONLY);
 }
 
+static SDL_bool SDL_SendsDisplayChanges(SDL_VideoDevice *_this)
+{
+    return !!(_this->device_caps & VIDEO_DEVICE_CAPS_SENDS_DISPLAY_CHANGES);
+}
+
 /* Hint to treat all window ops as synchronous */
 static SDL_bool syncHint;
 
@@ -1545,6 +1550,10 @@ SDL_DisplayID SDL_GetDisplayForWindow(SDL_Window *window)
 
 static void SDL_CheckWindowDisplayChanged(SDL_Window *window)
 {
+    if (SDL_SendsDisplayChanges(_this)) {
+        return;
+    }
+
     SDL_DisplayID displayID = SDL_GetDisplayForWindowPosition(window);
 
     if (displayID != window->last_displayID) {
diff --git a/src/video/wayland/SDL_waylandvideo.c b/src/video/wayland/SDL_waylandvideo.c
index 173fc7f7c99d1..f8db42975c965 100644
--- a/src/video/wayland/SDL_waylandvideo.c
+++ b/src/video/wayland/SDL_waylandvideo.c
@@ -423,6 +423,7 @@ static SDL_VideoDevice *Wayland_CreateDevice(void)
     data->display = display;
     data->input = input;
     data->display_externally_owned = display_is_external;
+    data->scale_to_display_enabled = SDL_GetHintBoolean(SDL_HINT_VIDEO_WAYLAND_SCALE_TO_DISPLAY, SDL_FALSE);
     WAYLAND_wl_list_init(&data->output_list);
     WAYLAND_wl_list_init(&data->output_order);
     WAYLAND_wl_list_init(&external_window_list);
@@ -488,6 +489,7 @@ static SDL_VideoDevice *Wayland_CreateDevice(void)
     device->SetWindowModalFor = Wayland_SetWindowModalFor;
     device->SetWindowTitle = Wayland_SetWindowTitle;
     device->GetWindowSizeInPixels = Wayland_GetWindowSizeInPixels;
+    device->GetDisplayForWindow = Wayland_GetDisplayForWindow;
     device->DestroyWindow = Wayland_DestroyWindow;
     device->SetWindowHitTest = Wayland_SetWindowHitTest;
     device->FlashWindow = Wayland_FlashWindow;
@@ -519,7 +521,8 @@ static SDL_VideoDevice *Wayland_CreateDevice(void)
 
     device->device_caps = VIDEO_DEVICE_CAPS_MODE_SWITCHING_EMULATED |
                           VIDEO_DEVICE_CAPS_HAS_POPUP_WINDOW_SUPPORT |
-                          VIDEO_DEVICE_CAPS_SENDS_FULLSCREEN_DIMENSIONS;
+                          VIDEO_DEVICE_CAPS_SENDS_FULLSCREEN_DIMENSIONS |
+                          VIDEO_DEVICE_CAPS_SENDS_DISPLAY_CHANGES;
 
     return device;
 }
@@ -831,9 +834,16 @@ static void display_handle_done(void *data,
     SDL_zero(desktop_mode);
     desktop_mode.format = SDL_PIXELFORMAT_XRGB8888;
 
-    desktop_mode.w = driverdata->screen_width;
-    desktop_mode.h = driverdata->screen_height;
-    desktop_mode.pixel_density = driverdata->scale_factor;
+    if (!video->scale_to_display_enabled) {
+        desktop_mode.w = driverdata->screen_width;
+        desktop_mode.h = driverdata->screen_height;
+        desktop_mode.pixel_density = driverdata->scale_factor;
+    } else {
+        desktop_mode.w = native_mode.w;
+        desktop_mode.h = native_mode.h;
+        desktop_mode.pixel_density = 1.0f;
+    }
+
     desktop_mode.refresh_rate = ((100 * driverdata->refresh) / 1000) / 100.0f; /* mHz to Hz */
 
     if (driverdata->display > 0) {
@@ -842,6 +852,10 @@ static void display_handle_done(void *data,
         dpy = &driverdata->placeholder;
     }
 
+    if (video->scale_to_display_enabled) {
+        SDL_SetDisplayContentScale(dpy, driverdata->scale_factor);
+    }
+
     /* Set the desktop display mode. */
     SDL_SetDesktopDisplayMode(dpy, &desktop_mode);
 
@@ -1176,6 +1190,12 @@ int Wayland_VideoInit(SDL_VideoDevice *_this)
     // First roundtrip to receive all registry objects.
     WAYLAND_wl_display_roundtrip(data->display);
 
+    // Require viewports for display scaling.
+    if (data->scale_to_display_enabled && !data->viewporter) {
+        SDL_LogError(SDL_LOG_CATEGORY_VIDEO, "wayland: Display scaling requires the missing 'wp_viewporter' protocol: disabling");
+        data->scale_to_display_enabled = SDL_FALSE;
+    }
+
     /* Now that we have all the protocols, load libdecor if applicable */
     Wayland_LoadLibdecor(data, SDL_FALSE);
 
@@ -1203,6 +1223,7 @@ int Wayland_VideoInit(SDL_VideoDevice *_this)
 
 static int Wayland_GetDisplayBounds(SDL_VideoDevice *_this, SDL_VideoDisplay *display, SDL_Rect *rect)
 {
+    SDL_VideoData *viddata = _this->driverdata;
     SDL_DisplayData *driverdata = display->driverdata;
     rect->x = driverdata->x;
     rect->y = driverdata->y;
@@ -1216,15 +1237,7 @@ static int Wayland_GetDisplayBounds(SDL_VideoDevice *_this, SDL_VideoDisplay *di
         rect->w = display->fullscreen_window->current_fullscreen_mode.w;
         rect->h = display->fullscreen_window->current_fullscreen_mode.h;
     } else {
-        /* If the focused window is on the requested display and requires display scaling,
-         * return the physical dimensions in pixels.
-         */
-        SDL_Window *kb = SDL_GetKeyboardFocus();
-        SDL_Window *m = SDL_GetMouseFocus();
-        SDL_bool scale_output = (kb && kb->driverdata->scale_to_display && (kb->last_displayID == display->id)) ||
-                                (m && m->driverdata->scale_to_display && (m->last_displayID == display->id));
-
-        if (!scale_output) {
+        if (!viddata->scale_to_display_enabled) {
             rect->w = display->current_mode->w;
             rect->h = display->current_mode->h;
         } else if (driverdata->transform & WL_OUTPUT_TRANSFORM_90) {
diff --git a/src/video/wayland/SDL_waylandvideo.h b/src/video/wayland/SDL_waylandvideo.h
index 7228636edd292..abf33d614b818 100644
--- a/src/video/wayland/SDL_waylandvideo.h
+++ b/src/video/wayland/SDL_waylandvideo.h
@@ -93,6 +93,8 @@ struct SDL_VideoData
 
     int relative_mouse_mode;
     SDL_bool display_externally_owned;
+
+    SDL_bool scale_to_display_enabled;
 };
 
 struct SDL_DisplayData
diff --git a/src/video/wayland/SDL_waylandwindow.c b/src/video/wayland/SDL_waylandwindow.c
index bfbde9e926b49..4bab51b3b7098 100644
--- a/src/video/wayland/SDL_waylandwindow.c
+++ b/src/video/wayland/SDL_waylandwindow.c
@@ -538,6 +538,7 @@ static void Wayland_move_window(SDL_Window *window)
                          */
                         FlushFullscreenEvents(window);
                         SDL_SendWindowEvent(window, SDL_EVENT_WINDOW_MOVED, display->x, display->y);
+                        SDL_SendWindowEvent(window, SDL_EVENT_WINDOW_DISPLAY_CHANGED, wind->last_displayID, 0);
                     }
                 }
                 break;
@@ -2212,20 +2213,6 @@ int Wayland_CreateWindow(SDL_VideoDevice *_this, SDL_Window *window, SDL_Propert
     const SDL_bool custom_surface_role = (external_surface != NULL) || SDL_GetBooleanProperty(create_props, SDL_PROP_WINDOW_CREATE_WAYLAND_SURFACE_ROLE_CUSTOM_BOOLEAN, SDL_FALSE);
     const SDL_bool create_egl_window = !!(window->flags & SDL_WINDOW_OPENGL) ||
                                        SDL_GetBooleanProperty(create_props, SDL_PROP_WINDOW_CREATE_WAYLAND_CREATE_EGL_WINDOW_BOOLEAN, SDL_FALSE);
-    SDL_bool scale_to_display = !(window->flags & SDL_WINDOW_HIGH_PIXEL_DENSITY) && !custom_surface_role &&
-                                SDL_GetBooleanProperty(create_props, SDL_PROP_WINDOW_CREATE_WAYLAND_SCALE_TO_DISPLAY_BOOLEAN,
-                                                       SDL_GetHintBoolean(SDL_HINT_VIDEO_WAYLAND_SCALE_TO_DISPLAY, SDL_FALSE));
-
-    /* Require viewports for display scaling. */
-    if (scale_to_display && !c->viewporter) {
-        SDL_LogError(SDL_LOG_CATEGORY_VIDEO, "wayland: Display scaling requires the missing 'wp_viewporter' protocol: disabling");
-        scale_to_display = SDL_FALSE;
-    }
-
-    /* Require popups to have the same scaling mode as the parent. */
-    if (SDL_WINDOW_IS_POPUP(window) && scale_to_display != window->parent->driverdata->scale_to_display) {
-        return SDL_SetError("wayland: Popup windows must use the same scaling as their parent");
-    }
 
     data = SDL_calloc(1, sizeof(*data));
     if (!data) {
@@ -2250,7 +2237,7 @@ int Wayland_CreateWindow(SDL_VideoDevice *_this, SDL_Window *window, SDL_Propert
         data->scale_to_display = window->parent->driverdata->scale_to_display;
         data->windowed_scale_factor = window->parent->driverdata->windowed_scale_factor;
         EnsurePopupPositionIsValid(window, &window->x, &window->y);
-    } else if ((window->flags & SDL_WINDOW_HIGH_PIXEL_DENSITY) || scale_to_display) {
+    } else if ((window->flags & SDL_WINDOW_HIGH_PIXEL_DENSITY) || c->scale_to_display_enabled) {
         for (int i = 0; i < _this->num_displays; i++) {
             float scale = _this->displays[i]->driverdata->scale_factor;
             data->windowed_scale_factor = SDL_max(data->windowed_scale_factor, scale);
@@ -2259,7 +2246,7 @@ int Wayland_CreateWindow(SDL_VideoDevice *_this, SDL_Window *window, SDL_Propert
 
     data->outputs = NULL;
     data->num_outputs = 0;
-    data->scale_to_display = scale_to_display;
+    data->scale_to_display = c->scale_to_display_enabled;
 
     /* Cache the app_id at creation time, as it may change before the window is mapped. */
     data->app_id = SDL_strdup(SDL_GetAppID());
@@ -2509,6 +2496,17 @@ void Wayland_GetWindowSizeInPixels(SDL_VideoDevice *_this, SDL_Window *window, i
     *h = data->current.drawable_height;
 }
 
+SDL_DisplayID Wayland_GetDisplayForWindow(SDL_VideoDevice *_this, SDL_Window *window)
+{
+    SDL_WindowData *wind = window->driverdata;
+
+    if (wind) {
+        return wind->last_displayID;
+    }
+
+    return 0;
+}
+
 void Wayland_SetWindowTitle(SDL_VideoDevice *_this, SDL_Window *window)
 {
     SDL_WindowData *wind = window->driverdata;
diff --git a/src/video/wayland/SDL_waylandwindow.h b/src/video/wayland/SDL_waylandwindow.h
index 24245cff55660..b490aa206d43e 100644
--- a/src/video/wayland/SDL_waylandwindow.h
+++ b/src/video/wayland/SDL_waylandwindow.h
@@ -200,6 +200,7 @@ extern void Wayland_SetWindowSize(SDL_VideoDevice *_this, SDL_Window *window);
 extern void Wayland_SetWindowMinimumSize(SDL_VideoDevice *_this, SDL_Window *window);
 extern void Wayland_SetWindowMaximumSize(SDL_VideoDevice *_this, SDL_Window *window);
 extern void Wayland_GetWindowSizeInPixels(SDL_VideoDevice *_this, SDL_Window *window, int *w, int *h);
+extern SDL_DisplayID Wayland_GetDisplayForWindow(SDL_VideoDevice *_this, SDL_Window *window);
 extern int Wayland_SetWindowModalFor(SDL_VideoDevice *_this, SDL_Window *modal_window, SDL_Window *parent_window);
 extern void Wayland_SetWindowTitle(SDL_VideoDevice *_this, SDL_Window *window);
 extern void Wayland_ShowWindowSystemMenu(SDL_Window *window, int x, int y);
diff --git a/test/testdisplayinfo.c b/test/testdisplayinfo.c
index 0e45b54a808fd..81c6f709fd124 100644
--- a/test/testdisplayinfo.c
+++ b/test/testdisplayinfo.c
@@ -71,7 +71,7 @@ int main(int argc, char *argv[])
 
         SDL_GetDisplayBounds(dpy, &rect);
         modes = SDL_GetFullscreenDisplayModes(dpy, &num_modes);
-        SDL_Log("%" SDL_PRIu32 ": \"%s\" (%dx%d at %d,%d), content scale %.1f, %d fullscreen modes.\n", dpy, SDL_GetDisplayName(dpy), rect.w, rect.h, rect.x, rect.y, SDL_GetDisplayContentScale(dpy), num_modes);
+        SDL_Log("%" SDL_PRIu32 ": \"%s\" (%dx%d at %d,%d), content scale %.2f, %d fullscreen modes.\n", dpy, SDL_GetDisplayName(dpy), rect.w, rect.h, rect.x, rect.y, SDL_GetDisplayContentScale(dpy), num_modes);
 
         mode = SDL_GetCurrentDisplayMode(dpy);
         if (mode) {