diff --git a/CMakeLists.txt b/CMakeLists.txt
index 5c8fb483..a4919952 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -55,9 +55,10 @@ boption(HYPRLAND " Hyprland" ON REQUIRES WAYLAND)
boption(HYPRLAND_IPC " Hyprland IPC" ON REQUIRES HYPRLAND)
boption(HYPRLAND_GLOBAL_SHORTCUTS " Hyprland Global Shortcuts" ON REQUIRES HYPRLAND)
boption(HYPRLAND_FOCUS_GRAB " Hyprland Focus Grabbing" ON REQUIRES HYPRLAND)
-boption(I3 " I3/Sway" ON)
-boption(I3_IPC " I3/Sway IPC" ON REQUIRES I3)
+boption(HYPRLAND_SURFACE_EXTENSIONS " Hyprland Surface Extensions" ON REQUIRES HYPRLAND)
boption(X11 "X11" ON)
+boption(I3 "I3/Sway" ON)
+boption(I3_IPC " I3/Sway IPC" ON REQUIRES I3)
boption(SERVICE_STATUS_NOTIFIER "System Tray" ON)
boption(SERVICE_PIPEWIRE "PipeWire" ON)
boption(SERVICE_MPRIS "Mpris" ON)
diff --git a/src/wayland/CMakeLists.txt b/src/wayland/CMakeLists.txt
index 8005a833..db03cf16 100644
--- a/src/wayland/CMakeLists.txt
+++ b/src/wayland/CMakeLists.txt
@@ -77,6 +77,7 @@ qt_add_library(quickshell-wayland STATIC
platformmenu.cpp
popupanchor.cpp
xdgshell.cpp
+ util.cpp
)
# required to make sure the constructor is linked
diff --git a/src/wayland/hyprland/CMakeLists.txt b/src/wayland/hyprland/CMakeLists.txt
index cb375358..570cbe54 100644
--- a/src/wayland/hyprland/CMakeLists.txt
+++ b/src/wayland/hyprland/CMakeLists.txt
@@ -19,6 +19,11 @@ if (HYPRLAND_GLOBAL_SHORTCUTS)
list(APPEND HYPRLAND_MODULES Quickshell.Hyprland._GlobalShortcuts)
endif()
+if (HYPRLAND_SURFACE_EXTENSIONS)
+ add_subdirectory(surface)
+ list(APPEND HYPRLAND_MODULES Quickshell.Hyprland._SurfaceExtensions)
+endif()
+
qt_add_qml_module(quickshell-hyprland
URI Quickshell.Hyprland
VERSION 0.1
diff --git a/src/wayland/hyprland/module.md b/src/wayland/hyprland/module.md
index 6c2de249..0bdb6a7f 100644
--- a/src/wayland/hyprland/module.md
+++ b/src/wayland/hyprland/module.md
@@ -7,5 +7,6 @@ headers = [
"ipc/qml.hpp",
"focus_grab/qml.hpp",
"global_shortcuts/qml.hpp",
+ "surface/qml.hpp",
]
-----
diff --git a/src/wayland/hyprland/surface/CMakeLists.txt b/src/wayland/hyprland/surface/CMakeLists.txt
new file mode 100644
index 00000000..04fa5c58
--- /dev/null
+++ b/src/wayland/hyprland/surface/CMakeLists.txt
@@ -0,0 +1,26 @@
+qt_add_library(quickshell-hyprland-surface-extensions STATIC
+ qml.cpp
+ manager.cpp
+ surface.cpp
+)
+
+qt_add_qml_module(quickshell-hyprland-surface-extensions
+ URI Quickshell.Hyprland._SurfaceExtensions
+ VERSION 0.1
+ DEPENDENCIES QtQml
+)
+
+install_qml_module(quickshell-hyprland-surface-extensions)
+
+wl_proto(quickshell-hyprland-surface-extensions
+ hyprland-surface-v1
+ "${CMAKE_CURRENT_SOURCE_DIR}/hyprland-surface-v1.xml"
+)
+
+target_link_libraries(quickshell-hyprland-surface-extensions PRIVATE
+ Qt::Quick Qt::WaylandClient Qt::WaylandClientPrivate wayland-client
+)
+
+qs_module_pch(quickshell-hyprland-surface-extensions)
+
+target_link_libraries(quickshell PRIVATE quickshell-hyprland-surface-extensionsplugin)
diff --git a/src/wayland/hyprland/surface/hyprland-surface-v1.xml b/src/wayland/hyprland/surface/hyprland-surface-v1.xml
new file mode 100644
index 00000000..2f683365
--- /dev/null
+++ b/src/wayland/hyprland/surface/hyprland-surface-v1.xml
@@ -0,0 +1,100 @@
+
+
+
+ Copyright © 2025 outfoxxed
+ All rights reserved.
+
+ Redistribution and use in source and binary forms, with or without
+ modification, are permitted provided that the following conditions are met:
+
+ 1. Redistributions of source code must retain the above copyright notice, this
+ list of conditions and the following disclaimer.
+
+ 2. Redistributions in binary form must reproduce the above copyright notice,
+ this list of conditions and the following disclaimer in the documentation
+ and/or other materials provided with the distribution.
+
+ 3. Neither the name of the copyright holder nor the names of its
+ contributors may be used to endorse or promote products derived from
+ this software without specific prior written permission.
+
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+
+
+ This protocol exposes hyprland-specific wl_surface properties.
+
+
+
+
+ This interface allows a client to create hyprland surface objects.
+
+
+
+
+ Create a hyprland surface object for the given wayland surface.
+
+ If the wl_surface already has an associated hyprland_surface_v1 object,
+ even from a different manager, creation is a protocol error.
+
+
+
+
+
+
+
+
+ Destroy the surface manager.
+ This does not destroy existing surface objects.
+
+
+
+
+
+
+
+
+
+
+ This interface allows access to hyprland-specific properties of a wl_surface.
+
+ Once the wl_surface has been destroyed, the hyprland surface object must be
+ destroyed as well. All other operations are a protocol error.
+
+
+
+
+ Sets a multiplier for the overall opacity of the surface.
+ This multiplier applies to visual effects such as blur behind the surface
+ in addition to the surface's content.
+
+ The default value is 1.0.
+ Setting a value outside of the range 0.0 - 1.0 (inclusive) is a protocol error.
+ Does not take effect until wl_surface.commit is called.
+
+
+
+
+
+
+
+ Destroy the hyprland surface object, resetting properties provided
+ by this interface to their default values on the next wl_surface.commit.
+
+
+
+
+
+
+
+
+
diff --git a/src/wayland/hyprland/surface/manager.cpp b/src/wayland/hyprland/surface/manager.cpp
new file mode 100644
index 00000000..31829bb6
--- /dev/null
+++ b/src/wayland/hyprland/surface/manager.cpp
@@ -0,0 +1,24 @@
+#include "manager.hpp"
+
+#include
+#include
+
+#include "surface.hpp"
+
+namespace qs::hyprland::surface::impl {
+
+HyprlandSurfaceManager::HyprlandSurfaceManager(): QWaylandClientExtensionTemplate(1) {
+ this->initialize();
+}
+
+HyprlandSurface*
+HyprlandSurfaceManager::createHyprlandExtension(QtWaylandClient::QWaylandWindow* surface) {
+ return new HyprlandSurface(this->get_hyprland_surface(surface->surface()));
+}
+
+HyprlandSurfaceManager* HyprlandSurfaceManager::instance() {
+ static auto* instance = new HyprlandSurfaceManager();
+ return instance;
+}
+
+} // namespace qs::hyprland::surface::impl
diff --git a/src/wayland/hyprland/surface/manager.hpp b/src/wayland/hyprland/surface/manager.hpp
new file mode 100644
index 00000000..8332241f
--- /dev/null
+++ b/src/wayland/hyprland/surface/manager.hpp
@@ -0,0 +1,22 @@
+#pragma once
+
+#include
+#include
+#include
+
+#include "surface.hpp"
+
+namespace qs::hyprland::surface::impl {
+
+class HyprlandSurfaceManager
+ : public QWaylandClientExtensionTemplate
+ , public QtWayland::hyprland_surface_manager_v1 {
+public:
+ explicit HyprlandSurfaceManager();
+
+ HyprlandSurface* createHyprlandExtension(QtWaylandClient::QWaylandWindow* surface);
+
+ static HyprlandSurfaceManager* instance();
+};
+
+} // namespace qs::hyprland::surface::impl
diff --git a/src/wayland/hyprland/surface/qml.cpp b/src/wayland/hyprland/surface/qml.cpp
new file mode 100644
index 00000000..8477de55
--- /dev/null
+++ b/src/wayland/hyprland/surface/qml.cpp
@@ -0,0 +1,154 @@
+#include "qml.hpp"
+#include
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include "../../../window/proxywindow.hpp"
+#include "../../../window/windowinterface.hpp"
+#include "../../util.hpp"
+#include "manager.hpp"
+#include "surface.hpp"
+
+using QtWaylandClient::QWaylandWindow;
+
+namespace qs::hyprland::surface {
+
+HyprlandWindow* HyprlandWindow::qmlAttachedProperties(QObject* object) {
+ auto* proxyWindow = qobject_cast(object);
+
+ if (!proxyWindow) {
+ if (auto* iface = qobject_cast(object)) {
+ proxyWindow = iface->proxyWindow();
+ }
+ }
+
+ qDebug() << "hlwindow for" << proxyWindow;
+ if (!proxyWindow) return nullptr;
+ return new HyprlandWindow(proxyWindow);
+}
+
+HyprlandWindow::HyprlandWindow(ProxyWindowBase* window): QObject(nullptr), proxyWindow(window) {
+ QObject::connect(
+ window,
+ &ProxyWindowBase::windowConnected,
+ this,
+ &HyprlandWindow::onWindowConnected
+ );
+
+ QObject::connect(window, &QObject::destroyed, this, &HyprlandWindow::onProxyWindowDestroyed);
+
+ if (window->backingWindow()) {
+ this->onWindowConnected();
+ }
+}
+
+qreal HyprlandWindow::opacity() const { return this->mOpacity; }
+
+void HyprlandWindow::setOpacity(qreal opacity) {
+ if (opacity == this->mOpacity) return;
+
+ if (opacity < 0.0 || opacity > 1.0) {
+ qmlWarning(this
+ ) << "Cannot set HyprlandWindow.opacity to a value larger than 1.0 or smaller than 0.0";
+ return;
+ }
+
+ this->mOpacity = opacity;
+
+ if (this->surface) {
+ this->surface->setOpacity(opacity);
+ qs::wayland::util::scheduleCommit(this->mWaylandWindow);
+ }
+
+ emit this->opacityChanged();
+}
+
+void HyprlandWindow::onWindowConnected() {
+ this->mWindow = this->proxyWindow->backingWindow();
+ // disconnected by destructor
+ QObject::connect(
+ this->mWindow,
+ &QWindow::visibleChanged,
+ this,
+ &HyprlandWindow::onWindowVisibleChanged
+ );
+
+ this->onWindowVisibleChanged();
+}
+
+void HyprlandWindow::onWindowVisibleChanged() {
+ if (this->mWindow->isVisible()) {
+ if (!this->mWindow->handle()) {
+ this->mWindow->create();
+ }
+
+ this->mWaylandWindow = dynamic_cast(this->mWindow->handle());
+
+ if (this->mWaylandWindow) {
+ // disconnected by destructor
+
+ QObject::connect(
+ this->mWaylandWindow,
+ &QWaylandWindow::surfaceCreated,
+ this,
+ &HyprlandWindow::onWaylandSurfaceCreated
+ );
+
+ QObject::connect(
+ this->mWaylandWindow,
+ &QWaylandWindow::surfaceDestroyed,
+ this,
+ &HyprlandWindow::onWaylandSurfaceDestroyed
+ );
+
+ if (this->mWaylandWindow->surface()) {
+ this->onWaylandSurfaceCreated();
+ }
+ }
+ }
+}
+
+void HyprlandWindow::onWaylandSurfaceCreated() {
+ auto* manager = impl::HyprlandSurfaceManager::instance();
+
+ if (!manager->isActive()) {
+ qWarning() << "The active compositor does not support the hyprland_surface_v1 protocol. "
+ "HyprlandWindow will not work.";
+ return;
+ }
+
+ auto* ext = manager->createHyprlandExtension(this->mWaylandWindow);
+ this->surface = std::unique_ptr(ext);
+
+ if (this->mOpacity != 1.0) {
+ this->surface->setOpacity(this->mOpacity);
+ qs::wayland::util::scheduleCommit(this->mWaylandWindow);
+ }
+}
+
+void HyprlandWindow::onWaylandSurfaceDestroyed() {
+ this->surface = nullptr;
+
+ if (!this->proxyWindow) {
+ this->deleteLater();
+ }
+}
+
+void HyprlandWindow::onProxyWindowDestroyed() {
+ // Don't delete the HyprlandWindow, and therefore the impl::HyprlandSurface until the wl_surface is destroyed.
+ // Deleting it when the proxy window is deleted will cause a full opacity frame between the destruction of the
+ // hyprland_surface_v1 and wl_surface objects.
+
+ if (this->surface == nullptr) {
+ this->proxyWindow = nullptr;
+ this->deleteLater();
+ }
+}
+
+} // namespace qs::hyprland::surface
diff --git a/src/wayland/hyprland/surface/qml.hpp b/src/wayland/hyprland/surface/qml.hpp
new file mode 100644
index 00000000..ce32a967
--- /dev/null
+++ b/src/wayland/hyprland/surface/qml.hpp
@@ -0,0 +1,74 @@
+#pragma once
+
+#include
+
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include "../../../window/proxywindow.hpp"
+#include "surface.hpp"
+
+namespace qs::hyprland::surface {
+
+///! Hyprland specific QsWindow properties.
+/// Allows setting hyprland specific window properties on a @@Quickshell.QsWindow or subclass,
+/// as an attached object.
+///
+/// #### Example
+/// ```qml
+/// @@Quickshell.PopupWindow {
+/// // ...
+/// HyprlandWindow.opacity: 0.6 // any number or binding
+/// }
+/// ```
+///
+/// > [!NOTE] Requires at least hyprland 0.47.0, or [hyprland-surface-v1] support.
+///
+/// [hyprland-surface-v1]: https://github.com/hyprwm/hyprland-protocols/blob/main/protocols/hyprland-surface-v1.xml
+class HyprlandWindow: public QObject {
+ Q_OBJECT;
+ /// A multiplier for the window's overall opacity, ranging from 1.0 to 0.0. Overall opacity includes the opacity of
+ /// both the window content *and* visual effects such as blur that apply to it.
+ ///
+ /// Default: 1.0
+ Q_PROPERTY(qreal opacity READ opacity WRITE setOpacity NOTIFY opacityChanged);
+ QML_ELEMENT;
+ QML_UNCREATABLE("HyprlandWindow can only be used as an attached object.");
+ QML_ATTACHED(HyprlandWindow);
+
+public:
+ explicit HyprlandWindow(ProxyWindowBase* window);
+
+ [[nodiscard]] static bool available();
+
+ [[nodiscard]] qreal opacity() const;
+ void setOpacity(qreal opacity);
+
+ static HyprlandWindow* qmlAttachedProperties(QObject* object);
+
+signals:
+ void opacityChanged();
+
+private slots:
+ void onWindowConnected();
+ void onWindowVisibleChanged();
+ void onWaylandSurfaceCreated();
+ void onWaylandSurfaceDestroyed();
+ void onProxyWindowDestroyed();
+
+private:
+ void disconnectWaylandWindow();
+
+ ProxyWindowBase* proxyWindow = nullptr;
+ QWindow* mWindow = nullptr;
+ QtWaylandClient::QWaylandWindow* mWaylandWindow = nullptr;
+
+ qreal mOpacity = 1.0;
+ std::unique_ptr surface;
+};
+
+} // namespace qs::hyprland::surface
diff --git a/src/wayland/hyprland/surface/surface.cpp b/src/wayland/hyprland/surface/surface.cpp
new file mode 100644
index 00000000..d1aa24fb
--- /dev/null
+++ b/src/wayland/hyprland/surface/surface.cpp
@@ -0,0 +1,19 @@
+#include "surface.hpp"
+
+#include
+#include
+#include
+#include
+
+namespace qs::hyprland::surface::impl {
+
+HyprlandSurface::HyprlandSurface(::hyprland_surface_v1* surface)
+ : QtWayland::hyprland_surface_v1(surface) {}
+
+HyprlandSurface::~HyprlandSurface() { this->destroy(); }
+
+void HyprlandSurface::setOpacity(qreal opacity) {
+ this->set_opacity(wl_fixed_from_double(opacity));
+}
+
+} // namespace qs::hyprland::surface::impl
diff --git a/src/wayland/hyprland/surface/surface.hpp b/src/wayland/hyprland/surface/surface.hpp
new file mode 100644
index 00000000..a27e50e3
--- /dev/null
+++ b/src/wayland/hyprland/surface/surface.hpp
@@ -0,0 +1,21 @@
+#pragma once
+
+#include
+#include
+#include
+#include
+#include
+#include
+
+namespace qs::hyprland::surface::impl {
+
+class HyprlandSurface: public QtWayland::hyprland_surface_v1 {
+public:
+ explicit HyprlandSurface(::hyprland_surface_v1* surface);
+ ~HyprlandSurface() override;
+ Q_DISABLE_COPY_MOVE(HyprlandSurface);
+
+ void setOpacity(qreal opacity);
+};
+
+} // namespace qs::hyprland::surface::impl
diff --git a/src/wayland/util.cpp b/src/wayland/util.cpp
new file mode 100644
index 00000000..6bce2621
--- /dev/null
+++ b/src/wayland/util.cpp
@@ -0,0 +1,17 @@
+#include "util.hpp"
+
+#include
+#include
+
+namespace qs::wayland::util {
+
+void scheduleCommit(QtWaylandClient::QWaylandWindow* window) {
+ // This seems to be one of the less offensive ways to force Qt to send a wl_surface.commit on its own terms.
+ // Ideally we would trigger the commit more directly.
+ QWindowSystemInterface::handleExposeEvent(
+ window->window(),
+ QRect(QPoint(), window->geometry().size())
+ );
+}
+
+} // namespace qs::wayland::util
diff --git a/src/wayland/util.hpp b/src/wayland/util.hpp
new file mode 100644
index 00000000..7967fadc
--- /dev/null
+++ b/src/wayland/util.hpp
@@ -0,0 +1,9 @@
+#pragma once
+
+#include
+
+namespace qs::wayland::util {
+
+void scheduleCommit(QtWaylandClient::QWaylandWindow* window);
+
+}