From 362c8e1b693d99254c2be979641d9c1188d2629a Mon Sep 17 00:00:00 2001 From: Maeeen Date: Fri, 20 Jun 2025 04:09:37 -0700 Subject: [PATCH] hyprland/ipc: expose Hyprland toplevels --- src/wayland/hyprland/ipc/connection.cpp | 235 ++++++++++++++++++ src/wayland/hyprland/ipc/connection.hpp | 25 ++ .../hyprland/ipc/hyprland_toplevel.cpp | 155 ++++++++++-- .../hyprland/ipc/hyprland_toplevel.hpp | 95 +++++-- src/wayland/hyprland/ipc/qml.cpp | 15 ++ src/wayland/hyprland/ipc/qml.hpp | 8 + src/wayland/hyprland/ipc/workspace.cpp | 68 +++++ src/wayland/hyprland/ipc/workspace.hpp | 22 +- .../test/manual/toplevel-association.qml | 37 +++ .../hyprland/test/manual/toplevels.qml | 34 +++ .../hyprland/test/manual/workspaces.qml | 34 +++ 11 files changed, 685 insertions(+), 43 deletions(-) create mode 100644 src/wayland/hyprland/test/manual/toplevel-association.qml create mode 100644 src/wayland/hyprland/test/manual/toplevels.qml create mode 100644 src/wayland/hyprland/test/manual/workspaces.qml diff --git a/src/wayland/hyprland/ipc/connection.cpp b/src/wayland/hyprland/ipc/connection.cpp index 1c9f4ebf..90cb8a21 100644 --- a/src/wayland/hyprland/ipc/connection.cpp +++ b/src/wayland/hyprland/ipc/connection.cpp @@ -14,6 +14,8 @@ #include #include #include +#include +#include #include #include #include @@ -21,7 +23,10 @@ #include "../../../core/model.hpp" #include "../../../core/qmlscreen.hpp" +#include "../../toplevel_management/handle.hpp" +#include "hyprland_toplevel.hpp" #include "monitor.hpp" +#include "toplevel_mapping.hpp" #include "workspace.hpp" namespace qs::hyprland::ipc { @@ -62,11 +67,16 @@ HyprlandIpc::HyprlandIpc() { QObject::connect(&this->eventSocket, &QLocalSocket::errorOccurred, this, &HyprlandIpc::eventSocketError); QObject::connect(&this->eventSocket, &QLocalSocket::stateChanged, this, &HyprlandIpc::eventSocketStateChanged); QObject::connect(&this->eventSocket, &QLocalSocket::readyRead, this, &HyprlandIpc::eventSocketReady); + + auto *instance = HyprlandToplevelMappingManager::instance(); + QObject::connect(instance, &HyprlandToplevelMappingManager::toplevelAddressed, this, &HyprlandIpc::toplevelAddressed); + // clang-format on this->eventSocket.connectToServer(this->mEventSocketPath, QLocalSocket::ReadOnly); this->refreshMonitors(true); this->refreshWorkspaces(true); + this->refreshToplevels(); } QString HyprlandIpc::requestSocketPath() const { return this->mRequestSocketPath; } @@ -113,6 +123,36 @@ void HyprlandIpc::eventSocketReady() { } } +void HyprlandIpc::toplevelAddressed( + wayland::toplevel_management::impl::ToplevelHandle* handle, + quint64 address +) { + auto* waylandToplevel = + wayland::toplevel_management::ToplevelManager::instance()->forImpl(handle); + + if (!waylandToplevel) return; + + auto* attached = qobject_cast( + qmlAttachedPropertiesObject(waylandToplevel, false) + ); + + auto* hyprToplevel = this->findToplevelByAddress(address, true); + + if (attached) { + if (attached->address()) { + qCDebug(logHyprlandIpc) << "Toplevel" << attached->addressStr() << "already has address" + << address; + + return; + } + + attached->setAddress(address); + attached->setHyprlandHandle(hyprToplevel); + } + + hyprToplevel->setWaylandHandle(waylandToplevel->implHandle()); +} + void HyprlandIpc::makeRequest( const QByteArray& request, const std::function& callback @@ -166,6 +206,8 @@ ObjectModel* HyprlandIpc::monitors() { return &this->mMonitors; ObjectModel* HyprlandIpc::workspaces() { return &this->mWorkspaces; } +ObjectModel* HyprlandIpc::toplevels() { return &this->mToplevels; } + QVector HyprlandIpc::parseEventArgs(QByteArrayView event, quint16 count) { auto args = QVector(); @@ -218,6 +260,7 @@ void HyprlandIpc::onEvent(HyprlandIpcEvent* event) { if (event->name == "configreloaded") { this->refreshMonitors(true); this->refreshWorkspaces(true); + this->refreshToplevels(); } else if (event->name == "monitoraddedv2") { auto args = event->parseView(3); @@ -390,6 +433,133 @@ void HyprlandIpc::onEvent(HyprlandIpcEvent* event) { // the fullscreen state changed, but this falls apart if you move a fullscreen // window between workspaces. this->refreshWorkspaces(false); + } else if (event->name == "openwindow") { + auto args = event->parseView(4); + auto ok = false; + auto windowAddress = args.at(0).toULongLong(&ok, 16); + + if (!ok) return; + + auto workspaceName = QString::fromUtf8(args.at(1)); + auto windowTitle = QString::fromUtf8(args.at(2)); + auto windowClass = QString::fromUtf8(args.at(3)); + + auto* workspace = this->findWorkspaceByName(workspaceName, false); + if (!workspace) { + qCWarning(logHyprlandIpc) << "Got openwindow for workspace" << workspaceName + << "which was not previously tracked."; + return; + } + + auto* toplevel = this->findToplevelByAddress(windowAddress, false); + const bool existed = toplevel != nullptr; + + if (!toplevel) toplevel = new HyprlandToplevel(this); + toplevel->updateInitial(windowAddress, windowTitle, workspaceName); + + workspace->insertToplevel(toplevel); + + if (!existed) { + this->mToplevels.insertObject(toplevel); + qCDebug(logHyprlandIpc) << "New toplevel created with address" << windowAddress << ", title" + << windowTitle << ", workspace" << workspaceName; + } + } else if (event->name == "closewindow") { + auto args = event->parseView(1); + auto ok = false; + auto windowAddress = args.at(0).toULongLong(&ok, 16); + + if (!ok) return; + + const auto& mList = this->mToplevels.valueList(); + auto toplevelIter = std::ranges::find_if(mList, [windowAddress](HyprlandToplevel* m) { + return m->address() == windowAddress; + }); + + if (toplevelIter == mList.end()) { + qCWarning(logHyprlandIpc) << "Got closewindow for address" << windowAddress + << "which was not previously tracked."; + return; + } + + auto* toplevel = *toplevelIter; + auto index = toplevelIter - mList.begin(); + this->mToplevels.removeAt(index); + + // Remove from workspace + auto* workspace = toplevel->bindableWorkspace().value(); + if (workspace) { + workspace->toplevels()->removeObject(toplevel); + } + + delete toplevel; + } else if (event->name == "movewindowv2") { + auto args = event->parseView(3); + auto ok = false; + auto windowAddress = args.at(0).toULongLong(&ok, 16); + auto workspaceName = QString::fromUtf8(args.at(2)); + + auto* toplevel = this->findToplevelByAddress(windowAddress, false); + if (!toplevel) { + qCWarning(logHyprlandIpc) << "Got movewindowv2 event for client with address" << windowAddress + << "which was not previously tracked."; + return; + } + + HyprlandWorkspace* workspace = this->findWorkspaceByName(workspaceName, false); + if (!workspace) { + qCWarning(logHyprlandIpc) << "Got movewindowv2 event for workspace" << args.at(2) + << "which was not previously tracked."; + return; + } + + auto* oldWorkspace = toplevel->bindableWorkspace().value(); + toplevel->setWorkspace(workspace); + + if (oldWorkspace) { + oldWorkspace->removeToplevel(toplevel); + } + + workspace->insertToplevel(toplevel); + } else if (event->name == "windowtitlev2") { + auto args = event->parseView(2); + auto ok = false; + auto windowAddress = args.at(0).toULongLong(&ok, 16); + auto windowTitle = QString::fromUtf8(args.at(1)); + + if (!ok) return; + + // It happens that Hyprland sends windowtitlev2 events before event + // "openwindow" is emitted, so let's preemptively create it + auto* toplevel = this->findToplevelByAddress(windowAddress, true); + if (!toplevel) { + qCWarning(logHyprlandIpc) << "Got windowtitlev2 event for client with address" + << windowAddress << "which was not previously tracked."; + return; + } + + toplevel->bindableTitle().setValue(windowTitle); + } else if (event->name == "activewindowv2") { + auto args = event->parseView(1); + auto ok = false; + auto windowAddress = args.at(0).toULongLong(&ok, 16); + + if (!ok) return; + + // Did not observe "activewindowv2" event before "openwindow", + // but better safe than sorry, so create if missing. + auto* toplevel = this->findToplevelByAddress(windowAddress, true); + this->bActiveToplevel = toplevel; + } else if (event->name == "urgent") { + auto args = event->parseView(1); + auto ok = false; + auto windowAddress = args.at(0).toULongLong(&ok, 16); + + if (!ok) return; + + // It happens that Hyprland sends urgent before "openwindow" + auto* toplevel = this->findToplevelByAddress(windowAddress, true); + toplevel->bindableUrgent().setValue(true); } } @@ -496,6 +666,71 @@ void HyprlandIpc::refreshWorkspaces(bool canCreate) { }); } +HyprlandToplevel* HyprlandIpc::findToplevelByAddress(quint64 address, bool createIfMissing) { + const auto& mList = this->mToplevels.valueList(); + HyprlandToplevel* toplevel = nullptr; + + auto toplevelIter = + std::ranges::find_if(mList, [&](HyprlandToplevel* m) { return m->address() == address; }); + + toplevel = toplevelIter == mList.end() ? nullptr : *toplevelIter; + + if (!toplevel && createIfMissing) { + qCDebug(logHyprlandIpc) << "Toplevel with address" << address + << "requested before creation, performing early init"; + + toplevel = new HyprlandToplevel(this); + toplevel->updateInitial(address, "", ""); + this->mToplevels.insertObject(toplevel); + } + + return toplevel; +} + +void HyprlandIpc::refreshToplevels() { + if (this->requestingToplevels) return; + this->requestingToplevels = true; + + this->makeRequest("j/clients", [this](bool success, const QByteArray& resp) { + this->requestingToplevels = false; + if (!success) return; + + qCDebug(logHyprlandIpc) << "Parsing j/clients response"; + auto json = QJsonDocument::fromJson(resp).array(); + + const auto& mList = this->mToplevels.valueList(); + + for (auto entry: json) { + auto object = entry.toObject().toVariantMap(); + + bool ok = false; + auto address = object.value("address").toString().toULongLong(&ok, 16); + + if (!ok) { + qCWarning(logHyprlandIpc) << "Invalid address in j/clients entry:" << object; + continue; + } + + auto toplevelsIter = + std::ranges::find_if(mList, [&](HyprlandToplevel* m) { return m->address() == address; }); + + auto* toplevel = toplevelsIter == mList.end() ? nullptr : *toplevelsIter; + auto exists = toplevel != nullptr; + + if (!exists) toplevel = new HyprlandToplevel(this); + toplevel->updateFromObject(object); + + if (!exists) { + qCDebug(logHyprlandIpc) << "New toplevel created with address" << address; + this->mToplevels.insertObject(toplevel); + } + + auto* workspace = toplevel->bindableWorkspace().value(); + workspace->insertToplevel(toplevel); + } + }); +} + HyprlandMonitor* HyprlandIpc::findMonitorByName(const QString& name, bool createIfMissing, qint32 id) { const auto& mList = this->mMonitors.valueList(); diff --git a/src/wayland/hyprland/ipc/connection.hpp b/src/wayland/hyprland/ipc/connection.hpp index 5a5783f8..e15d5cd7 100644 --- a/src/wayland/hyprland/ipc/connection.hpp +++ b/src/wayland/hyprland/ipc/connection.hpp @@ -14,16 +14,19 @@ #include "../../../core/model.hpp" #include "../../../core/qmlscreen.hpp" +#include "../../../wayland/toplevel_management/handle.hpp" namespace qs::hyprland::ipc { class HyprlandMonitor; class HyprlandWorkspace; +class HyprlandToplevel; } // namespace qs::hyprland::ipc Q_DECLARE_OPAQUE_POINTER(qs::hyprland::ipc::HyprlandWorkspace*); Q_DECLARE_OPAQUE_POINTER(qs::hyprland::ipc::HyprlandMonitor*); +Q_DECLARE_OPAQUE_POINTER(qs::hyprland::ipc::HyprlandToplevel*); namespace qs::hyprland::ipc { @@ -85,18 +88,25 @@ public: return &this->bFocusedWorkspace; } + [[nodiscard]] QBindable bindableActiveToplevel() const { + return &this->bActiveToplevel; + } + void setFocusedMonitor(HyprlandMonitor* monitor); [[nodiscard]] ObjectModel* monitors(); [[nodiscard]] ObjectModel* workspaces(); + [[nodiscard]] ObjectModel* toplevels(); // No byId because these preemptively create objects. The given id is set if created. HyprlandWorkspace* findWorkspaceByName(const QString& name, bool createIfMissing, qint32 id = -1); HyprlandMonitor* findMonitorByName(const QString& name, bool createIfMissing, qint32 id = -1); + HyprlandToplevel* findToplevelByAddress(quint64 address, bool createIfMissing); // canCreate avoids making ghost workspaces when the connection races void refreshWorkspaces(bool canCreate); void refreshMonitors(bool canCreate); + void refreshToplevels(); // The last argument may contain commas, so the count is required. [[nodiscard]] static QVector parseEventArgs(QByteArrayView event, quint16 count); @@ -107,12 +117,18 @@ signals: void focusedMonitorChanged(); void focusedWorkspaceChanged(); + void activeToplevelChanged(); private slots: void eventSocketError(QLocalSocket::LocalSocketError error) const; void eventSocketStateChanged(QLocalSocket::LocalSocketState state); void eventSocketReady(); + void toplevelAddressed( + qs::wayland::toplevel_management::impl::ToplevelHandle* handle, + quint64 address + ); + void onFocusedMonitorDestroyed(); private: @@ -128,10 +144,12 @@ private: bool valid = false; bool requestingMonitors = false; bool requestingWorkspaces = false; + bool requestingToplevels = false; bool monitorsRequested = false; ObjectModel mMonitors {this}; ObjectModel mWorkspaces {this}; + ObjectModel mToplevels {this}; HyprlandIpcEvent event {this}; @@ -148,6 +166,13 @@ private: bFocusedWorkspace, &HyprlandIpc::focusedWorkspaceChanged ); + + Q_OBJECT_BINDABLE_PROPERTY( + HyprlandIpc, + HyprlandToplevel*, + bActiveToplevel, + &HyprlandIpc::activeToplevelChanged + ); }; } // namespace qs::hyprland::ipc diff --git a/src/wayland/hyprland/ipc/hyprland_toplevel.cpp b/src/wayland/hyprland/ipc/hyprland_toplevel.cpp index 93c924c6..59ed17eb 100644 --- a/src/wayland/hyprland/ipc/hyprland_toplevel.cpp +++ b/src/wayland/hyprland/ipc/hyprland_toplevel.cpp @@ -2,50 +2,159 @@ #include #include +#include #include #include -#include "toplevel_mapping.hpp" -#include "../../toplevel_management/handle.hpp" #include "../../toplevel_management/qml.hpp" +#include "connection.hpp" +#include "toplevel_mapping.hpp" +#include "workspace.hpp" using namespace qs::wayland::toplevel_management; -using namespace qs::wayland::toplevel_management::impl; namespace qs::hyprland::ipc { -HyprlandToplevel::HyprlandToplevel(Toplevel* toplevel) - : QObject(toplevel) - , handle(toplevel->implHandle()) { - auto* instance = HyprlandToplevelMappingManager::instance(); - auto addr = instance->getToplevelAddress(handle); +HyprlandToplevel::HyprlandToplevel(HyprlandIpc* ipc): QObject(ipc), ipc(ipc) { + this->bMonitor.setBinding([this]() { + return this->bWorkspace ? this->bWorkspace->bindableMonitor().value() : nullptr; + }); - if (addr != 0) this->setAddress(addr); - else { - QObject::connect( - instance, - &HyprlandToplevelMappingManager::toplevelAddressed, - this, - &HyprlandToplevel::onToplevelAddressed - ); - } + this->bActivated.setBinding([this]() { + return this->ipc->bindableActiveToplevel().value() == this; + }); + + QObject::connect( + this, + &HyprlandToplevel::activatedChanged, + this, + &HyprlandToplevel::onActivatedChanged + ); } -void HyprlandToplevel::onToplevelAddressed(ToplevelHandle* handle, quint64 address) { - if (handle == this->handle) { - this->setAddress(address); - QObject::disconnect(HyprlandToplevelMappingManager::instance(), nullptr, this, nullptr); +HyprlandToplevel::HyprlandToplevel(HyprlandIpc* ipc, Toplevel* toplevel): HyprlandToplevel(ipc) { + this->mWaylandHandle = toplevel->implHandle(); + auto* instance = HyprlandToplevelMappingManager::instance(); + auto addr = instance->getToplevelAddress(this->mWaylandHandle); + + if (!addr) { + // Address not available, will rely on HyprlandIpc to resolve it. + return; + } + + this->setAddress(addr); + + // Check if client is present in HyprlandIPC + auto* hyprToplevel = ipc->findToplevelByAddress(addr, false); + // HyprlandIpc will eventually resolve it + if (!hyprToplevel) return; + + this->setHyprlandHandle(hyprToplevel); +} + +void HyprlandToplevel::updateInitial( + quint64 address, + const QString& title, + const QString& workspaceName +) { + auto* workspace = this->ipc->findWorkspaceByName(workspaceName, false); + Qt::beginPropertyUpdateGroup(); + this->setAddress(address); + this->bTitle = title; + this->setWorkspace(workspace); + Qt::endPropertyUpdateGroup(); +} + +void HyprlandToplevel::updateFromObject(const QVariantMap& object) { + auto addressStr = object.value("address").value(); + auto title = object.value("title").value(); + + bool ok = false; + auto address = addressStr.toULongLong(&ok, 16); + if (!ok || !address) { + return; + } + + this->setAddress(address); + this->bTitle = title; + + auto workspaceMap = object.value("workspace").toMap(); + auto workspaceName = workspaceMap.value("name").toString(); + + auto* workspace = this->ipc->findWorkspaceByName(workspaceName, false); + if (!workspace) return; + + this->setWorkspace(workspace); +} + +void HyprlandToplevel::setWorkspace(HyprlandWorkspace* workspace) { + auto* oldWorkspace = this->bWorkspace.value(); + if (oldWorkspace == workspace) return; + + if (oldWorkspace) { + QObject::disconnect(oldWorkspace, nullptr, this, nullptr); + } + + this->bWorkspace = workspace; + + if (workspace) { + QObject::connect(workspace, &QObject::destroyed, this, [this]() { + this->bWorkspace = nullptr; + }); } } void HyprlandToplevel::setAddress(quint64 address) { - this->mAddress = QString::number(address, 16); + this->mAddress = address; emit this->addressChanged(); } +Toplevel* HyprlandToplevel::waylandHandle() { + return ToplevelManager::instance()->forImpl(this->mWaylandHandle); +} + +void HyprlandToplevel::setWaylandHandle(impl::ToplevelHandle* handle) { + if (this->mWaylandHandle == handle) return; + if (this->mWaylandHandle) { + QObject::disconnect(this->mWaylandHandle, nullptr, this, nullptr); + } + + this->mWaylandHandle = handle; + if (handle) { + QObject::connect(handle, &QObject::destroyed, this, [this]() { + this->mWaylandHandle = nullptr; + }); + } + + emit this->waylandHandleChanged(); +} + +void HyprlandToplevel::setHyprlandHandle(HyprlandToplevel* handle) { + if (this->mHyprlandHandle == handle) return; + if (this->mHyprlandHandle) { + QObject::disconnect(this->mHyprlandHandle, nullptr, this, nullptr); + } + this->mHyprlandHandle = handle; + if (handle) { + QObject::connect(handle, &QObject::destroyed, this, [this]() { + this->mHyprlandHandle = nullptr; + }); + } + + emit this->hyprlandHandleChanged(); +} + +void HyprlandToplevel::onActivatedChanged() { + if (this->bUrgent.value()) { + // If was urgent, and now active, clear urgent state + this->bUrgent = false; + } +} + HyprlandToplevel* HyprlandToplevel::qmlAttachedProperties(QObject* object) { if (auto* toplevel = qobject_cast(object)) { - return new HyprlandToplevel(toplevel); + auto* ipc = HyprlandIpc::instance(); + return new HyprlandToplevel(ipc, toplevel); } else { return nullptr; } diff --git a/src/wayland/hyprland/ipc/hyprland_toplevel.hpp b/src/wayland/hyprland/ipc/hyprland_toplevel.hpp index 2cc70a5a..4d61bef8 100644 --- a/src/wayland/hyprland/ipc/hyprland_toplevel.hpp +++ b/src/wayland/hyprland/ipc/hyprland_toplevel.hpp @@ -2,49 +2,108 @@ #include #include +#include #include #include #include #include "../../toplevel_management/handle.hpp" #include "../../toplevel_management/qml.hpp" +#include "connection.hpp" namespace qs::hyprland::ipc { -//! Exposes Hyprland window address for a Toplevel -/// Attached object of @@Quickshell.Wayland.Toplevel which exposes -/// a Hyprland window address for the window. +//! Hyprland Toplevel +/// Represents a window as Hyprland exposes it. +/// Can also be used as an attached object of a @@Quickshell.Wayland.Toplevel, +/// to resolve a handle to an Hyprland toplevel. class HyprlandToplevel: public QObject { Q_OBJECT; QML_ELEMENT; QML_UNCREATABLE(""); QML_ATTACHED(HyprlandToplevel); + // clang-format off /// Hexadecimal Hyprland window address. Will be an empty string until /// the address is reported. - Q_PROPERTY(QString address READ address NOTIFY addressChanged); + Q_PROPERTY(QString address READ addressStr NOTIFY addressChanged); + /// The toplevel handle, exposing the Hyprland toplevel. + /// Will be null until the address is reported + Q_PROPERTY(HyprlandToplevel* handle READ hyprlandHandle NOTIFY hyprlandHandleChanged); + /// The wayland toplevel handle. Will be null intil the address is reported + Q_PROPERTY(qs::wayland::toplevel_management::Toplevel* wayland READ waylandHandle NOTIFY waylandHandleChanged); + /// The title of the toplevel + Q_PROPERTY(QString title READ default NOTIFY titleChanged BINDABLE bindableTitle); + /// Whether the toplevel is active or not + Q_PROPERTY(bool activated READ default NOTIFY activatedChanged BINDABLE bindableActivated); + /// Whether the client is urgent or not + Q_PROPERTY(bool urgent READ default NOTIFY urgentChanged BINDABLE bindableUrgent); + /// The current workspace of the toplevel (might be null) + Q_PROPERTY(qs::hyprland::ipc::HyprlandWorkspace* workspace READ default NOTIFY workspaceChanged BINDABLE bindableWorkspace); + /// The current monitor of the toplevel (might be null) + Q_PROPERTY(qs::hyprland::ipc::HyprlandMonitor* monitor READ default NOTIFY monitorChanged BINDABLE bindableMonitor); + // clang-format on public: - explicit HyprlandToplevel(qs::wayland::toplevel_management::Toplevel* toplevel); - - [[nodiscard]] QString address() { return this->mAddress; } + /// When invoked from HyprlandIpc, reacting to Hyprland's IPC events. + explicit HyprlandToplevel(HyprlandIpc* ipc); + /// When attached from a Toplevel + explicit HyprlandToplevel(HyprlandIpc* ipc, qs::wayland::toplevel_management::Toplevel* toplevel); static HyprlandToplevel* qmlAttachedProperties(QObject* object); -signals: - void addressChanged(); + void updateInitial(quint64 address, const QString& title, const QString& workspaceName); -private slots: - void onToplevelAddressed( - qs::wayland::toplevel_management::impl::ToplevelHandle* handle, - quint64 address - ); + void updateFromObject(const QVariantMap& object); -private: + [[nodiscard]] QString addressStr() const { return QString::number(this->mAddress, 16); } + [[nodiscard]] quint64 address() const { return this->mAddress; } void setAddress(quint64 address); - QString mAddress; - // doesn't have to be nulled on destroy, only used for comparison - qs::wayland::toplevel_management::impl::ToplevelHandle* handle; + // clang-format off + [[nodiscard]] HyprlandToplevel* hyprlandHandle() { return this->mHyprlandHandle; } + void setHyprlandHandle(HyprlandToplevel* handle); + + [[nodiscard]] wayland::toplevel_management::Toplevel* waylandHandle(); + void setWaylandHandle(wayland::toplevel_management::impl::ToplevelHandle* handle); + // clang-format on + + [[nodiscard]] QBindable bindableTitle() { return &this->bTitle; } + [[nodiscard]] QBindable bindableActivated() { return &this->bActivated; } + [[nodiscard]] QBindable bindableUrgent() { return &this->bUrgent; } + + [[nodiscard]] QBindable bindableWorkspace() { return &this->bWorkspace; } + void setWorkspace(HyprlandWorkspace* workspace); + + [[nodiscard]] QBindable bindableMonitor() { return &this->bMonitor; } + +signals: + void addressChanged(); + QSDOC_HIDE void waylandHandleChanged(); + QSDOC_HIDE void hyprlandHandleChanged(); + + void titleChanged(); + void activatedChanged(); + void urgentChanged(); + void workspaceChanged(); + void monitorChanged(); + +private slots: + void onActivatedChanged(); + +private: + quint64 mAddress = 0; + HyprlandIpc* ipc; + + qs::wayland::toplevel_management::impl::ToplevelHandle* mWaylandHandle = nullptr; + HyprlandToplevel* mHyprlandHandle = nullptr; + + // clang-format off + Q_OBJECT_BINDABLE_PROPERTY(HyprlandToplevel, QString, bTitle, &HyprlandToplevel::titleChanged); + Q_OBJECT_BINDABLE_PROPERTY(HyprlandToplevel, bool, bActivated, &HyprlandToplevel::activatedChanged); + Q_OBJECT_BINDABLE_PROPERTY(HyprlandToplevel, bool, bUrgent, &HyprlandToplevel::urgentChanged); + Q_OBJECT_BINDABLE_PROPERTY(HyprlandToplevel, HyprlandWorkspace*, bWorkspace, &HyprlandToplevel::workspaceChanged); + Q_OBJECT_BINDABLE_PROPERTY(HyprlandToplevel, HyprlandMonitor*, bMonitor, &HyprlandToplevel::monitorChanged); + // clang-format on }; } // namespace qs::hyprland::ipc diff --git a/src/wayland/hyprland/ipc/qml.cpp b/src/wayland/hyprland/ipc/qml.cpp index a497ab35..3694c97c 100644 --- a/src/wayland/hyprland/ipc/qml.cpp +++ b/src/wayland/hyprland/ipc/qml.cpp @@ -28,6 +28,13 @@ HyprlandIpcQml::HyprlandIpcQml() { this, &HyprlandIpcQml::focusedMonitorChanged ); + + QObject::connect( + instance, + &HyprlandIpc::activeToplevelChanged, + this, + &HyprlandIpcQml::activeToplevelChanged + ); } void HyprlandIpcQml::dispatch(const QString& request) { @@ -51,6 +58,10 @@ QBindable HyprlandIpcQml::bindableFocusedWorkspace() { return HyprlandIpc::instance()->bindableFocusedWorkspace(); } +QBindable HyprlandIpcQml::bindableActiveToplevel() { + return HyprlandIpc::instance()->bindableActiveToplevel(); +} + ObjectModel* HyprlandIpcQml::monitors() { return HyprlandIpc::instance()->monitors(); } @@ -59,4 +70,8 @@ ObjectModel* HyprlandIpcQml::workspaces() { return HyprlandIpc::instance()->workspaces(); } +ObjectModel* HyprlandIpcQml::toplevels() { + return HyprlandIpc::instance()->toplevels(); +} + } // namespace qs::hyprland::ipc diff --git a/src/wayland/hyprland/ipc/qml.hpp b/src/wayland/hyprland/ipc/qml.hpp index f776ef6a..fce50cc1 100644 --- a/src/wayland/hyprland/ipc/qml.hpp +++ b/src/wayland/hyprland/ipc/qml.hpp @@ -24,6 +24,8 @@ class HyprlandIpcQml: public QObject { Q_PROPERTY(qs::hyprland::ipc::HyprlandMonitor* focusedMonitor READ default NOTIFY focusedMonitorChanged BINDABLE bindableFocusedMonitor); /// The currently focused hyprland workspace. May be null. Q_PROPERTY(qs::hyprland::ipc::HyprlandWorkspace* focusedWorkspace READ default NOTIFY focusedWorkspaceChanged BINDABLE bindableFocusedWorkspace); + /// Currently active toplevel (might be null) + Q_PROPERTY(qs::hyprland::ipc::HyprlandToplevel* activeToplevel READ default NOTIFY activeToplevelChanged BINDABLE bindableActiveToplevel); /// All hyprland monitors. QSDOC_TYPE_OVERRIDE(ObjectModel*); Q_PROPERTY(UntypedObjectModel* monitors READ monitors CONSTANT); @@ -32,6 +34,9 @@ class HyprlandIpcQml: public QObject { /// > [!NOTE] Named workspaces have a negative id, and will appear before unnamed workspaces. QSDOC_TYPE_OVERRIDE(ObjectModel*); Q_PROPERTY(UntypedObjectModel* workspaces READ workspaces CONSTANT); + /// All hyprland toplevels + QSDOC_TYPE_OVERRIDE(ObjectModel*); + Q_PROPERTY(UntypedObjectModel* toplevels READ toplevels CONSTANT); // clang-format on QML_NAMED_ELEMENT(Hyprland); QML_SINGLETON; @@ -61,8 +66,10 @@ public: [[nodiscard]] static QString eventSocketPath(); [[nodiscard]] static QBindable bindableFocusedMonitor(); [[nodiscard]] static QBindable bindableFocusedWorkspace(); + [[nodiscard]] static QBindable bindableActiveToplevel(); [[nodiscard]] static ObjectModel* monitors(); [[nodiscard]] static ObjectModel* workspaces(); + [[nodiscard]] static ObjectModel* toplevels(); signals: /// Emitted for every event that comes in through the hyprland event socket (socket2). @@ -72,6 +79,7 @@ signals: void focusedMonitorChanged(); void focusedWorkspaceChanged(); + void activeToplevelChanged(); }; } // namespace qs::hyprland::ipc diff --git a/src/wayland/hyprland/ipc/workspace.cpp b/src/wayland/hyprland/ipc/workspace.cpp index bc0070d7..d16c821c 100644 --- a/src/wayland/hyprland/ipc/workspace.cpp +++ b/src/wayland/hyprland/ipc/workspace.cpp @@ -1,4 +1,5 @@ #include "workspace.hpp" +#include #include #include @@ -25,6 +26,12 @@ HyprlandWorkspace::HyprlandWorkspace(HyprlandIpc* ipc): QObject(ipc), ipc(ipc) { return this->ipc->bindableFocusedWorkspace().value() == this; }); + QObject::connect(this, &HyprlandWorkspace::focusedChanged, this, [this]() { + if (this->bFocused.value()) { + this->updateUrgent(); + } + }); + Qt::endPropertyUpdateGroup(); } @@ -82,6 +89,67 @@ void HyprlandWorkspace::setMonitor(HyprlandMonitor* monitor) { void HyprlandWorkspace::onMonitorDestroyed() { this->bMonitor = nullptr; } +void HyprlandWorkspace::insertToplevel(HyprlandToplevel* toplevel) { + if (!toplevel) return; + + const auto& mList = this->mToplevels.valueList(); + + if (std::ranges::find(mList, toplevel) != mList.end()) { + return; + } + + this->mToplevels.insertObject(toplevel); + + QObject::connect(toplevel, &QObject::destroyed, this, [this, toplevel]() { + this->removeToplevel(toplevel); + }); + + QObject::connect( + toplevel, + &HyprlandToplevel::urgentChanged, + this, + &HyprlandWorkspace::updateUrgent + ); + + this->updateUrgent(); +} + +void HyprlandWorkspace::removeToplevel(HyprlandToplevel* toplevel) { + if (!toplevel) return; + + this->mToplevels.removeObject(toplevel); + emit this->updateUrgent(); + QObject::disconnect(toplevel, nullptr, this, nullptr); +} + +// Triggered when there is an update either on the toplevel list, on a toplevel's urgent state +void HyprlandWorkspace::updateUrgent() { + const auto& mList = this->mToplevels.valueList(); + + const bool hasUrgentToplevel = std::ranges::any_of(mList, [&](HyprlandToplevel* toplevel) { + return toplevel->bindableUrgent().value(); + }); + + if (this->bFocused && hasUrgentToplevel) { + this->clearUrgent(); + return; + } + + if (hasUrgentToplevel != this->bUrgent.value()) { + this->bUrgent = hasUrgentToplevel; + } +} + +void HyprlandWorkspace::clearUrgent() { + this->bUrgent = false; + + // Clear all urgent toplevels + const auto& mList = this->mToplevels.valueList(); + for (auto* toplevel: mList) { + toplevel->bindableUrgent().setValue(false); + } +} + void HyprlandWorkspace::activate() { this->ipc->dispatch(QString("workspace %1").arg(this->bId.value())); } diff --git a/src/wayland/hyprland/ipc/workspace.hpp b/src/wayland/hyprland/ipc/workspace.hpp index 3493c5f1..957639a7 100644 --- a/src/wayland/hyprland/ipc/workspace.hpp +++ b/src/wayland/hyprland/ipc/workspace.hpp @@ -9,6 +9,7 @@ #include #include "connection.hpp" +#include "hyprland_toplevel.hpp" namespace qs::hyprland::ipc { @@ -24,8 +25,11 @@ class HyprlandWorkspace: public QObject { /// If this workspace is currently active on a monitor and that monitor is currently /// focused. See also @@active. Q_PROPERTY(bool focused READ default NOTIFY focusedChanged BINDABLE bindableFocused); + /// If this workspace has a window that is urgent. + /// Becomes always falsed after the workspace is @@focused. + Q_PROPERTY(bool urgent READ default NOTIFY urgentChanged BINDABLE bindableUrgent); /// If this workspace currently has a fullscreen client. - Q_PROPERTY(bool hasFullscreen READ default NOTIFY focusedChanged BINDABLE bindableHasFullscreen); + Q_PROPERTY(bool hasFullscreen READ default NOTIFY hasFullscreenChanged BINDABLE bindableHasFullscreen); /// Last json returned for this workspace, as a javascript object. /// /// > [!WARNING] This is *not* updated unless the workspace object is fetched again from @@ -33,6 +37,9 @@ class HyprlandWorkspace: public QObject { /// > property, run @@Hyprland.refreshWorkspaces() and wait for this property to update. Q_PROPERTY(QVariantMap lastIpcObject READ lastIpcObject NOTIFY lastIpcObjectChanged); Q_PROPERTY(qs::hyprland::ipc::HyprlandMonitor* monitor READ default NOTIFY monitorChanged BINDABLE bindableMonitor); + /// List of toplevels on this workspace. + QSDOC_TYPE_OVERRIDE(ObjectModel bindableName() { return &this->bName; } [[nodiscard]] QBindable bindableActive() { return &this->bActive; } [[nodiscard]] QBindable bindableFocused() { return &this->bFocused; } + [[nodiscard]] QBindable bindableUrgent() { return &this->bUrgent; } [[nodiscard]] QBindable bindableHasFullscreen() { return &this->bHasFullscreen; } [[nodiscard]] QBindable bindableMonitor() { return &this->bMonitor; } + [[nodiscard]] ObjectModel* toplevels() { return &this->mToplevels; } [[nodiscard]] QVariantMap lastIpcObject() const; void setMonitor(HyprlandMonitor* monitor); + void insertToplevel(HyprlandToplevel* toplevel); + void removeToplevel(HyprlandToplevel* toplevel); + signals: void idChanged(); void nameChanged(); void activeChanged(); void focusedChanged(); + void urgentChanged(); void hasFullscreenChanged(); void lastIpcObjectChanged(); void monitorChanged(); private slots: void onMonitorDestroyed(); + void updateUrgent(); private: - HyprlandIpc* ipc; + void clearUrgent(); + HyprlandIpc* ipc; QVariantMap mLastIpcObject; + ObjectModel mToplevels {this}; + // clang-format off Q_OBJECT_BINDABLE_PROPERTY_WITH_ARGS(HyprlandWorkspace, qint32, bId, -1, &HyprlandWorkspace::idChanged); Q_OBJECT_BINDABLE_PROPERTY(HyprlandWorkspace, QString, bName, &HyprlandWorkspace::nameChanged); Q_OBJECT_BINDABLE_PROPERTY(HyprlandWorkspace, bool, bActive, &HyprlandWorkspace::activeChanged); Q_OBJECT_BINDABLE_PROPERTY(HyprlandWorkspace, bool, bFocused, &HyprlandWorkspace::focusedChanged); + Q_OBJECT_BINDABLE_PROPERTY(HyprlandWorkspace, bool, bUrgent, &HyprlandWorkspace::urgentChanged); Q_OBJECT_BINDABLE_PROPERTY(HyprlandWorkspace, bool, bHasFullscreen, &HyprlandWorkspace::hasFullscreenChanged); Q_OBJECT_BINDABLE_PROPERTY(HyprlandWorkspace, HyprlandMonitor*, bMonitor, &HyprlandWorkspace::monitorChanged); // clang-format on diff --git a/src/wayland/hyprland/test/manual/toplevel-association.qml b/src/wayland/hyprland/test/manual/toplevel-association.qml new file mode 100644 index 00000000..042b915a --- /dev/null +++ b/src/wayland/hyprland/test/manual/toplevel-association.qml @@ -0,0 +1,37 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Hyprland +import Quickshell.Wayland + +FloatingWindow { + ColumnLayout { + anchors.fill: parent + + Text { text: "Hyprland -> Wayland" } + + ListView { + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + model: Hyprland.toplevels + delegate: Text { + required property HyprlandToplevel modelData + text: `${modelData} -> ${modelData.wayland}` + } + } + + Text { text: "Wayland -> Hyprland" } + + ListView { + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + model: ToplevelManager.toplevels + delegate: Text { + required property Toplevel modelData + text: `${modelData} -> ${modelData.HyprlandToplevel.handle}` + } + } + } +} diff --git a/src/wayland/hyprland/test/manual/toplevels.qml b/src/wayland/hyprland/test/manual/toplevels.qml new file mode 100644 index 00000000..da54e5ce --- /dev/null +++ b/src/wayland/hyprland/test/manual/toplevels.qml @@ -0,0 +1,34 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Hyprland + +FloatingWindow { + ColumnLayout { + anchors.fill: parent + + Text { text: "Current toplevel:" } + + ToplevelFromHyprland { + modelData: Hyprland.activeToplevel + } + + Text { text: "\nAll toplevels:" } + + ListView { + Layout.fillHeight: true + Layout.fillWidth: true + clip: true + model: Hyprland.toplevels + delegate: ToplevelFromHyprland {} + } + } + + component ToplevelFromHyprland: ColumnLayout { + required property HyprlandToplevel modelData + + Text { + text: `Window 0x${modelData.address}, title: ${modelData.title}, activated: ${modelData.activated}, workspace id: ${modelData.workspace.id}, monitor name: ${modelData.monitor.name}, urgent: ${modelData.urgent}` + } + } +} diff --git a/src/wayland/hyprland/test/manual/workspaces.qml b/src/wayland/hyprland/test/manual/workspaces.qml new file mode 100644 index 00000000..ef1bafe2 --- /dev/null +++ b/src/wayland/hyprland/test/manual/workspaces.qml @@ -0,0 +1,34 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Widgets +import Quickshell.Hyprland + +FloatingWindow { + ListView { + anchors.fill: parent + model: Hyprland.workspaces + spacing: 5 + + delegate: WrapperRectangle { + id: wsDelegate + required property HyprlandWorkspace modelData + color: "lightgray" + + ColumnLayout { + Text { text: `Workspace ${wsDelegate.modelData.id} on ${wsDelegate.modelData.monitor} | urgent: ${wsDelegate.modelData.urgent}`} + + ColumnLayout { + Repeater { + model: wsDelegate.modelData.toplevels + Text { + id: tDelegate + required property HyprlandToplevel modelData; + text: `${tDelegate.modelData}: ${tDelegate.modelData.title}` + } + } + } + } + } + } +}