Преглед изворни кода

Feature/stretching across monitors (#557)

* feat: add --screen-span to stretch wallpaper across multiple monitors

* debug: add span UV logging, revert to updateUVs-based scaling

* fix: add xdg-output protocol for correct monitor positions

Some Wayland compositors (including Hyprland) send (0,0) in
wl_output.geometry and rely on xdg-output-unstable-v1 for the
actual logical positions. This adds xdg-output support so
globalPosition is correctly set for span UV computation.

* fix: throttle span debug logging to first 5 frames

* fix: safe xdg-output version negotiation

Use min(server_version, 3) when binding xdg-output-manager
to avoid protocol errors on compositors with older versions.

* docs: document --screen-span in README

* refactor: extract helper methods from complex constructors

* docs: (pr) adjust readme

* fix: (pr) make --screen-span and --screen-root not exclusive

* fix: (pr) reject duplicate screen names

* fix: (pr) use logical coordinates for span bounding box computation

* fix: (pr) render shared scene at span dimensions, not first monitor's

* fix: (pr) destroy zxdg_output_v1 in onLayerClose and destructor

* fix: (pr) add more logging

* fix: (pr) more logging

* fix: (pr) resolve comment

* fix: (pr) address code review findings

---------

Co-authored-by: Alexis Maiquez <wpengine@alma.mu>
oq_ пре 4 месеци
родитељ
комит
94eb4c481e

+ 7 - 1
CMakeLists.txt

@@ -178,6 +178,10 @@ if(WAYLAND_SUPPORT_FOUND)
         COMMAND ${WaylandScanner} client-header ${WAYLAND_PROTOCOLS_DIR}/stable/xdg-shell/xdg-shell.xml ${WAYLAND_OUTPUT_DIR}/xdg-shell-protocol.h)
     add_custom_command(OUTPUT ${WAYLAND_OUTPUT_DIR}/xdg-shell-protocol.c
         COMMAND ${WaylandScanner} private-code ${WAYLAND_PROTOCOLS_DIR}/stable/xdg-shell/xdg-shell.xml ${WAYLAND_OUTPUT_DIR}/xdg-shell-protocol.c)
+    add_custom_command(OUTPUT ${WAYLAND_OUTPUT_DIR}/xdg-output-unstable-v1-protocol.h
+        COMMAND ${WaylandScanner} client-header ${WAYLAND_PROTOCOLS_DIR}/unstable/xdg-output/xdg-output-unstable-v1.xml ${WAYLAND_OUTPUT_DIR}/xdg-output-unstable-v1-protocol.h)
+    add_custom_command(OUTPUT ${WAYLAND_OUTPUT_DIR}/xdg-output-unstable-v1-protocol.c
+        COMMAND ${WaylandScanner} private-code ${WAYLAND_PROTOCOLS_DIR}/unstable/xdg-output/xdg-output-unstable-v1.xml ${WAYLAND_OUTPUT_DIR}/xdg-output-unstable-v1-protocol.c)
 
     include_directories(${WAYLAND_SUPPORT_INCLUDE_DIRS})
     include_directories(${WAYLAND_OUTPUT_DIR})
@@ -203,7 +207,9 @@ if(WAYLAND_SUPPORT_FOUND)
         ${WAYLAND_OUTPUT_DIR}/wlr-foreign-toplevel-management-unstable-v1-protocol.c
         ${WAYLAND_OUTPUT_DIR}/wlr-foreign-toplevel-management-unstable-v1-protocol.h
         ${WAYLAND_OUTPUT_DIR}/wlr-layer-shell-unstable-v1-protocol.c
-        ${WAYLAND_OUTPUT_DIR}/wlr-layer-shell-unstable-v1-protocol.h)
+        ${WAYLAND_OUTPUT_DIR}/wlr-layer-shell-unstable-v1-protocol.h
+        ${WAYLAND_OUTPUT_DIR}/xdg-output-unstable-v1-protocol.c
+        ${WAYLAND_OUTPUT_DIR}/xdg-output-unstable-v1-protocol.h)
 endif()
 
 if(X11_FOUND)

+ 9 - 2
README.md

@@ -180,7 +180,8 @@ If you're one of those developers, feel free to open an issue to get your projec
 | `--fps <val>` | Limit frame rate |
 | `--window <XxYxWxH>` | Run in windowed mode with custom size/position |
 | `--screen-root <screen>` | Set as background for specific screen |
-| `--bg <id/path>` | Assign a background to a specific screen (use after `--screen-root`) |
+| `--screen-span <screen-1>,<screen-2>,...` | Stretch a single wallpaper across multiple screens |
+| `--bg <id/path>` | Assign a background to a specific screen (use after `--screen-root`/`--screen-span`) |
 | `--scaling <mode>` | Wallpaper scaling: `stretch`, `fit`, `fill`, or `default` |
 | `--clamping <mode>` | Set texture clamping: `clamp`, `border`, `repeat` |
 | `--assets-dir <path>` | Set custom path for assets |
@@ -214,6 +215,12 @@ linux-wallpaperengine \
   --scaling fill --screen-root HDMI-1 --bg 2667198602
 ```
 
+#### Stretch one wallpaper across multiple monitors
+```bash
+linux-wallpaperengine \
+  --scaling fill --screen-span HDMI-A-1,DP-2,DP-3 --bg 1845706469
+```
+
 #### Run in a window
 ```bash
 linux-wallpaperengine --window 0x0x1280x720 1845706469
@@ -296,7 +303,7 @@ linux-wallpaperengine --set-property bloom=1 2370927443
 
 ## 🧪 Wayland & X11 Support
 
-- **Wayland**: Works with compositors that support `wlr-layer-shell-unstable`.
+- **Wayland**: Works with compositors that support `wlr-layer-shell-unstable`. Uses `xdg-output-unstable-v1` for accurate monitor positioning (required for `--screen-span`).
 - **X11**: Requires XRandr. Use `--screen-root <screen_name>` (as shown in `xrandr`).
 
 > ⚠ For X11 users: Currently doesn't work if a compositor or desktop environment (e.g. GNOME, KDE, Nautilus) is drawing the background.

+ 73 - 5
src/WallpaperEngine/Application/ApplicationContext.cpp

@@ -9,6 +9,7 @@
 #include <cstring>
 #include <fstream>
 #include <iostream>
+#include <sstream>
 #include <optional>
 #include <stdexcept>
 #include <string_view>
@@ -298,6 +299,11 @@ void ApplicationContext::loadSettingsFromArgv () {
 		!= this->settings.general.screenBackgrounds.end ()) {
 		sLog.exception ("Cannot specify the same screen more than once: ", value);
 	    }
+	    for (const auto& group : this->settings.general.spanGroups) {
+		if (std::find (group.screens.begin (), group.screens.end (), value) != group.screens.end ()) {
+		    sLog.exception ("--screen-root: screen '", value, "' already belongs to a span group");
+		}
+	    }
 	    if (this->settings.render.mode == EXPLICIT_WINDOW) {
 		sLog.exception ("Cannot run in both background and window mode");
 	    }
@@ -309,12 +315,64 @@ void ApplicationContext::loadSettingsFromArgv () {
 	    this->settings.general.screenClamps[lastScreen] = this->settings.render.window.clamp;
 	})
 	.append ();
+    backgroundGroup.add_argument ("--screen-span")
+	.help ("Comma-separated list of screens to span a single wallpaper across")
+	.action ([this, &lastScreen] (const std::string& value) -> void {
+	    if (this->settings.render.mode == EXPLICIT_WINDOW) {
+		sLog.exception ("Cannot run in both background and window mode");
+	    }
+
+	    this->settings.render.mode = DESKTOP_BACKGROUND;
+
+	    SpanGroup group;
+	    std::string screen;
+	    std::istringstream ss (value);
+
+	    while (std::getline (ss, screen, ',')) {
+		if (screen.empty ()) {
+		    continue;
+		}
+		if (this->settings.general.screenBackgrounds.find (screen)
+		    != this->settings.general.screenBackgrounds.end ()) {
+		    sLog.exception ("--screen-span: screen '", screen, "' is already configured individually");
+		}
+		// reject duplicates within this group
+		if (std::find (group.screens.begin (), group.screens.end (), screen) != group.screens.end ()) {
+		    sLog.exception ("--screen-span: duplicate screen name '", screen, "'");
+		}
+		// reject screens already claimed by another span group
+		for (const auto& existing : this->settings.general.spanGroups) {
+		    if (std::find (existing.screens.begin (), existing.screens.end (), screen)
+			!= existing.screens.end ()) {
+			sLog.exception ("--screen-span: screen '", screen, "' already belongs to another span group");
+		    }
+		}
+		group.screens.push_back (screen);
+	    }
+
+	    if (group.screens.size () < 2) {
+		sLog.exception ("--screen-span requires at least two comma-separated screen names");
+	    }
+
+	    group.scaling = this->settings.render.window.scalingMode;
+	    group.clamp = this->settings.render.window.clamp;
+	    this->settings.general.spanGroups.push_back (std::move (group));
+	    // set lastScreen to a synthetic name so --bg/--scaling/--clamp can target this group
+	    lastScreen = "span:" + value;
+	    // register the synthetic name in screenBackgrounds so the rest of the pipeline sees it
+	    this->settings.general.screenBackgrounds[lastScreen] = "";
+	})
+	.append ();
     backgroundGroup.add_argument ("-b", "--bg")
-	.help ("After --screen-root, specifies the background to use for the given screen")
+	.help ("After --screen-root or --screen-span, specifies the background to use")
 	.action ([this, &lastScreen] (const std::string& value) -> void {
 	    this->settings.general.screenBackgrounds[lastScreen] = translateBackground (value);
 	    // set the default background to the last one used
 	    this->settings.general.defaultBackground = translateBackground (value);
+	    // if this targets a span group, update the group's background too
+	    if (lastScreen.rfind ("span:", 0) == 0 && !this->settings.general.spanGroups.empty ()) {
+		this->settings.general.spanGroups.back ().background = translateBackground (value);
+	    }
 	})
 	.append ();
     backgroundGroup.add_argument ("--playlist")
@@ -344,8 +402,8 @@ void ApplicationContext::loadSettingsFromArgv () {
 	.append ();
     backgroundGroup.add_argument ("--scaling")
 	.help (
-	    "Scaling mode to use when rendering the background, this applies to the previous --window or --screen-root "
-	    "output, or the default background if no other background is specified"
+	    "Scaling mode to use when rendering the background, this applies to the previous --window, --screen-root, "
+	    "or --screen-span output, or the default background if no other background is specified"
 	)
 	.choices ("stretch", "fit", "fill", "default")
 	.action ([this, &lastScreen] (const std::string& value) -> void {
@@ -365,6 +423,10 @@ void ApplicationContext::loadSettingsFromArgv () {
 
 	    if (this->settings.render.mode == DESKTOP_BACKGROUND) {
 		this->settings.general.screenScalings[lastScreen] = mode;
+		// also update span group if targeting one
+		if (lastScreen.rfind ("span:", 0) == 0 && !this->settings.general.spanGroups.empty ()) {
+		    this->settings.general.spanGroups.back ().scaling = mode;
+		}
 	    } else {
 		this->settings.render.window.scalingMode = mode;
 	    }
@@ -372,8 +434,8 @@ void ApplicationContext::loadSettingsFromArgv () {
 	.append ();
     backgroundGroup.add_argument ("--clamp")
 	.help (
-	    "Clamp mode to use when rendering the background, this applies to the previous --window or --screen-root "
-	    "output, or the default background if no other background is specified"
+	    "Clamp mode to use when rendering the background, this applies to the previous --window, --screen-root, "
+	    "or --screen-span output, or the default background if no other background is specified"
 	)
 	.choices ("clamp", "border", "repeat")
 	.action ([this, &lastScreen] (const std::string& value) -> void {
@@ -391,6 +453,10 @@ void ApplicationContext::loadSettingsFromArgv () {
 
 	    if (this->settings.render.mode == DESKTOP_BACKGROUND) {
 		this->settings.general.screenClamps[lastScreen] = flags;
+		// also update span group if targeting one
+		if (lastScreen.rfind ("span:", 0) == 0 && !this->settings.general.spanGroups.empty ()) {
+		    this->settings.general.spanGroups.back ().clamp = flags;
+		}
 	    } else {
 		this->settings.render.window.clamp = flags;
 	    }
@@ -584,6 +650,8 @@ void ApplicationContext::loadSettingsFromArgv () {
 	"    Runs two backgrounds on two screens, one on HDMI-1 and the other on HDMI-2\n\n"
 	"  linux-wallpaperengine --screen-root HDMI-1 --screen-root HDMI-2 2317494988\n"
 	"    Runs the background 2317494988 on two screens, one on HDMI-1 and the other on HDMI-2\n\n"
+	"  linux-wallpaperengine --screen-span HDMI-1,HDMI-2 --bg 2317494988 --scaling fill\n"
+	"    Spans the background 2317494988 across HDMI-1 and HDMI-2 as a single stretched wallpaper\n\n"
     );
 
     try {

+ 11 - 0
src/WallpaperEngine/Application/ApplicationContext.h

@@ -66,6 +66,14 @@ public:
 	PlaylistSettings settings;
     };
 
+    struct SpanGroup {
+	std::vector<std::string> screens;
+	std::filesystem::path background;
+	WallpaperEngine::Render::WallpaperState::TextureUVsScaling scaling
+	    = WallpaperEngine::Render::WallpaperState::TextureUVsScaling::DefaultUVs;
+	TextureFlags clamp = TextureFlags_ClampUVs;
+    };
+
     struct {
 	/**
 	 * General settings
@@ -93,6 +101,8 @@ public:
 	    std::map<std::string, PlaylistDefinition> screenPlaylists;
 	    /** Playlist used in window mode */
 	    std::optional<PlaylistDefinition> defaultPlaylist;
+	    /** Span groups: multiple monitors sharing one stretched wallpaper */
+	    std::vector<SpanGroup> spanGroups;
 	} general;
 
 	/**
@@ -185,6 +195,7 @@ public:
             .screenClamps = {},
             .screenPlaylists = {},
             .defaultPlaylist = std::nullopt,
+            .spanGroups = {},
         },
         .render = {
             .mode = NORMAL_WINDOW,

+ 89 - 2
src/WallpaperEngine/Application/WallpaperApplication.cpp

@@ -21,6 +21,7 @@
 #endif /* DEMOMODE */
 
 #include <algorithm>
+#include <climits>
 #include <numeric>
 #include <unistd.h>
 #define STB_IMAGE_WRITE_IMPLEMENTATION
@@ -182,13 +183,33 @@ void WallpaperApplication::loadBackgrounds () {
     }
 
     for (const auto& [screen, path] : this->m_context.settings.general.screenBackgrounds) {
-	// screens with no screen should use the default
+	// skip span group synthetic keys here, they're handled below
+	if (screen.rfind ("span:", 0) == 0) {
+	    continue;
+	}
+	// screens with no path should use the default
 	if (path.empty ()) {
 	    this->m_backgrounds[screen] = this->loadBackground (this->m_context.settings.general.defaultBackground);
 	} else {
 	    this->m_backgrounds[screen] = this->loadBackground (path);
 	}
     }
+
+    // Load one background per span group
+    for (const auto& spanGroup : this->m_context.settings.general.spanGroups) {
+	if (spanGroup.screens.empty ()) {
+	    continue;
+	}
+
+	std::filesystem::path bgPath = spanGroup.background;
+	if (bgPath.empty ()) {
+	    bgPath = this->m_context.settings.general.defaultBackground;
+	}
+
+	// use the first screen's name as the group key for the loaded project
+	const std::string groupKey = "span:" + spanGroup.screens.front ();
+	this->m_backgrounds[groupKey] = this->loadBackground (bgPath);
+    }
 }
 
 ProjectUniquePtr WallpaperApplication::loadBackground (const std::string& bg) {
@@ -692,8 +713,11 @@ void WallpaperApplication::prepareOutputs () {
     m_renderContext = std::make_unique<WallpaperEngine::Render::RenderContext> (*m_videoDriver, *this);
     // create a new background for each screen
 
-    // set all the specific wallpapers required
+    // set all the specific wallpapers required (skip span group synthetic keys)
     for (const auto& [background, info] : this->m_backgrounds) {
+	if (background.rfind ("span:", 0) == 0) {
+	    continue;
+	}
 	const auto scalingIt = this->m_context.settings.general.screenScalings.find (background);
 	const auto clampIt = this->m_context.settings.general.screenClamps.find (background);
 	const auto scaling = scalingIt != this->m_context.settings.general.screenScalings.end ()
@@ -710,6 +734,69 @@ void WallpaperApplication::prepareOutputs () {
 	    )
 	);
     }
+
+    // Set up span groups: one shared wallpaper per group, registered for each viewport
+    for (const auto& spanGroup : this->m_context.settings.general.spanGroups) {
+	if (spanGroup.screens.empty ()) {
+	    continue;
+	}
+
+	const std::string groupKey = "span:" + spanGroup.screens.front ();
+	const auto bgIt = this->m_backgrounds.find (groupKey);
+	if (bgIt == this->m_backgrounds.end ()) {
+	    continue;
+	}
+
+	// Compute the bounding box of all viewports in this span group
+	const auto& viewports = m_renderContext->getOutput ().getViewports ();
+	int minX = INT_MAX, minY = INT_MAX, maxX = INT_MIN, maxY = INT_MIN;
+	bool anyFound = false;
+
+	for (const auto& screenName : spanGroup.screens) {
+	    const auto vpIt = viewports.find (screenName);
+	    if (vpIt == viewports.end ()) {
+		sLog.error ("Span group screen not found: ", screenName);
+		continue;
+	    }
+	    anyFound = true;
+	    const auto& vp = vpIt->second;
+	    const int x = vp->globalPosition.x;
+	    const int y = vp->globalPosition.y;
+	    const int w = vp->logicalSize.x;
+	    const int h = vp->logicalSize.y;
+	    sLog.debug ("SPAN DEBUG prepareOutputs: screen '", screenName,
+		"' globalPos=(", x, ",", y, ") logicalSize=", w, "x", h);
+	    minX = std::min (minX, x);
+	    minY = std::min (minY, y);
+	    maxX = std::max (maxX, x + w);
+	    maxY = std::max (maxY, y + h);
+	}
+
+	if (!anyFound) {
+	    sLog.error ("No viewports found for span group, skipping");
+	    continue;
+	}
+
+	sLog.debug ("SPAN DEBUG prepareOutputs: bounding box=(", minX, ",", minY, ",", maxX - minX, ",", maxY - minY, ")");
+
+	WallpaperEngine::Render::CWallpaper::SpanInfo spanInfo;
+	spanInfo.totalBounds = { minX, minY, maxX - minX, maxY - minY };
+
+	// Create one shared wallpaper with the span group's scaling mode
+	auto sharedWallpaper = WallpaperEngine::Render::CWallpaper::fromWallpaper (
+	    *bgIt->second->wallpaper, *m_renderContext, *m_audioContext, m_browserContext.get (), spanGroup.scaling,
+	    spanGroup.clamp
+	);
+
+	// Convert to shared_ptr so it can be registered for multiple viewports
+	std::shared_ptr<WallpaperEngine::Render::CWallpaper> shared (std::move (sharedWallpaper));
+	shared->setSpanInfo (spanInfo);
+
+	// Register the same wallpaper for each screen in the span group
+	for (const auto& screenName : spanGroup.screens) {
+	    m_renderContext->setWallpaper (screenName, shared);
+	}
+    }
 }
 
 void WallpaperApplication::setupOpenGLDebugging () {

+ 69 - 5
src/WallpaperEngine/Render/CWallpaper.cpp

@@ -182,6 +182,12 @@ void CWallpaper::setupShaders () {
 
 void CWallpaper::setDestinationFramebuffer (GLuint framebuffer) { this->m_destFramebuffer = framebuffer; }
 
+void CWallpaper::setSpanInfo (const SpanInfo& spanInfo) { this->m_spanInfo = spanInfo; }
+
+const CWallpaper::SpanInfo* CWallpaper::getSpanInfo () const {
+    return this->m_spanInfo.has_value () ? &this->m_spanInfo.value () : nullptr;
+}
+
 void CWallpaper::updateUVs (const glm::ivec4& viewport, const bool vflip) {
     // update UVs if something has changed, otherwise use old values
     if (this->m_state.hasChanged (viewport, vflip, this->getWidth (), this->getHeight ())) {
@@ -190,18 +196,76 @@ void CWallpaper::updateUVs (const glm::ivec4& viewport, const bool vflip) {
     }
 }
 
-void CWallpaper::render (const glm::ivec4& viewport, const bool vflip) {
+void CWallpaper::render (const glm::ivec4& viewport, const bool vflip, const glm::ivec2& globalPosition,
+    const glm::ivec2& logicalSize) {
+    // Get current frame counter from the driver to avoid redundant scene renders
+    const uint32_t currentFrame = this->getContext ().getDriver ().getFrameCounter ();
+    const bool needsSceneRender = (currentFrame != this->m_lastRenderedFrame);
+    const glm::ivec4 sceneViewport = this->m_spanInfo.has_value ()
+	? glm::ivec4 { 0, 0, this->m_spanInfo->totalBounds.z, this->m_spanInfo->totalBounds.w }
+	: viewport;
+
 #if !NDEBUG
     glPushDebugGroup (GL_DEBUG_SOURCE_APPLICATION, 0, -1, "Rendering scene");
 #endif /* !NDEBUG */
-    this->renderFrame (viewport);
+    if (needsSceneRender) {
+	this->renderFrame (sceneViewport);
+	this->m_lastRenderedFrame = currentFrame;
+    }
 #if !NDEBUG
     glPopDebugGroup ();
     glPushDebugGroup (GL_DEBUG_SOURCE_APPLICATION, 0, -1, "Rendering scene to output");
 #endif /* !NDEBUG */
-    // Update UVs coordinates according to scaling mode of this wallpaper
-    updateUVs (viewport, vflip);
-    auto [ustart, uend, vstart, vend] = this->m_state.getTextureUVs ();
+
+    float ustart, uend, vstart, vend;
+
+    if (this->m_spanInfo.has_value ()) {
+	// Span mode: treat bounding box as virtual viewport, scale wallpaper using
+	// the normal scaling rules (fill/fit/stretch/default), then slice per monitor.
+	const auto& span = this->m_spanInfo.value ();
+	const float spanW = static_cast<float> (span.totalBounds.z);
+	const float spanH = static_cast<float> (span.totalBounds.w);
+	const float spanX = static_cast<float> (span.totalBounds.x);
+	const float spanY = static_cast<float> (span.totalBounds.y);
+
+	// Compute base UVs for the wallpaper scaled to the bounding box
+	this->updateUVs (span.totalBounds, vflip);
+	auto [baseUstart, baseUend, baseVstart, baseVend] = this->m_state.getTextureUVs ();
+
+	// This viewport's relative position within the bounding box [0..1]
+	// Use logicalSize (same coordinate space as globalPosition and totalBounds)
+	const float relLeft = (static_cast<float> (globalPosition.x) - spanX) / spanW;
+	const float relRight = (static_cast<float> (globalPosition.x + logicalSize.x) - spanX) / spanW;
+	const float relTop = (static_cast<float> (globalPosition.y) - spanY) / spanH;
+	const float relBottom = (static_cast<float> (globalPosition.y + logicalSize.y) - spanY) / spanH;
+
+	// Interpolate within the base UVs to get this viewport's slice
+	const float baseURange = baseUend - baseUstart;
+	const float baseVRange = baseVend - baseVstart;
+
+	ustart = baseUstart + relLeft * baseURange;
+	uend = baseUstart + relRight * baseURange;
+	vstart = baseVstart + relTop * baseVRange;
+	vend = baseVstart + relBottom * baseVRange;
+
+	// Log span debug info only on first few frames
+	if (this->m_lastRenderedFrame < 5) {
+	    sLog.debug ("SPAN DEBUG: viewport=", viewport.z, "x", viewport.w,
+		" globalPos=(", globalPosition.x, ",", globalPosition.y, ")",
+		" span=(", span.totalBounds.x, ",", span.totalBounds.y, ",", span.totalBounds.z, ",", span.totalBounds.w, ")",
+		" rel=[", relLeft, ",", relRight, "]x[", relTop, ",", relBottom, "]",
+		" baseUV=[", baseUstart, ",", baseUend, "]x[", baseVstart, ",", baseVend, "]",
+		" finalUV=[", ustart, ",", uend, "]x[", vstart, ",", vend, "]");
+	}
+    } else {
+	// Normal mode: compute UVs based on viewport dimensions and wallpaper resolution
+	updateUVs (viewport, vflip);
+	auto uvs = this->m_state.getTextureUVs ();
+	ustart = uvs.ustart;
+	uend = uvs.uend;
+	vstart = uvs.vstart;
+	vend = uvs.vend;
+    }
 
     const GLfloat texCoords[] = {
 	ustart, vstart, uend, vstart, ustart, vend, ustart, vend, uend, vstart, uend, vend,

+ 23 - 1
src/WallpaperEngine/Render/CWallpaper.h

@@ -2,6 +2,7 @@
 
 #include <GL/glew.h>
 #include <GLFW/glfw3.h>
+#include <optional>
 
 #include "WallpaperEngine/Audio/AudioContext.h"
 
@@ -36,6 +37,12 @@ class CWallpaper : public Helpers::ContextAware, public FBOProvider {
     friend class WallpaperEngine::Application::WallpaperApplication;
 
 public:
+    /** Information for span-mode rendering: one wallpaper across multiple viewports */
+    struct SpanInfo {
+	/** Bounding box of the entire span group (x, y, width, height) in global desktop coordinates */
+	glm::ivec4 totalBounds;
+    };
+
     template <class T> [[nodiscard]] const T* as () const {
 	if (is<T> ()) {
 	    return static_cast<const T*> (this);
@@ -59,7 +66,8 @@ public:
     /**
      * Performs a render pass of the wallpaper
      */
-    void render (const glm::ivec4& viewport, const bool vflip);
+    void render (const glm::ivec4& viewport, const bool vflip, const glm::ivec2& globalPosition = {0, 0},
+	const glm::ivec2& logicalSize = {0, 0});
 
     /**
      * Pause the renderer
@@ -114,6 +122,16 @@ public:
      */
     void setDestinationFramebuffer (GLuint framebuffer);
 
+    /**
+     * Sets span info for this wallpaper, enabling span-mode rendering
+     */
+    void setSpanInfo (const SpanInfo& spanInfo);
+
+    /**
+     * @return The span info if set, or nullptr
+     */
+    [[nodiscard]] const SpanInfo* getSpanInfo () const;
+
     /**
      * @return The width of this wallpaper
      */
@@ -184,5 +202,9 @@ private:
     AudioContext& m_audioContext;
     /** Current Wallpaper state */
     WallpaperState m_state;
+    /** Span info for multi-monitor spanning (optional) */
+    std::optional<SpanInfo> m_spanInfo = std::nullopt;
+    /** Frame counter to avoid redundant renderFrame calls when shared across viewports */
+    uint32_t m_lastRenderedFrame = UINT32_MAX;
 };
 } // namespace WallpaperEngine::Render

+ 5 - 0
src/WallpaperEngine/Render/Drivers/Output/OutputViewport.h

@@ -1,5 +1,6 @@
 #pragma once
 
+#include <glm/vec2.hpp>
 #include <glm/vec4.hpp>
 #include <string>
 
@@ -11,6 +12,10 @@ public:
 
     glm::ivec4 viewport;
     std::string name;
+    /** Global position of this viewport in the combined desktop coordinate space */
+    glm::ivec2 globalPosition = {0, 0};
+    /** Logical (unscaled) size in the same coordinate space as globalPosition */
+    glm::ivec2 logicalSize = {0, 0};
 
     /** Whether this viewport is single in the framebuffer or shares space with more viewports */
     bool single;

+ 48 - 2
src/WallpaperEngine/Render/Drivers/Output/WaylandOutputViewport.cpp

@@ -6,6 +6,7 @@
 #define static
 extern "C" {
 #include "wlr-layer-shell-unstable-v1-protocol.h"
+#include "xdg-output-unstable-v1-protocol.h"
 #include "xdg-shell-protocol.h"
 }
 #undef class
@@ -18,6 +19,7 @@ using namespace WallpaperEngine::Render::Drivers::Output;
 static void handleLSConfigure (void* data, zwlr_layer_surface_v1* surface, uint32_t serial, uint32_t w, uint32_t h) {
     const auto viewport = static_cast<WaylandOutputViewport*> (data);
     viewport->size = { w, h };
+    viewport->logicalSize = { w, h };
     viewport->viewport = { 0, 0, viewport->size.x * viewport->scale, viewport->size.y * viewport->scale };
     viewport->resize ();
 
@@ -35,13 +37,17 @@ static void geometry (
     const char* make, const char* model, int32_t transform
 ) {
     const auto viewport = static_cast<WaylandOutputViewport*> (data);
-    viewport->position = { x, y };
+    // only use geometry position as fallback if xdg-output hasn't provided one
+    if (!viewport->hasXdgLogicalPosition) {
+	viewport->globalPosition = { x, y };
+    }
+    sLog.debug ("SPAN DEBUG geometry: output '", viewport->name, "' position=(", x, ",", y, ") transform=", transform);
 }
 
 static void mode (void* data, wl_output* output, uint32_t flags, int32_t width, int32_t height, int32_t refresh) {
     const auto viewport = static_cast<WaylandOutputViewport*> (data);
 
-    // update viewport size too
+    // update viewport size (physical pixels; logicalSize comes from xdg-output or layer shell configure)
     viewport->size = { width, height };
     viewport->viewport = { 0, 0, viewport->size.x * viewport->scale, viewport->size.y * viewport->scale };
 
@@ -106,6 +112,41 @@ constexpr struct zwlr_layer_surface_v1_listener layerSurfaceListener = {
     .closed = handleLSClosed,
 };
 
+static void xdgOutputLogicalPosition (void* data, struct zxdg_output_v1* xdg_output, int32_t x, int32_t y) {
+    const auto viewport = static_cast<WaylandOutputViewport*> (data);
+    viewport->globalPosition = { x, y };
+    viewport->hasXdgLogicalPosition = true;
+    sLog.debug ("SPAN DEBUG xdg-output logical_position: '", viewport->name, "' position=(", x, ",", y, ")");
+}
+
+static void xdgOutputLogicalSize (void* data, struct zxdg_output_v1* xdg_output, int32_t width, int32_t height) {
+    const auto viewport = static_cast<WaylandOutputViewport*> (data);
+    viewport->logicalSize = { width, height };
+    if (viewport->initialized) {
+	viewport->getDriver ()->getOutput ().reset ();
+    }
+}
+
+static void xdgOutputDone (void* data, struct zxdg_output_v1* xdg_output) {
+    // deprecated since xdg-output v3, compositor uses wl_output.done instead
+}
+
+static void xdgOutputName (void* data, struct zxdg_output_v1* xdg_output, const char* name) {
+    // already handled by wl_output.name
+}
+
+static void xdgOutputDescription (void* data, struct zxdg_output_v1* xdg_output, const char* description) {
+    // ignored
+}
+
+constexpr struct zxdg_output_v1_listener xdgOutputListener = {
+    .logical_position = xdgOutputLogicalPosition,
+    .logical_size = xdgOutputLogicalSize,
+    .done = xdgOutputDone,
+    .name = xdgOutputName,
+    .description = xdgOutputDescription,
+};
+
 WaylandOutputViewport::WaylandOutputViewport (
     WaylandOpenGLDriver* driver, uint32_t waylandName, struct wl_registry* registry
 ) : OutputViewport ({ 0, 0, 0, 0 }, "", true), size ({ 0, 0 }), waylandName (waylandName), m_driver (driver) {
@@ -114,6 +155,11 @@ WaylandOutputViewport::WaylandOutputViewport (
     wl_output_add_listener (output, &outputListener, this);
 }
 
+void WaylandOutputViewport::setupXdgOutput (zxdg_output_manager_v1* manager) {
+    this->xdgOutput = zxdg_output_manager_v1_get_xdg_output (manager, this->output);
+    zxdg_output_v1_add_listener (this->xdgOutput, &xdgOutputListener, this);
+}
+
 void WaylandOutputViewport::setupLS () {
     surface = wl_compositor_create_surface (m_driver->getWaylandContext ()->compositor);
 

+ 5 - 0
src/WallpaperEngine/Render/Drivers/Output/WaylandOutputViewport.h

@@ -16,6 +16,8 @@
 
 struct zwlr_layer_shell_v1;
 struct zwlr_layer_surface_v1;
+struct zxdg_output_v1;
+struct zxdg_output_manager_v1;
 
 namespace WallpaperEngine::Render::Drivers {
 class WaylandOpenGLDriver;
@@ -51,8 +53,11 @@ namespace Output {
 	wl_cursor* pointer = nullptr;
 	wl_surface* cursorSurface = nullptr;
 	bool callbackInitialized = false;
+	bool hasXdgLogicalPosition = false;
+	zxdg_output_v1* xdgOutput = nullptr;
 
 	void setupLS ();
+	void setupXdgOutput (zxdg_output_manager_v1* manager);
 
 	/**
 	 * Activates output's context for drawing

+ 54 - 15
src/WallpaperEngine/Render/Drivers/Output/X11Output.cpp

@@ -52,11 +52,12 @@ void X11Output::reset () {
 }
 
 void X11Output::free () {
-    // go through all the viewports and free them
-    for (const auto& [screen, viewport] : this->m_viewports) {
-	delete viewport;
+    // delete owned viewport objects (m_viewports holds non-owning aliases)
+    for (const auto& screen : this->m_screens) {
+	delete screen;
     }
 
+    this->m_screens.clear ();
     this->m_viewports.clear ();
 
     // free all the resources we've got
@@ -101,6 +102,13 @@ void X11Output::loadScreenInfo () {
 	return;
     }
 
+    discoverOutputs (screenResources);
+    XRRFreeScreenResources (screenResources);
+    validateOutputs ();
+    initX11Background ();
+}
+
+void X11Output::discoverOutputs (XRRScreenResources* screenResources) {
     for (int i = 0; i < screenResources->noutput; i++) {
 	const XRROutputInfo* info = XRRGetOutputInfo (this->m_display, screenResources, screenResources->outputs[i]);
 
@@ -116,37 +124,66 @@ void X11Output::loadScreenInfo () {
 	    continue;
 	}
 
-	// add the screen to the list of screens
-	this->m_screens.push_back (new GLFWOutputViewport { { crtc->x, crtc->y, crtc->width, crtc->height },
-							    info->name });
+	// check if this screen is part of a span group
+	bool inSpanGroup = false;
+	for (const auto& spanGroup : this->m_context.settings.general.spanGroups) {
+	    for (const auto& screen : spanGroup.screens) {
+		if (screen == info->name) {
+		    inSpanGroup = true;
+		    break;
+		}
+	    }
+	    if (inSpanGroup) {
+		break;
+	    }
+	}
 
 	// only keep info of registered screens
-	if (this->m_context.settings.general.screenBackgrounds.find (info->name)
-	    != this->m_context.settings.general.screenBackgrounds.end ()) {
+	if (inSpanGroup
+	    || this->m_context.settings.general.screenBackgrounds.find (info->name)
+		!= this->m_context.settings.general.screenBackgrounds.end ()) {
 	    sLog.out (
 		"Found requested screen: ", info->name, " -> ", crtc->x, "x", crtc->y, ":", crtc->width, "x",
 		crtc->height
 	    );
 
-	    this->m_viewports[info->name]
-		= new GLFWOutputViewport { { crtc->x, crtc->y, crtc->width, crtc->height }, info->name };
+	    auto* vp = new GLFWOutputViewport { { crtc->x, crtc->y, crtc->width, crtc->height }, info->name };
+	    vp->globalPosition = { crtc->x, crtc->y };
+	    vp->logicalSize = { crtc->width, crtc->height };
+	    this->m_screens.push_back (vp);
+	    this->m_viewports[info->name] = vp;
 	}
 
 	XRRFreeCrtcInfo (crtc);
     }
+}
 
-    XRRFreeScreenResources (screenResources);
-
+void X11Output::validateOutputs () const {
     bool any = false;
 
     for (const auto& o : this->m_screens) {
 	const auto cur = this->m_context.settings.general.screenBackgrounds.find (o->name);
 
-	if (cur == this->m_context.settings.general.screenBackgrounds.end ()) {
-	    continue;
+	if (cur != this->m_context.settings.general.screenBackgrounds.end ()) {
+	    any = true;
+	    break;
 	}
 
-	any = true;
+	// also check span groups
+	for (const auto& spanGroup : this->m_context.settings.general.spanGroups) {
+	    for (const auto& screen : spanGroup.screens) {
+		if (screen == o->name) {
+		    any = true;
+		    break;
+		}
+	    }
+	    if (any) {
+		break;
+	    }
+	}
+	if (any) {
+	    break;
+	}
     }
 
     if (!any) {
@@ -165,7 +202,9 @@ void X11Output::loadScreenInfo () {
 
 	sLog.exception ("Cannot continue...");
     }
+}
 
+void X11Output::initX11Background () {
     // create pixmap so we can draw things in there
     this->m_pixmap = XCreatePixmap (this->m_display, this->m_root, this->m_fullWidth, this->m_fullHeight, 24);
     this->m_gc = XCreateGC (this->m_display, this->m_pixmap, 0, nullptr);

+ 4 - 0
src/WallpaperEngine/Render/Drivers/Output/X11Output.h

@@ -5,6 +5,7 @@
 #include <string>
 
 #include <X11/Xlib.h>
+#include <X11/extensions/Xrandr.h>
 
 #include "Output.h"
 #include "WallpaperEngine/Render/Drivers/VideoDriver.h"
@@ -26,6 +27,9 @@ public:
 
 private:
     void loadScreenInfo ();
+    void discoverOutputs (XRRScreenResources* screenResources);
+    void validateOutputs () const;
+    void initX11Background ();
     void free ();
 
     Display* m_display = nullptr;

+ 66 - 5
src/WallpaperEngine/Render/Drivers/WaylandOpenGLDriver.cpp

@@ -8,6 +8,7 @@
 #define static
 extern "C" {
 #include "wlr-layer-shell-unstable-v1-protocol.h"
+#include "xdg-output-unstable-v1-protocol.h"
 #include "xdg-shell-protocol.h"
 #include <linux/input-event-codes.h>
 }
@@ -15,6 +16,7 @@ extern "C" {
 #undef namespace
 #undef static
 
+#include <algorithm>
 #include <string.h>
 #include <unistd.h>
 
@@ -119,6 +121,10 @@ handleGlobal (void* data, struct wl_registry* registry, uint32_t name, const cha
 	driver->getWaylandContext ()->seat
 	    = static_cast<wl_seat*> (wl_registry_bind (registry, name, &wl_seat_interface, 1));
 	wl_seat_add_listener (driver->getWaylandContext ()->seat, &seatListener, driver);
+    } else if (strcmp (interface, zxdg_output_manager_v1_interface.name) == 0) {
+	driver->getWaylandContext ()->xdgOutputManager = static_cast<zxdg_output_manager_v1*> (
+	    wl_registry_bind (registry, name, &zxdg_output_manager_v1_interface, std::min (version, 3u))
+	);
     }
 }
 
@@ -240,6 +246,11 @@ void WaylandOpenGLDriver::onLayerClose (Output::WaylandOutputViewport* viewport)
 	zwlr_layer_surface_v1_destroy (viewport->layerSurface);
     }
 
+    if (viewport->xdgOutput) {
+	zxdg_output_v1_destroy (viewport->xdgOutput);
+	viewport->xdgOutput = nullptr;
+    }
+
     if (viewport->surface) {
 	wl_surface_destroy (viewport->surface);
     }
@@ -257,6 +268,13 @@ void WaylandOpenGLDriver::onLayerClose (Output::WaylandOutputViewport* viewport)
 WaylandOpenGLDriver::WaylandOpenGLDriver (ApplicationContext& context, WallpaperApplication& app) :
     VideoDriver (app, m_mouseInput), m_output (context, *this), m_requestedExit (false), m_frameCounter (0),
     m_context (context), m_mouseInput (*this) {
+    initWaylandRegistry ();
+    initEGL ();
+    setupOutputLayerSurfaces ();
+    initGLEW ();
+}
+
+void WaylandOpenGLDriver::initWaylandRegistry () {
     m_waylandContext.display = wl_display_connect (nullptr);
 
     if (!m_waylandContext.display) {
@@ -274,12 +292,39 @@ WaylandOpenGLDriver::WaylandOpenGLDriver (ApplicationContext& context, Wallpaper
 	sLog.exception ("Failed to bind to required interfaces");
     }
 
-    initEGL ();
+    // If xdg-output-manager is available, use it to get logical output positions
+    if (m_waylandContext.xdgOutputManager) {
+	for (const auto& o : this->m_screens) {
+	    o->setupXdgOutput (m_waylandContext.xdgOutputManager);
+	}
+	wl_display_roundtrip (m_waylandContext.display);
+    } else if (!m_context.settings.general.spanGroups.empty ()) {
+	sLog.error ("zxdg_output_manager_v1 is unavailable; screen-span positions will be incorrect.");
+    }
+}
 
+void WaylandOpenGLDriver::setupOutputLayerSurfaces () {
     bool any = false;
 
     for (const auto& o : this->m_screens) {
-	if (!context.settings.general.screenBackgrounds.contains (o->name)) {
+	bool shouldSetup = m_context.settings.general.screenBackgrounds.contains (o->name);
+
+	// also check if this screen is in any span group
+	if (!shouldSetup) {
+	    for (const auto& spanGroup : m_context.settings.general.spanGroups) {
+		for (const auto& screen : spanGroup.screens) {
+		    if (screen == o->name) {
+			shouldSetup = true;
+			break;
+		    }
+		}
+		if (shouldSetup) {
+		    break;
+		}
+	    }
+	}
+
+	if (!shouldSetup) {
 	    continue;
 	}
 
@@ -297,19 +342,27 @@ WaylandOpenGLDriver::WaylandOpenGLDriver (ApplicationContext& context, Wallpaper
 
 	sLog.error ("Requested: ");
 
-	for (const auto& o : context.settings.general.screenBackgrounds | std::views::keys) {
+	for (const auto& o : m_context.settings.general.screenBackgrounds | std::views::keys) {
 	    sLog.error ("  ", o);
 	}
 
+	for (const auto& spanGroup : m_context.settings.general.spanGroups) {
+	    for (const auto& screen : spanGroup.screens) {
+		sLog.error ("  ", screen, " (span group)");
+	    }
+	}
+
 	sLog.exception ("Cannot continue...");
     }
+}
 
+void WaylandOpenGLDriver::initGLEW () {
     glewExperimental = GL_TRUE;
     if (const GLenum result = glewInit (); result != GLEW_OK) {
 	if (result == GLEW_ERROR_NO_GLX_DISPLAY) {
 	    sLog.out ("Failed to initialize GLEW, but continuing with EGL context: No GLX display");
 	} else {
-	    const char* error = reinterpret_cast<const char*>(glewGetErrorString (result));
+	    const char* error = reinterpret_cast<const char*> (glewGetErrorString (result));
 	    sLog.error ("Failed to initialize GLEW: ", error ? error : "Unknown error");
 	    sLog.exception ("Cannot continue...");
 	}
@@ -317,6 +370,14 @@ WaylandOpenGLDriver::WaylandOpenGLDriver (ApplicationContext& context, Wallpaper
 }
 
 WaylandOpenGLDriver::~WaylandOpenGLDriver () {
+    // destroy xdg outputs
+    for (const auto& screen : this->m_screens) {
+	if (screen->xdgOutput) {
+	    zxdg_output_v1_destroy (screen->xdgOutput);
+	    screen->xdgOutput = nullptr;
+	}
+    }
+
     // stop EGL
     eglMakeCurrent (EGL_NO_DISPLAY, EGL_NO_SURFACE, EGL_NO_SURFACE, EGL_NO_CONTEXT);
 
@@ -416,4 +477,4 @@ __attribute__ ((constructor)) void registerWaylandOpenGL () {
 	    return std::make_unique<WaylandOpenGLDriver> (context, application);
 	}
     );
-}
+}

+ 5 - 0
src/WallpaperEngine/Render/Drivers/WaylandOpenGLDriver.h

@@ -24,6 +24,7 @@ class WallpaperApplication;
 
 struct zwlr_layer_shell_v1;
 struct zwlr_layer_surface_v1;
+struct zxdg_output_manager_v1;
 
 namespace WallpaperEngine::Render::Drivers {
 using namespace WallpaperEngine::Application;
@@ -54,6 +55,7 @@ public:
 	wl_shm* shm = nullptr;
 	zwlr_layer_shell_v1* layerShell = nullptr;
 	wl_seat* seat = nullptr;
+	zxdg_output_manager_v1* xdgOutputManager = nullptr;
     };
 
     explicit WaylandOpenGLDriver (ApplicationContext& context, WallpaperApplication& app);
@@ -91,7 +93,10 @@ private:
     WaylandContext m_waylandContext = {};
     mutable bool m_requestedExit;
 
+    void initWaylandRegistry ();
+    void setupOutputLayerSurfaces ();
     void initEGL ();
+    void initGLEW ();
     void finishEGL () const;
 
     uint32_t m_frameCounter = 0;

+ 2 - 1
src/WallpaperEngine/Render/RenderContext.cpp

@@ -24,7 +24,8 @@ void RenderContext::render (Drivers::Output::OutputViewport* viewport) {
 
     // render the background
     if (const auto ref = this->m_wallpapers.find (viewport->name); ref != this->m_wallpapers.end ()) {
-	ref->second->render (viewport->viewport, this->getOutput ().renderVFlip ());
+	ref->second->render (viewport->viewport, this->getOutput ().renderVFlip (), viewport->globalPosition,
+	    viewport->logicalSize);
     }
 
 #if !NDEBUG