SDL: wayland: Expose displays in a deterministic order, and attempt to better handle selecting a primary display.

From 553fc5fe0e53a88f19f011ac3d2fab8de2926dcb Mon Sep 17 00:00:00 2001
From: Frank Praznik <[EMAIL REDACTED]>
Date: Sun, 13 Oct 2024 14:14:10 -0400
Subject: [PATCH] wayland: Expose displays in a deterministic order, and
 attempt to better handle selecting a primary display.

Wayland can expose displays in any arbitrary order, and doesn't have the native concept of a primary display. However, there are games that presume that the first listed display is the primary, which can lead to problems if that output isn't necessarily the ideal one, as they may use that display to build a resolution list or as the default fullscreen output. This sorts displays by position, then attempts to find the primary display, first by querying the explicit ordering hint, then the GNOME DBus property, then tries to determine the 'best' display according to the criteria that is generally ideal for games and media playback.

The makes the list of displays deterministic, as long as the desktop configuration remains static, with a reasonably appropriate one prioritized as primary, even if there is no explicit way to retrieve the primary display. In the case where a user has a particularly esoteric physical display configuration, the new hint enables explicitly overriding the sorting and selection logic, allowing the entire display order to be customized, if necessary.
---
 docs/README-wayland.md               |   6 +
 include/SDL3/SDL_hints.h             |  22 +++
 src/video/wayland/SDL_waylandvideo.c | 260 ++++++++++++++++++++++-----
 src/video/wayland/SDL_waylandvideo.h |   5 +-
 4 files changed, 243 insertions(+), 50 deletions(-)

diff --git a/docs/README-wayland.md b/docs/README-wayland.md
index 7b4330a580d45..93f830422ca53 100644
--- a/docs/README-wayland.md
+++ b/docs/README-wayland.md
@@ -28,6 +28,12 @@ encounter limitations or behavior that is different from other windowing systems
   applications _must_ have an event loop and processes messages on a regular basis, or the application can appear
   unresponsive to both the user and desktop compositor.
 
+### The display reported as the primary by ```SDL_GetPrimaryDisplay()``` is incorrect
+
+- Wayland doesn't natively have the concept of a primary display, so SDL attempts to determine it by querying various
+  system settings, and falling back to a selection algorithm if this fails. If it is incorrect, it can be manually
+  overridden by setting the ```SDL_VIDEO_DISPLAY_PRIORITY``` hint.
+
 ### ```SDL_SetWindowPosition()``` doesn't work on non-popup windows
 
 - Wayland does not allow toplevel windows to position themselves programmatically.
diff --git a/include/SDL3/SDL_hints.h b/include/SDL3/SDL_hints.h
index 2e0b196be76d0..e809b1abd57cc 100644
--- a/include/SDL3/SDL_hints.h
+++ b/include/SDL3/SDL_hints.h
@@ -3131,6 +3131,28 @@ extern "C" {
  */
 #define SDL_HINT_VIDEO_ALLOW_SCREENSAVER "SDL_VIDEO_ALLOW_SCREENSAVER"
 
+/**
+ * A comma separated list containing the names of the displays that SDL should
+ * sort to the front of the display list.
+ *
+ * When this hint is set, displays with matching name strings will be prioritized in
+ * the list of displays, as exposed by calling SDL_GetDisplays(), with the first listed
+ * becoming the primary display. The naming convention can vary depending on the environment,
+ * but it is usually a connector name (e.g. 'DP-1', 'DP-2', 'HDMI-1', etc...).
+ *
+ * On X11 and Wayland desktops, the connector names associated with displays can typically be
+ * found by using the `xrandr` utility.
+ *
+ * This hint is currently supported on the following drivers:
+ *
+ * - Wayland (wayland)
+ *
+ * This hint should be set before SDL is initialized.
+ *
+ * \since This hint is available since SDL 3.1.5.
+ */
+#define SDL_HINT_VIDEO_DISPLAY_PRIORITY "SDL_VIDEO_DISPLAY_PRIORITY"
+
 /**
  * Tell the video driver that we only want a double buffer.
  *
diff --git a/src/video/wayland/SDL_waylandvideo.c b/src/video/wayland/SDL_waylandvideo.c
index b57052ebc6034..da60ee025981d 100644
--- a/src/video/wayland/SDL_waylandvideo.c
+++ b/src/video/wayland/SDL_waylandvideo.c
@@ -244,52 +244,210 @@ static const struct kde_output_order_v1_listener kde_output_order_listener = {
     handle_kde_output_order_done
 };
 
+// Sort the list of displays into a deterministic order
+static int SDLCALL Wayland_DisplayPositionCompare(const void *a, const void *b)
+{
+    const SDL_DisplayData *da = *(SDL_DisplayData **)a;
+    const SDL_DisplayData *db = *(SDL_DisplayData **)b;
+
+    const bool a_at_origin = da->x == 0 && da->y == 0;
+    const bool b_at_origin = db->x == 0 && db->y == 0;
+
+    // Sort the display at 0,0 to be beginning of the list, as that will be the fallback primary.
+    if (a_at_origin && !b_at_origin) {
+        return -1;
+    }
+    if (b_at_origin && !a_at_origin) {
+        return 1;
+    }
+    if (da->x < db->x) {
+        return -1;
+    }
+    if (da->x > db->x) {
+        return 1;
+    }
+    if (da->y < db->y) {
+        return -1;
+    }
+    if (da->y > db->y) {
+        return 1;
+    }
+
+    // If no position information is available, use the connector name.
+    if (da->wl_output_name && db->wl_output_name) {
+        return SDL_strcmp(da->wl_output_name, db->wl_output_name);
+    }
+
+    return 0;
+}
+
+/* Wayland doesn't have the native concept of a primary display, but there are clients that
+ * will base their resolution lists on, or automatically make themselves fullscreen on, the
+ * first listed output, which can lead to problems if the first listed output isn't
+ * necessarily the best display for this. This attempts to find a primary display, first by
+ * querying the GNOME DBus property, then trying to determine the 'best' display if that fails.
+ * If all displays are equal, the one at position 0,0 will become the primary.
+ *
+ * The primary is determined by the following criteria, in order:
+ * - The highest native resolution
+ * - Landscape is preferred over portrait
+ * - TODO: A higher HDR range is preferred
+ * - Higher refresh is preferred (ignoring small differences)
+ * - Lower scale values are preferred (larger display)
+ */
+static int Wayland_GetPrimaryDisplay(SDL_VideoData *vid)
+{
+    static const int REFRESH_DELTA = 4000;
+
+    // Query the DBus interface to see if the coordinates of the primary display are exposed.
+    int x, y;
+    if (Wayland_GetGNOMEPrimaryDisplayCoordinates(&x, &y)) {
+        for (int i = 0; i < vid->output_count; ++i) {
+            if (vid->output_list[i]->x == x && vid->output_list[i]->y == y) {
+                return i;
+            }
+        }
+    }
+
+    // Otherwise, choose the 'best' display.
+    int best_width = 0;
+    int best_height = 0;
+    double best_scale = 0.0;
+    int best_refresh = 0;
+    bool best_is_landscape = false;
+    int best_index = 0;
+
+    for (int i = 0; i < vid->output_count; ++i) {
+        const SDL_DisplayData *d = vid->output_list[i];
+        const bool is_landscape = d->orientation != SDL_ORIENTATION_PORTRAIT && d->orientation != SDL_ORIENTATION_PORTRAIT_FLIPPED;
+        bool have_new_best = false;
+
+        if (d->pixel_width > best_width || d->pixel_height > best_height) {
+            have_new_best = true;
+        } else if (d->pixel_width == best_width && d->pixel_height == best_height) {
+            if (!best_is_landscape && is_landscape) { // Favor landscape over portrait displays.
+                have_new_best = true;
+            } else if (!best_is_landscape || is_landscape) { // Ignore portrait displays if a landscape was already found.
+                if (d->refresh - best_refresh > REFRESH_DELTA) { // Favor a higher refresh rate, but ignore small differences (e.g. 59.97 vs 60.1)
+                    have_new_best = true;
+                } else if (d->scale_factor < best_scale && SDL_abs(d->refresh - best_refresh) <= REFRESH_DELTA) {
+                    // Prefer a lower scale display if the difference in refresh rate is small.
+                    have_new_best = true;
+                }
+            }
+        }
+
+        if (have_new_best) {
+            best_width = d->pixel_width;
+            best_height = d->pixel_height;
+            best_scale = d->scale_factor;
+            best_refresh = d->refresh;
+            best_is_landscape = is_landscape;
+            best_index = i;
+        }
+    }
+
+    return best_index;
+}
+
+static bool Wayland_SortOutputsByPriorityHint(SDL_VideoData *vid)
+{
+    const char *name_hint = SDL_GetHint(SDL_HINT_VIDEO_DISPLAY_PRIORITY);
+
+    if (name_hint) {
+        char *saveptr;
+        char *str = SDL_strdup(name_hint);
+        SDL_DisplayData **sorted_list = SDL_malloc(sizeof(SDL_DisplayData *) * vid->output_count);
+        int sorted_index = 0;
+
+        if (str && sorted_list) {
+            // Sort the requested displays to the front of the list.
+            const char *token = SDL_strtok_r(str, ",", &saveptr);
+            while (token) {
+                for (int i = 0; i < vid->output_count; ++i) {
+                    SDL_DisplayData *d = vid->output_list[i];
+                    if (d && d->wl_output_name && SDL_strcmp(token, d->wl_output_name) == 0) {
+                        sorted_list[sorted_index++] = d;
+                        vid->output_list[i] = NULL;
+                        break;
+                    }
+                }
+
+                token = SDL_strtok_r(NULL, ",", &saveptr);
+            }
+
+            // Append the remaining outputs to the end of the list.
+            for (int i = 0; i < vid->output_count; ++i) {
+                if (vid->output_list[i]) {
+                    sorted_list[sorted_index++] = vid->output_list[i];
+                }
+            }
+
+            // Copy the sorted list to the output list.
+            SDL_memcpy(vid->output_list, sorted_list, sizeof(SDL_DisplayData *) * vid->output_count);
+        }
+
+        SDL_free(str);
+        SDL_free(sorted_list);
+
+        return true;
+    }
+
+    return false;
+}
+
 static void Wayland_SortOutputs(SDL_VideoData *vid)
 {
-    SDL_DisplayData *d;
-    int p_x, p_y;
+    bool have_primary = false;
 
     /* KDE provides the kde-output-order-v1 protocol, which gives us the full preferred display
-     * ordering in the form of a list of wl_output.name strings (connector names).
+     * ordering in the form of a list of wl_output.name strings.
      */
     if (!WAYLAND_wl_list_empty(&vid->output_order)) {
-        struct wl_list sorted_list;
         SDL_WaylandConnectorName *c;
-
-        // Sort the outputs by connector name.
-        WAYLAND_wl_list_init(&sorted_list);
-        wl_list_for_each (c, &vid->output_order, link) {
-            wl_list_for_each (d, &vid->output_list, link) {
-                if (d->wl_output_name && SDL_strcmp(c->wl_output_name, d->wl_output_name) == 0) {
-                    // Remove from the current list and Append the next node to the end of the new list.
-                    WAYLAND_wl_list_remove(&d->link);
-                    WAYLAND_wl_list_insert(sorted_list.prev, &d->link);
-                    break;
+        SDL_DisplayData **sorted_list = SDL_malloc(sizeof(SDL_DisplayData *) * vid->output_count);
+        int sorted_index = 0;
+
+        if (sorted_list) {
+            // Sort the outputs by connector name.
+            wl_list_for_each (c, &vid->output_order, link) {
+                for (int i = 0; i < vid->output_count; ++i) {
+                    SDL_DisplayData *d = vid->output_list[i];
+                    if (d && d->wl_output_name && SDL_strcmp(c->wl_output_name, d->wl_output_name) == 0) {
+                        sorted_list[sorted_index++] = d;
+                        vid->output_list[i] = NULL;
+                        break;
+                    }
                 }
             }
-        }
 
-        if (!WAYLAND_wl_list_empty(&vid->output_list)) {
             /* If any displays were omitted during the sort, append them to the new list.
              * This shouldn't happen, but better safe than sorry.
              */
-            WAYLAND_wl_list_insert_list(sorted_list.prev, &vid->output_list);
+            for (int i = 0; i < vid->output_count; ++i) {
+                if (vid->output_list[i]) {
+                    sorted_list[sorted_index++] = vid->output_list[i];
+                }
+            }
+
+            // Copy the sorted list to the output list.
+            SDL_memcpy(vid->output_list, sorted_list, sizeof(SDL_DisplayData *) * vid->output_count);
+            SDL_free(sorted_list);
+
+            have_primary = true;
         }
+    } else {
+        // Sort by position or connector name, so the order of outputs is deterministic.
+        SDL_qsort(vid->output_list, vid->output_count, sizeof(SDL_DisplayData *), Wayland_DisplayPositionCompare);
+    }
 
-        // Set the output list to the sorted list.
-        WAYLAND_wl_list_init(&vid->output_list);
-        WAYLAND_wl_list_insert_list(&vid->output_list, &sorted_list);
-    } else if (Wayland_GetGNOMEPrimaryDisplayCoordinates(&p_x, &p_y)) {
-        /* GNOME doesn't expose the displays in any preferential order, so find the primary display coordinates and use them
-         * to manually sort the primary display to the front of the list so that it is always the first exposed by SDL.
-         * Otherwise, assume that the displays were already exposed in preferential order.
-         */
-        wl_list_for_each (d, &vid->output_list, link) {
-            if (d->x == p_x && d->y == p_y) {
-                WAYLAND_wl_list_remove(&d->link);
-                WAYLAND_wl_list_insert(&vid->output_list, &d->link);
-                break;
-            }
+    // Apply the ordering hint if specified, otherwise, try to find the primary display, if no preferred order is known.
+    if (!Wayland_SortOutputsByPriorityHint(vid) && !have_primary) {
+        const int primary_index = Wayland_GetPrimaryDisplay(vid);
+        if (primary_index) {
+            SDL_DisplayData *primary = vid->output_list[primary_index];
+            SDL_memmove(&vid->output_list[1], &vid->output_list[0], sizeof(SDL_DisplayData *) * primary_index);
+            vid->output_list[0] = primary;
         }
     }
 }
@@ -485,7 +643,6 @@ static SDL_VideoDevice *Wayland_CreateDevice(bool require_preferred_protocols)
     data->input = input;
     data->display_externally_owned = display_is_external;
     data->scale_to_display_enabled = SDL_GetHintBoolean(SDL_HINT_VIDEO_WAYLAND_SCALE_TO_DISPLAY, false);
-    WAYLAND_wl_list_init(&data->output_list);
     WAYLAND_wl_list_init(&data->output_order);
     WAYLAND_wl_list_init(&external_window_list);
 
@@ -1043,8 +1200,12 @@ static bool Wayland_add_display(SDL_VideoData *d, uint32_t id, uint32_t version)
     wl_output_add_listener(output, &output_listener, data);
     SDL_WAYLAND_register_output(output);
 
-    // Keep a list of outputs for deferred xdg-output initialization.
-    WAYLAND_wl_list_insert(d->output_list.prev, &data->link);
+    // Keep a list of outputs for sorting and deferred protocol initialization.
+    if (d->output_count == d->output_max) {
+        d->output_max += 4;
+        d->output_list = SDL_realloc(d->output_list, sizeof(SDL_DisplayData *) * d->output_max);
+    }
+    d->output_list[d->output_count++] = data;
 
     if (data->videodata->xdg_output_manager) {
         data->xdg_output = zxdg_output_manager_v1_get_xdg_output(data->videodata->xdg_output_manager, output);
@@ -1077,19 +1238,15 @@ static void Wayland_free_display(SDL_VideoDisplay *display)
             wl_output_destroy(display_data->output);
         }
 
-        // Unlink this display.
-        WAYLAND_wl_list_remove(&display_data->link);
-
         SDL_DelVideoDisplay(display->id, false);
     }
 }
 
 static void Wayland_FinalizeDisplays(SDL_VideoData *vid)
 {
-    SDL_DisplayData *d;
-
     Wayland_SortOutputs(vid);
-    wl_list_for_each (d, &vid->output_list, link) {
+    for(int i = 0; i < vid->output_count; ++i) {
+        SDL_DisplayData *d = vid->output_list[i];
         d->display = SDL_AddVideoDisplay(&d->placeholder, false);
         SDL_free(d->placeholder.name);
         SDL_zero(d->placeholder);
@@ -1098,10 +1255,10 @@ static void Wayland_FinalizeDisplays(SDL_VideoData *vid)
 
 static void Wayland_init_xdg_output(SDL_VideoData *d)
 {
-    SDL_DisplayData *node;
-    wl_list_for_each (node, &d->output_list, link) {
-        node->xdg_output = zxdg_output_manager_v1_get_xdg_output(node->videodata->xdg_output_manager, node->output);
-        zxdg_output_v1_add_listener(node->xdg_output, &xdg_output_listener, node);
+    for(int i = 0; i < d->output_count; ++i) {
+        SDL_DisplayData *disp = d->output_list[i];
+        disp->xdg_output = zxdg_output_manager_v1_get_xdg_output(disp->videodata->xdg_output_manager, disp->output);
+        zxdg_output_v1_add_listener(disp->xdg_output, &xdg_output_listener, disp);
     }
 }
 
@@ -1207,12 +1364,18 @@ static void display_handle_global(void *data, struct wl_registry *registry, uint
 static void display_remove_global(void *data, struct wl_registry *registry, uint32_t id)
 {
     SDL_VideoData *d = data;
-    SDL_DisplayData *node;
 
     // We don't get an interface, just an ID, so assume it's a wl_output :shrug:
-    wl_list_for_each (node, &d->output_list, link) {
-        if (node->registry_id == id) {
-            Wayland_free_display(SDL_GetVideoDisplay(node->display));
+    for (int i = 0; i < d->output_count; ++i) {
+        SDL_DisplayData *disp = d->output_list[i];
+        if (disp->registry_id == id) {
+            Wayland_free_display(SDL_GetVideoDisplay(disp->display));
+
+            if (i < d->output_count) {
+                SDL_memmove(&d->output_list[i], &d->output_list[i + 1], sizeof(SDL_DisplayData *) * (d->output_count - i - 1));
+            }
+
+            d->output_count--;
             break;
         }
     }
@@ -1355,6 +1518,7 @@ static void Wayland_VideoCleanup(SDL_VideoDevice *_this)
         SDL_VideoDisplay *display = _this->displays[i];
         Wayland_free_display(display);
     }
+    SDL_free(data->output_list);
 
     Wayland_display_destroy_input(data);
 
diff --git a/src/video/wayland/SDL_waylandvideo.h b/src/video/wayland/SDL_waylandvideo.h
index c9c75d13083be..8d6c8a8627352 100644
--- a/src/video/wayland/SDL_waylandvideo.h
+++ b/src/video/wayland/SDL_waylandvideo.h
@@ -88,7 +88,9 @@ struct SDL_VideoData
 
     struct xkb_context *xkb_context;
     struct SDL_WaylandInput *input;
-    struct wl_list output_list;
+    SDL_DisplayData **output_list;
+    int output_count;
+    int output_max;
     struct wl_list output_order;
 
     bool output_order_finalized;
@@ -115,7 +117,6 @@ struct SDL_DisplayData
     SDL_DisplayID display;
     SDL_VideoDisplay placeholder;
     int wl_output_done_count;
-    struct wl_list link;
 };
 
 // Needed here to get wl_surface declaration, fixes GitHub#4594