Compare commits

...

4 commits

45 changed files with 3655 additions and 5 deletions

View file

@ -36,6 +36,7 @@ Checks: >
-readability-braces-around-statements, -readability-braces-around-statements,
-readability-redundant-access-specifiers, -readability-redundant-access-specifiers,
-readability-else-after-return, -readability-else-after-return,
-readability-container-data-pointer,
tidyfox-*, tidyfox-*,
CheckOptions: CheckOptions:
performance-for-range-copy.WarnOnAllAutoCopies: true performance-for-range-copy.WarnOnAllAutoCopies: true

View file

@ -14,7 +14,11 @@ option(SOCKETS "Enable unix socket support" ON)
option(WAYLAND "Enable wayland support" ON) option(WAYLAND "Enable wayland support" ON)
option(WAYLAND_WLR_LAYERSHELL "Support the zwlr_layer_shell_v1 wayland protocol" ON) option(WAYLAND_WLR_LAYERSHELL "Support the zwlr_layer_shell_v1 wayland protocol" ON)
option(WAYLAND_SESSION_LOCK "Support the ext_session_lock_v1 wayland protocol" ON) option(WAYLAND_SESSION_LOCK "Support the ext_session_lock_v1 wayland protocol" ON)
option(HYPRLAND "Support hyprland specific features" ON)
option(HYPRLAND_GLOBAL_SHORTCUTS "Hyprland Global Shortcuts" ON)
option(HYPRLAND_FOCUS_GRAB "Hyprland Focus Grabbing" ON)
option(SERVICE_STATUS_NOTIFIER "StatusNotifierItem service" ON) option(SERVICE_STATUS_NOTIFIER "StatusNotifierItem service" ON)
option(SERVICE_PIPEWIRE "PipeWire service" ON)
message(STATUS "Quickshell configuration") message(STATUS "Quickshell configuration")
message(STATUS " NVIDIA workarounds: ${NVIDIA_COMPAT}") message(STATUS " NVIDIA workarounds: ${NVIDIA_COMPAT}")
@ -27,6 +31,12 @@ if (WAYLAND)
endif () endif ()
message(STATUS " Services") message(STATUS " Services")
message(STATUS " StatusNotifier: ${SERVICE_STATUS_NOTIFIER}") message(STATUS " StatusNotifier: ${SERVICE_STATUS_NOTIFIER}")
message(STATUS " PipeWire: ${SERVICE_PIPEWIRE}")
message(STATUS " Hyprland: ${HYPRLAND}")
if (HYPRLAND)
message(STATUS " Focus Grabbing: ${HYPRLAND_FOCUS_GRAB}")
message(STATUS " Global Shortcuts: ${HYPRLAND_GLOBAL_SHORTCUTS}")
endif()
if (NOT DEFINED GIT_REVISION) if (NOT DEFINED GIT_REVISION)
execute_process( execute_process(

View file

@ -24,6 +24,7 @@
debug ? false, debug ? false,
enableWayland ? true, enableWayland ? true,
enablePipewire ? true,
nvidiaCompat ? false, nvidiaCompat ? false,
svgSupport ? true, # you almost always want this svgSupport ? true, # you almost always want this
}: buildStdenv.mkDerivation { }: buildStdenv.mkDerivation {
@ -46,7 +47,8 @@
qt6.qtdeclarative qt6.qtdeclarative
] ]
++ (lib.optionals enableWayland [ qt6.qtwayland wayland ]) ++ (lib.optionals enableWayland [ qt6.qtwayland wayland ])
++ (lib.optionals svgSupport [ qt6.qtsvg ]); ++ (lib.optionals svgSupport [ qt6.qtsvg ])
++ (lib.optionals enablePipewire [ pipewire ]);
QTWAYLANDSCANNER = lib.optionalString enableWayland "${qt6.qtwayland}/libexec/qtwaylandscanner"; QTWAYLANDSCANNER = lib.optionalString enableWayland "${qt6.qtwayland}/libexec/qtwaylandscanner";
@ -62,7 +64,8 @@
cmakeFlags = [ cmakeFlags = [
"-DGIT_REVISION=${gitRev}" "-DGIT_REVISION=${gitRev}"
] ++ lib.optional (!enableWayland) "-DWAYLAND=OFF" ] ++ lib.optional (!enableWayland) "-DWAYLAND=OFF"
++ lib.optional nvidiaCompat "-DNVIDIA_COMPAT=ON"; ++ lib.optional nvidiaCompat "-DNVIDIA_COMPAT=ON"
++ lib.optional (!enablePipewire) "-DSERVICE_PIPEWIRE=OFF";
buildPhase = "ninjaBuildPhase"; buildPhase = "ninjaBuildPhase";
enableParallelBuilding = true; enableParallelBuilding = true;

2
docs

@ -1 +1 @@
Subproject commit 149b784a5a4c40ada67cb9f6af5a5350678ab6d4 Subproject commit ff5da84a8b258a9b2caaf978ddb6de23635d8903

View file

@ -88,6 +88,7 @@ void ProxyWindowBase::createWindow() {
} }
void ProxyWindowBase::deleteWindow() { void ProxyWindowBase::deleteWindow() {
if (this->window != nullptr) emit this->windowDestroyed();
if (auto* window = this->disownWindow()) { if (auto* window = this->disownWindow()) {
if (auto* generation = EngineGeneration::findObjectGeneration(this)) { if (auto* generation = EngineGeneration::findObjectGeneration(this)) {
generation->deregisterIncubationController(window->incubationController()); generation->deregisterIncubationController(window->incubationController());

View file

@ -102,6 +102,7 @@ public:
signals: signals:
void windowConnected(); void windowConnected();
void windowDestroyed();
void visibleChanged(); void visibleChanged();
void backerVisibilityChanged(); void backerVisibilityChanged();
void xChanged(); void xChanged();

View file

@ -1 +1,7 @@
add_subdirectory(status_notifier) if (SERVICE_STATUS_NOTIFIER)
add_subdirectory(status_notifier)
endif()
if (SERVICE_PIPEWIRE)
add_subdirectory(pipewire)
endif()

View file

@ -0,0 +1,24 @@
find_package(PkgConfig REQUIRED)
pkg_check_modules(pipewire REQUIRED IMPORTED_TARGET libpipewire-0.3)
qt_add_library(quickshell-service-pipewire STATIC
qml.cpp
core.cpp
connection.cpp
registry.cpp
node.cpp
metadata.cpp
link.cpp
)
qt_add_qml_module(quickshell-service-pipewire
URI Quickshell.Services.Pipewire
VERSION 0.1
)
target_link_libraries(quickshell-service-pipewire PRIVATE ${QT_DEPS} PkgConfig::pipewire)
qs_pch(quickshell-service-pipewire)
qs_pch(quickshell-service-pipewireplugin)
target_link_libraries(quickshell PRIVATE quickshell-service-pipewireplugin)

View file

@ -0,0 +1,23 @@
#include "connection.hpp"
#include <qobject.h>
namespace qs::service::pipewire {
PwConnection::PwConnection(QObject* parent): QObject(parent) {
if (this->core.isValid()) {
this->registry.init(this->core);
}
}
PwConnection* PwConnection::instance() {
static PwConnection* instance = nullptr; // NOLINT
if (instance == nullptr) {
instance = new PwConnection();
}
return instance;
}
} // namespace qs::service::pipewire

View file

@ -0,0 +1,25 @@
#pragma once
#include "core.hpp"
#include "metadata.hpp"
#include "registry.hpp"
namespace qs::service::pipewire {
class PwConnection: public QObject {
Q_OBJECT;
public:
explicit PwConnection(QObject* parent = nullptr);
PwRegistry registry;
PwDefaultsMetadata defaults {&this->registry};
static PwConnection* instance();
private:
// init/destroy order is important. do not rearrange.
PwCore core;
};
} // namespace qs::service::pipewire

View file

@ -0,0 +1,87 @@
#include "core.hpp"
#include <cerrno>
#include <pipewire/context.h>
#include <pipewire/core.h>
#include <pipewire/loop.h>
#include <pipewire/pipewire.h>
#include <qlogging.h>
#include <qloggingcategory.h>
#include <qobject.h>
#include <qsocketnotifier.h>
#include <spa/utils/defs.h>
#include <spa/utils/hook.h>
namespace qs::service::pipewire {
Q_LOGGING_CATEGORY(logLoop, "quickshell.service.pipewire.loop", QtWarningMsg);
PwCore::PwCore(QObject* parent): QObject(parent), notifier(QSocketNotifier::Read) {
qCInfo(logLoop) << "Creating pipewire event loop.";
pw_init(nullptr, nullptr);
this->loop = pw_loop_new(nullptr);
if (this->loop == nullptr) {
qCCritical(logLoop) << "Failed to create pipewire event loop.";
return;
}
this->context = pw_context_new(this->loop, nullptr, 0);
if (this->context == nullptr) {
qCCritical(logLoop) << "Failed to create pipewire context.";
return;
}
qCInfo(logLoop) << "Connecting to pipewire server.";
this->core = pw_context_connect(this->context, nullptr, 0);
if (this->core == nullptr) {
qCCritical(logLoop) << "Failed to connect pipewire context. Errno:" << errno;
return;
}
qCInfo(logLoop) << "Linking pipewire event loop.";
// Tie the pw event loop into qt.
auto fd = pw_loop_get_fd(this->loop);
this->notifier.setSocket(fd);
QObject::connect(&this->notifier, &QSocketNotifier::activated, this, &PwCore::poll);
this->notifier.setEnabled(true);
}
PwCore::~PwCore() {
qCInfo(logLoop) << "Destroying PwCore.";
if (this->loop != nullptr) {
if (this->context != nullptr) {
if (this->core != nullptr) {
pw_core_disconnect(this->core);
}
pw_context_destroy(this->context);
}
pw_loop_destroy(this->loop);
}
}
bool PwCore::isValid() const {
// others must init first
return this->core != nullptr;
}
void PwCore::poll() const {
qCDebug(logLoop) << "Pipewire event loop received new events, iterating.";
// Spin pw event loop.
pw_loop_iterate(this->loop, 0);
qCDebug(logLoop) << "Done iterating pipewire event loop.";
}
SpaHook::SpaHook() { // NOLINT
spa_zero(this->hook);
}
void SpaHook::remove() {
spa_hook_remove(&this->hook);
spa_zero(this->hook);
}
} // namespace qs::service::pipewire

View file

@ -0,0 +1,59 @@
#pragma once
#include <pipewire/context.h>
#include <pipewire/core.h>
#include <pipewire/loop.h>
#include <pipewire/proxy.h>
#include <qloggingcategory.h>
#include <qobject.h>
#include <qsocketnotifier.h>
#include <qtclasshelpermacros.h>
#include <qtmetamacros.h>
#include <qtypes.h>
#include <spa/utils/hook.h>
namespace qs::service::pipewire {
class PwCore: public QObject {
Q_OBJECT;
public:
explicit PwCore(QObject* parent = nullptr);
~PwCore() override;
Q_DISABLE_COPY_MOVE(PwCore);
[[nodiscard]] bool isValid() const;
pw_loop* loop = nullptr;
pw_context* context = nullptr;
pw_core* core = nullptr;
private slots:
void poll() const;
private:
QSocketNotifier notifier;
};
template <typename T>
class PwObject {
public:
explicit PwObject(T* object = nullptr): object(object) {}
~PwObject() {
pw_proxy_destroy(reinterpret_cast<pw_proxy*>(this->object)); // NOLINT
}
Q_DISABLE_COPY_MOVE(PwObject);
T* object;
};
class SpaHook {
public:
explicit SpaHook();
void remove();
spa_hook hook;
};
} // namespace qs::service::pipewire

View file

@ -0,0 +1,184 @@
#include "link.hpp"
#include <cstring>
#include <pipewire/link.h>
#include <qdebug.h>
#include <qlogging.h>
#include <qloggingcategory.h>
#include <qobject.h>
#include <qtmetamacros.h>
#include <qtypes.h>
#include <spa/utils/dict.h>
#include "registry.hpp"
namespace qs::service::pipewire {
Q_LOGGING_CATEGORY(logLink, "quickshell.service.pipewire.link", QtWarningMsg);
QString PwLinkState::toString(Enum value) {
return QString(pw_link_state_as_string(static_cast<pw_link_state>(value)));
}
void PwLink::bindHooks() {
pw_link_add_listener(this->proxy(), &this->listener.hook, &PwLink::EVENTS, this);
}
void PwLink::unbindHooks() {
this->listener.remove();
this->setState(PW_LINK_STATE_UNLINKED);
}
void PwLink::initProps(const spa_dict* props) {
qCDebug(logLink) << "Parsing initial SPA props of link" << this;
const spa_dict_item* item = nullptr;
spa_dict_for_each(item, props) {
if (strcmp(item->key, "link.output.node") == 0) {
auto str = QString(item->value);
auto ok = false;
auto value = str.toInt(&ok);
if (ok) this->setOutputNode(value);
else {
qCWarning(logLink) << "Could not parse link.output.node for" << this << ":" << item->value;
}
} else if (strcmp(item->key, "link.input.node") == 0) {
auto str = QString(item->value);
auto ok = false;
auto value = str.toInt(&ok);
if (ok) this->setInputNode(value);
else {
qCWarning(logLink) << "Could not parse link.input.node for" << this << ":" << item->value;
}
}
}
}
const pw_link_events PwLink::EVENTS = {
.version = PW_VERSION_LINK_EVENTS,
.info = &PwLink::onInfo,
};
void PwLink::onInfo(void* data, const struct pw_link_info* info) {
auto* self = static_cast<PwLink*>(data);
qCDebug(logLink) << "Got link info update for" << self << "with mask" << info->change_mask;
self->setOutputNode(info->output_node_id);
self->setInputNode(info->input_node_id);
if ((info->change_mask & PW_LINK_CHANGE_MASK_STATE) != 0) {
self->setState(info->state);
}
}
quint32 PwLink::outputNode() const { return this->mOutputNode; }
quint32 PwLink::inputNode() const { return this->mInputNode; }
PwLinkState::Enum PwLink::state() const { return static_cast<PwLinkState::Enum>(this->mState); }
void PwLink::setOutputNode(quint32 outputNode) {
if (outputNode == this->mOutputNode) return;
if (this->mOutputNode != 0) {
qCWarning(logLink) << "Got unexpected output node update for" << this << "to" << outputNode;
}
this->mOutputNode = outputNode;
qCDebug(logLink) << "Updated output node of" << this;
}
void PwLink::setInputNode(quint32 inputNode) {
if (inputNode == this->mInputNode) return;
if (this->mInputNode != 0) {
qCWarning(logLink) << "Got unexpected input node update for" << this << "to" << inputNode;
}
this->mInputNode = inputNode;
qCDebug(logLink) << "Updated input node of" << this;
}
void PwLink::setState(pw_link_state state) {
if (state == this->mState) return;
this->mState = state;
qCDebug(logLink) << "Updated state of" << this;
emit this->stateChanged();
}
QDebug operator<<(QDebug debug, const PwLink* link) {
if (link == nullptr) {
debug << "PwLink(0x0)";
} else {
auto saver = QDebugStateSaver(debug);
debug.nospace() << "PwLink(" << link->outputNode() << " -> " << link->inputNode() << ", "
<< static_cast<const void*>(link) << ", id=";
link->debugId(debug);
debug << ", state=" << link->state() << ')';
}
return debug;
}
PwLinkGroup::PwLinkGroup(PwLink* firstLink, QObject* parent)
: QObject(parent)
, mOutputNode(firstLink->outputNode())
, mInputNode(firstLink->inputNode()) {
this->tryAddLink(firstLink);
}
void PwLinkGroup::ref() {
this->refcount++;
if (this->refcount == 1) {
this->trackedLink = *this->links.begin();
this->trackedLink->ref();
QObject::connect(this->trackedLink, &PwLink::stateChanged, this, &PwLinkGroup::stateChanged);
emit this->stateChanged();
}
}
void PwLinkGroup::unref() {
if (this->refcount == 0) return;
this->refcount--;
if (this->refcount == 0) {
this->trackedLink->unref();
this->trackedLink = nullptr;
emit this->stateChanged();
}
}
quint32 PwLinkGroup::outputNode() const { return this->mOutputNode; }
quint32 PwLinkGroup::inputNode() const { return this->mInputNode; }
PwLinkState::Enum PwLinkGroup::state() const {
if (this->trackedLink == nullptr) {
return PwLinkState::Unlinked;
} else {
return this->trackedLink->state();
}
}
bool PwLinkGroup::tryAddLink(PwLink* link) {
if (link->outputNode() != this->mOutputNode || link->inputNode() != this->mInputNode)
return false;
this->links.insert(link->id, link);
QObject::connect(link, &PwBindableObject::destroying, this, &PwLinkGroup::onLinkRemoved);
return true;
}
void PwLinkGroup::onLinkRemoved(QObject* object) {
auto* link = static_cast<PwLink*>(object); // NOLINT
this->links.remove(link->id);
if (this->links.empty()) {
delete this;
} else if (link == this->trackedLink) {
this->trackedLink = *this->links.begin();
QObject::connect(this->trackedLink, &PwLink::stateChanged, this, &PwLinkGroup::stateChanged);
emit this->stateChanged();
}
}
} // namespace qs::service::pipewire

View file

@ -0,0 +1,99 @@
#pragma once
#include <pipewire/link.h>
#include <pipewire/type.h>
#include <qcontainerfwd.h>
#include <qdebug.h>
#include <qobject.h>
#include <qqmlintegration.h>
#include <qtmetamacros.h>
#include <qtypes.h>
#include "registry.hpp"
namespace qs::service::pipewire {
class PwLinkState: public QObject {
Q_OBJECT;
QML_ELEMENT;
QML_SINGLETON;
public:
enum Enum {
Error = PW_LINK_STATE_ERROR,
Unlinked = PW_LINK_STATE_UNLINKED,
Init = PW_LINK_STATE_INIT,
Negotiating = PW_LINK_STATE_NEGOTIATING,
Allocating = PW_LINK_STATE_ALLOCATING,
Paused = PW_LINK_STATE_PAUSED,
Active = PW_LINK_STATE_ACTIVE,
};
Q_ENUM(Enum);
Q_INVOKABLE static QString toString(PwLinkState::Enum value);
};
constexpr const char TYPE_INTERFACE_Link[] = PW_TYPE_INTERFACE_Link; // NOLINT
class PwLink: public PwBindable<pw_link, TYPE_INTERFACE_Link, PW_VERSION_LINK> { // NOLINT
Q_OBJECT;
public:
void bindHooks() override;
void unbindHooks() override;
void initProps(const spa_dict* props) override;
[[nodiscard]] quint32 outputNode() const;
[[nodiscard]] quint32 inputNode() const;
[[nodiscard]] PwLinkState::Enum state() const;
signals:
void stateChanged();
private:
static const pw_link_events EVENTS;
static void onInfo(void* data, const struct pw_link_info* info);
void setOutputNode(quint32 outputNode);
void setInputNode(quint32 inputNode);
void setState(pw_link_state state);
SpaHook listener;
quint32 mOutputNode = 0;
quint32 mInputNode = 0;
pw_link_state mState = PW_LINK_STATE_UNLINKED;
};
QDebug operator<<(QDebug debug, const PwLink* link);
class PwLinkGroup: public QObject {
Q_OBJECT;
public:
explicit PwLinkGroup(PwLink* firstLink, QObject* parent = nullptr);
void ref();
void unref();
[[nodiscard]] quint32 outputNode() const;
[[nodiscard]] quint32 inputNode() const;
[[nodiscard]] PwLinkState::Enum state() const;
QHash<quint32, PwLink*> links;
bool tryAddLink(PwLink* link);
signals:
void stateChanged();
private slots:
void onLinkRemoved(QObject* object);
private:
quint32 mOutputNode = 0;
quint32 mInputNode = 0;
PwLink* trackedLink = nullptr;
quint32 refcount = 0;
};
} // namespace qs::service::pipewire

View file

@ -0,0 +1,146 @@
#include "metadata.hpp"
#include <array>
#include <cstring>
#include <pipewire/extensions/metadata.h>
#include <qlogging.h>
#include <qloggingcategory.h>
#include <qobject.h>
#include <qtmetamacros.h>
#include <qtypes.h>
#include <spa/utils/json.h>
#include "registry.hpp"
namespace qs::service::pipewire {
Q_LOGGING_CATEGORY(logMeta, "quickshell.service.pipewire.metadata", QtWarningMsg);
void PwMetadata::bindHooks() {
pw_metadata_add_listener(this->proxy(), &this->listener.hook, &PwMetadata::EVENTS, this);
}
void PwMetadata::unbindHooks() { this->listener.remove(); }
const pw_metadata_events PwMetadata::EVENTS = {
.version = PW_VERSION_METADATA_EVENTS,
.property = &PwMetadata::onProperty,
};
int PwMetadata::onProperty(
void* data,
quint32 subject,
const char* key,
const char* type,
const char* value
) {
auto* self = static_cast<PwMetadata*>(data);
qCDebug(logMeta) << "Received metadata for" << self << "- subject:" << subject
<< "key:" << QString(key) << "type:" << QString(type)
<< "value:" << QString(value);
emit self->registry->metadataUpdate(self, subject, key, type, value);
// ideally we'd dealloc metadata that wasn't picked up but there's no information
// available about if updates can come in later, so I assume they can.
return 0; // ??? - no docs and no reason for a callback to return an int
}
PwDefaultsMetadata::PwDefaultsMetadata(PwRegistry* registry) {
QObject::connect(
registry,
&PwRegistry::metadataUpdate,
this,
&PwDefaultsMetadata::onMetadataUpdate
);
}
QString PwDefaultsMetadata::defaultSink() const { return this->mDefaultSink; }
QString PwDefaultsMetadata::defaultSource() const { return this->mDefaultSource; }
// we don't really care if the metadata objects are destroyed, but try to ref them so we get property updates
void PwDefaultsMetadata::onMetadataUpdate(
PwMetadata* metadata,
quint32 subject,
const char* key,
const char* /*type*/,
const char* value
) {
if (subject != 0) return;
// non "configured" sinks and sources have lower priority as wireplumber seems to only change
// the "configured" ones.
bool sink = false;
if (strcmp(key, "default.configured.audio.sink") == 0) {
sink = true;
this->sinkConfigured = true;
} else if ((!this->sinkConfigured && strcmp(key, "default.audio.sink") == 0)) {
sink = true;
}
if (sink) {
this->defaultSinkHolder.setObject(metadata);
auto newSink = PwDefaultsMetadata::parseNameSpaJson(value);
qCInfo(logMeta) << "Got default sink" << newSink << "configured:" << this->sinkConfigured;
if (newSink == this->mDefaultSink) return;
this->mDefaultSink = newSink;
emit this->defaultSinkChanged();
return;
}
bool source = false;
if (strcmp(key, "default.configured.audio.source") == 0) {
source = true;
this->sourceConfigured = true;
} else if ((!this->sourceConfigured && strcmp(key, "default.audio.source") == 0)) {
source = true;
}
if (source) {
this->defaultSourceHolder.setObject(metadata);
auto newSource = PwDefaultsMetadata::parseNameSpaJson(value);
qCInfo(logMeta) << "Got default source" << newSource << "configured:" << this->sourceConfigured;
if (newSource == this->mDefaultSource) return;
this->mDefaultSource = newSource;
emit this->defaultSourceChanged();
return;
}
}
QString PwDefaultsMetadata::parseNameSpaJson(const char* spaJson) {
auto iter = std::array<spa_json, 2>();
spa_json_init(&iter[0], spaJson, strlen(spaJson));
if (spa_json_enter_object(&iter[0], &iter[1]) < 0) {
qCWarning(logMeta) << "Failed to parse source/sink SPA json - failed to enter object of"
<< QString(spaJson);
return "";
}
auto buf = std::array<char, 512>();
while (spa_json_get_string(&iter[1], buf.data(), buf.size()) > 0) {
if (strcmp(buf.data(), "name") != 0) continue;
if (spa_json_get_string(&iter[1], buf.data(), buf.size()) < 0) {
qCWarning(logMeta
) << "Failed to parse source/sink SPA json - failed to read value of name property"
<< QString(spaJson);
return "";
}
return QString(buf.data());
}
qCWarning(logMeta) << "Failed to parse source/sink SPA json - failed to find name property of"
<< QString(spaJson);
return "";
}
} // namespace qs::service::pipewire

View file

@ -0,0 +1,64 @@
#pragma once
#include <pipewire/extensions/metadata.h>
#include <pipewire/type.h>
#include <qobject.h>
#include <qtmetamacros.h>
#include "core.hpp"
#include "registry.hpp"
namespace qs::service::pipewire {
constexpr const char TYPE_INTERFACE_Metadata[] = PW_TYPE_INTERFACE_Metadata; // NOLINT
class PwMetadata
: public PwBindable<pw_metadata, TYPE_INTERFACE_Metadata, PW_VERSION_METADATA> { // NOLINT
Q_OBJECT;
public:
void bindHooks() override;
void unbindHooks() override;
private:
static const pw_metadata_events EVENTS;
static int
onProperty(void* data, quint32 subject, const char* key, const char* type, const char* value);
SpaHook listener;
};
class PwDefaultsMetadata: public QObject {
Q_OBJECT;
public:
explicit PwDefaultsMetadata(PwRegistry* registry);
[[nodiscard]] QString defaultSource() const;
[[nodiscard]] QString defaultSink() const;
signals:
void defaultSourceChanged();
void defaultSinkChanged();
private slots:
void onMetadataUpdate(
PwMetadata* metadata,
quint32 subject,
const char* key,
const char* type,
const char* value
);
private:
static QString parseNameSpaJson(const char* spaJson);
PwBindableRef<PwMetadata> defaultSinkHolder;
PwBindableRef<PwMetadata> defaultSourceHolder;
bool sinkConfigured = false;
QString mDefaultSink;
bool sourceConfigured = false;
QString mDefaultSource;
};
} // namespace qs::service::pipewire

View file

@ -0,0 +1,8 @@
name = "Quickshell.Services.PipeWire"
description = "Pipewire API"
headers = [
"qml.hpp",
"link.hpp",
"node.hpp",
]
-----

View file

@ -0,0 +1,384 @@
#include "node.hpp"
#include <array>
#include <cmath>
#include <cstdint>
#include <cstring>
#include <pipewire/core.h>
#include <pipewire/node.h>
#include <qcontainerfwd.h>
#include <qlogging.h>
#include <qloggingcategory.h>
#include <qobject.h>
#include <qtmetamacros.h>
#include <qtypes.h>
#include <spa/node/keys.h>
#include <spa/param/param.h>
#include <spa/param/props.h>
#include <spa/pod/builder.h>
#include <spa/pod/iter.h>
#include <spa/pod/pod.h>
#include <spa/pod/vararg.h>
#include <spa/utils/dict.h>
#include <spa/utils/keys.h>
#include <spa/utils/type.h>
namespace qs::service::pipewire {
Q_LOGGING_CATEGORY(logNode, "quickshell.service.pipewire.node", QtWarningMsg);
QString PwAudioChannel::toString(Enum value) {
switch (value) {
case Unknown: return "Unknown";
case NA: return "N/A";
case Mono: return "Mono";
case FrontCenter: return "Front Center";
case FrontLeft: return "Front Left";
case FrontRight: return "Front Right";
case FrontLeftCenter: return "Front Left Center";
case FrontRightCenter: return "Front Right Center";
case FrontLeftWide: return "Front Left Wide";
case FrontRightWide: return "Front Right Wide";
case FrontCenterHigh: return "Front Center High";
case FrontLeftHigh: return "Front Left High";
case FrontRightHigh: return "Front Right High";
case LowFrequencyEffects: return "Low Frequency Effects";
case LowFrequencyEffects2: return "Low Frequency Effects 2";
case LowFrequencyEffectsLeft: return "Low Frequency Effects Left";
case LowFrequencyEffectsRight: return "Low Frequency Effects Right";
case SideLeft: return "Side Left";
case SideRight: return "Side Right";
case RearCenter: return "Rear Center";
case RearLeft: return "Rear Left";
case RearRight: return "Rear Right";
case RearLeftCenter: return "Rear Left Center";
case RearRightCenter: return "Rear Right Center";
case TopCenter: return "Top Center";
case TopFrontCenter: return "Top Front Center";
case TopFrontLeft: return "Top Front Left";
case TopFrontRight: return "Top Front Right";
case TopFrontLeftCenter: return "Top Front Left Center";
case TopFrontRightCenter: return "Top Front Right Center";
case TopSideLeft: return "Top Side Left";
case TopSideRight: return "Top Side Right";
case TopRearCenter: return "Top Rear Center";
case TopRearLeft: return "Top Rear Left";
case TopRearRight: return "Top Rear Right";
case BottomCenter: return "Bottom Center";
case BottomLeftCenter: return "Bottom Left Center";
case BottomRightCenter: return "Bottom Right Center";
default:
if (value >= AuxRangeStart && value <= AuxRangeEnd) {
return QString("Aux %1").arg(value - AuxRangeStart + 1);
} else if (value >= CustomRangeStart) {
return QString("Custom %1").arg(value - CustomRangeStart + 1);
} else {
return "Unknown";
}
}
}
void PwNode::bindHooks() {
pw_node_add_listener(this->proxy(), &this->listener.hook, &PwNode::EVENTS, this);
}
void PwNode::unbindHooks() {
this->listener.remove();
this->properties.clear();
emit this->propertiesChanged();
if (this->boundData != nullptr) {
this->boundData->onUnbind();
}
}
void PwNode::initProps(const spa_dict* props) {
if (const auto* mediaClass = spa_dict_lookup(props, SPA_KEY_MEDIA_CLASS)) {
if (strcmp(mediaClass, "Audio/Sink") == 0) {
this->type = PwNodeType::Audio;
this->isSink = true;
this->isStream = false;
} else if (strcmp(mediaClass, "Audio/Source") == 0) {
this->type = PwNodeType::Audio;
this->isSink = false;
this->isStream = false;
} else if (strcmp(mediaClass, "Stream/Output/Audio") == 0) {
this->type = PwNodeType::Audio;
this->isSink = false;
this->isStream = true;
} else if (strcmp(mediaClass, "Stream/Input/Audio") == 0) {
this->type = PwNodeType::Audio;
this->isSink = true;
this->isStream = true;
}
}
if (const auto* nodeName = spa_dict_lookup(props, SPA_KEY_NODE_NAME)) {
this->name = nodeName;
}
if (const auto* nodeDesc = spa_dict_lookup(props, SPA_KEY_NODE_DESCRIPTION)) {
this->description = nodeDesc;
}
if (const auto* nodeNick = spa_dict_lookup(props, "node.nick")) {
this->nick = nodeNick;
}
if (this->type == PwNodeType::Audio) {
this->boundData = new PwNodeBoundAudio(this);
}
}
const pw_node_events PwNode::EVENTS = {
.version = PW_VERSION_NODE_EVENTS,
.info = &PwNode::onInfo,
.param = &PwNode::onParam,
};
void PwNode::onInfo(void* data, const pw_node_info* info) {
auto* self = static_cast<PwNode*>(data);
if ((info->change_mask & PW_NODE_CHANGE_MASK_PROPS) != 0) {
auto properties = QMap<QString, QString>();
const spa_dict_item* item = nullptr;
spa_dict_for_each(item, info->props) { properties.insert(item->key, item->value); }
self->properties = properties;
emit self->propertiesChanged();
}
if (self->boundData != nullptr) {
self->boundData->onInfo(info);
}
}
void PwNode::onParam(
void* data,
qint32 /*seq*/,
quint32 id,
quint32 index,
quint32 /*next*/,
const spa_pod* param
) {
auto* self = static_cast<PwNode*>(data);
if (self->boundData != nullptr) {
self->boundData->onSpaParam(id, index, param);
}
}
void PwNodeBoundAudio::onInfo(const pw_node_info* info) {
if ((info->change_mask & PW_NODE_CHANGE_MASK_PARAMS) != 0) {
for (quint32 i = 0; i < info->n_params; i++) {
auto& param = info->params[i]; // NOLINT
if (param.id == SPA_PARAM_Props && (param.flags & SPA_PARAM_INFO_READ) != 0) {
pw_node_enum_params(this->node->proxy(), 0, param.id, 0, UINT32_MAX, nullptr);
}
}
}
}
void PwNodeBoundAudio::onSpaParam(quint32 id, quint32 index, const spa_pod* param) {
if (id == SPA_PARAM_Props && index == 0) {
this->updateVolumeFromParam(param);
this->updateMutedFromParam(param);
}
}
void PwNodeBoundAudio::updateVolumeFromParam(const spa_pod* param) {
const auto* volumesProp = spa_pod_find_prop(param, nullptr, SPA_PROP_channelVolumes);
const auto* channelsProp = spa_pod_find_prop(param, nullptr, SPA_PROP_channelMap);
if (volumesProp == nullptr) {
qCWarning(logNode) << "Cannot update volume props of" << this->node
<< "- channelVolumes was null.";
return;
}
if (channelsProp == nullptr) {
qCWarning(logNode) << "Cannot update volume props of" << this->node << "- channelMap was null.";
return;
}
if (spa_pod_is_array(&volumesProp->value) == 0) {
qCWarning(logNode) << "Cannot update volume props of" << this->node
<< "- channelVolumes was not an array.";
return;
}
if (spa_pod_is_array(&channelsProp->value) == 0) {
qCWarning(logNode) << "Cannot update volume props of" << this->node
<< "- channelMap was not an array.";
return;
}
const auto* volumes = reinterpret_cast<const spa_pod_array*>(&volumesProp->value); // NOLINT
const auto* channels = reinterpret_cast<const spa_pod_array*>(&channelsProp->value); // NOLINT
auto volumesVec = QVector<float>();
auto channelsVec = QVector<PwAudioChannel::Enum>();
spa_pod* iter = nullptr;
SPA_POD_ARRAY_FOREACH(volumes, iter) {
// Cubing behavior found in MPD source, and appears to corrospond to everyone else's measurements correctly.
auto linear = *reinterpret_cast<float*>(iter); // NOLINT
auto visual = std::cbrt(linear);
volumesVec.push_back(visual);
}
SPA_POD_ARRAY_FOREACH(channels, iter) {
channelsVec.push_back(*reinterpret_cast<PwAudioChannel::Enum*>(iter)); // NOLINT
}
if (volumesVec.size() != channelsVec.size()) {
qCWarning(logNode) << "Cannot update volume props of" << this->node
<< "- channelVolumes and channelMap are not the same size. Sizes:"
<< volumesVec.size() << channelsVec.size();
return;
}
// It is important that the lengths of channels and volumes stay in sync whenever you read them.
auto channelsChanged = false;
auto volumesChanged = false;
if (this->mChannels != channelsVec) {
this->mChannels = channelsVec;
channelsChanged = true;
qCDebug(logNode) << "Got updated channels of" << this->node << '-' << this->mChannels;
}
if (this->mVolumes != volumesVec) {
this->mVolumes = volumesVec;
volumesChanged = true;
qCDebug(logNode) << "Got updated volumes of" << this->node << '-' << this->mVolumes;
}
if (channelsChanged) emit this->channelsChanged();
if (volumesChanged) emit this->volumesChanged();
}
void PwNodeBoundAudio::updateMutedFromParam(const spa_pod* param) {
const auto* mutedProp = spa_pod_find_prop(param, nullptr, SPA_PROP_mute);
if (mutedProp == nullptr) {
qCWarning(logNode) << "Cannot update muted state of" << this->node
<< "- mute property was null.";
return;
}
if (spa_pod_is_bool(&mutedProp->value) == 0) {
qCWarning(logNode) << "Cannot update muted state of" << this->node
<< "- mute property was not a boolean.";
return;
}
bool muted = false;
spa_pod_get_bool(&mutedProp->value, &muted);
if (muted != this->mMuted) {
qCDebug(logNode) << "Got updated mute status of" << this->node << '-' << muted;
this->mMuted = muted;
emit this->mutedChanged();
}
}
void PwNodeBoundAudio::onUnbind() {
this->mChannels.clear();
this->mVolumes.clear();
emit this->channelsChanged();
emit this->volumesChanged();
}
bool PwNodeBoundAudio::isMuted() const { return this->mMuted; }
void PwNodeBoundAudio::setMuted(bool muted) {
if (this->node->proxy() == nullptr) {
qCWarning(logNode) << "Tried to change mute state for" << this->node << "which is not bound.";
return;
}
if (muted == this->mMuted) return;
auto buffer = std::array<quint32, 1024>();
auto builder = SPA_POD_BUILDER_INIT(buffer.data(), buffer.size());
// is this a leak? seems like probably not? docs don't say, as usual.
// clang-format off
auto* pod = spa_pod_builder_add_object(
&builder, SPA_TYPE_OBJECT_Props, SPA_PARAM_Props,
SPA_PROP_mute, SPA_POD_Bool(muted)
);
// clang-format on
qCDebug(logNode) << "Changed muted state of" << this->node << "to" << muted;
this->mMuted = muted;
pw_node_set_param(this->node->proxy(), SPA_PARAM_Props, 0, static_cast<spa_pod*>(pod));
emit this->mutedChanged();
}
float PwNodeBoundAudio::averageVolume() const {
float total = 0;
for (auto volume: this->mVolumes) {
total += volume;
}
return total / static_cast<float>(this->mVolumes.size());
}
void PwNodeBoundAudio::setAverageVolume(float volume) {
auto oldAverage = this->averageVolume();
auto mul = oldAverage == 0 ? 0 : volume / oldAverage;
auto volumes = QVector<float>();
for (auto oldVolume: this->mVolumes) {
volumes.push_back(mul == 0 ? volume : oldVolume * mul);
}
this->setVolumes(volumes);
}
QVector<PwAudioChannel::Enum> PwNodeBoundAudio::channels() const { return this->mChannels; }
QVector<float> PwNodeBoundAudio::volumes() const { return this->mVolumes; }
void PwNodeBoundAudio::setVolumes(const QVector<float>& volumes) {
if (this->node->proxy() == nullptr) {
qCWarning(logNode) << "Tried to change node volumes for" << this->node << "which is not bound.";
return;
}
if (volumes == this->mVolumes) return;
if (volumes.length() != this->mVolumes.length()) {
qCWarning(logNode) << "Tried to change node volumes for" << this->node << "from"
<< this->mVolumes << "to" << volumes
<< "which has a different length than the list of channels"
<< this->mChannels;
return;
}
auto buffer = std::array<quint32, 1024>();
auto builder = SPA_POD_BUILDER_INIT(buffer.data(), buffer.size());
auto cubedVolumes = QVector<float>();
for (auto volume: volumes) {
cubedVolumes.push_back(volume * volume * volume);
}
// clang-format off
auto* pod = spa_pod_builder_add_object(
&builder, SPA_TYPE_OBJECT_Props, SPA_PARAM_Props,
SPA_PROP_channelVolumes, SPA_POD_Array(sizeof(float), SPA_TYPE_Float, cubedVolumes.length(), cubedVolumes.data())
);
// clang-format on
qCDebug(logNode) << "Changed volumes of" << this->node << "to" << volumes;
this->mVolumes = volumes;
pw_node_set_param(this->node->proxy(), SPA_PARAM_Props, 0, static_cast<spa_pod*>(pod));
emit this->volumesChanged();
}
} // namespace qs::service::pipewire

View file

@ -0,0 +1,174 @@
#pragma once
#include <pipewire/core.h>
#include <pipewire/node.h>
#include <pipewire/type.h>
#include <qcontainerfwd.h>
#include <qmap.h>
#include <qobject.h>
#include <qqmlintegration.h>
#include <qtclasshelpermacros.h>
#include <qtmetamacros.h>
#include <qtypes.h>
#include <spa/param/audio/raw.h>
#include <spa/pod/pod.h>
#include "core.hpp"
#include "registry.hpp"
namespace qs::service::pipewire {
class PwAudioChannel: public QObject {
Q_OBJECT;
QML_ELEMENT;
QML_SINGLETON;
public:
enum Enum {
Unknown = SPA_AUDIO_CHANNEL_UNKNOWN,
NA = SPA_AUDIO_CHANNEL_NA,
Mono = SPA_AUDIO_CHANNEL_MONO,
FrontCenter = SPA_AUDIO_CHANNEL_FC,
FrontLeft = SPA_AUDIO_CHANNEL_FL,
FrontRight = SPA_AUDIO_CHANNEL_FR,
FrontLeftCenter = SPA_AUDIO_CHANNEL_FLC,
FrontRightCenter = SPA_AUDIO_CHANNEL_FRC,
FrontLeftWide = SPA_AUDIO_CHANNEL_FLW,
FrontRightWide = SPA_AUDIO_CHANNEL_FRW,
FrontCenterHigh = SPA_AUDIO_CHANNEL_FCH,
FrontLeftHigh = SPA_AUDIO_CHANNEL_FLH,
FrontRightHigh = SPA_AUDIO_CHANNEL_FRH,
LowFrequencyEffects = SPA_AUDIO_CHANNEL_LFE,
LowFrequencyEffects2 = SPA_AUDIO_CHANNEL_LFE2,
LowFrequencyEffectsLeft = SPA_AUDIO_CHANNEL_LLFE,
LowFrequencyEffectsRight = SPA_AUDIO_CHANNEL_RLFE,
SideLeft = SPA_AUDIO_CHANNEL_SL,
SideRight = SPA_AUDIO_CHANNEL_SR,
RearCenter = SPA_AUDIO_CHANNEL_RC,
RearLeft = SPA_AUDIO_CHANNEL_RL,
RearRight = SPA_AUDIO_CHANNEL_RR,
RearLeftCenter = SPA_AUDIO_CHANNEL_RLC,
RearRightCenter = SPA_AUDIO_CHANNEL_RRC,
TopCenter = SPA_AUDIO_CHANNEL_TC,
TopFrontCenter = SPA_AUDIO_CHANNEL_TFC,
TopFrontLeft = SPA_AUDIO_CHANNEL_TFL,
TopFrontRight = SPA_AUDIO_CHANNEL_TFR,
TopFrontLeftCenter = SPA_AUDIO_CHANNEL_TFLC,
TopFrontRightCenter = SPA_AUDIO_CHANNEL_TFRC,
TopSideLeft = SPA_AUDIO_CHANNEL_TSL,
TopSideRight = SPA_AUDIO_CHANNEL_TSR,
TopRearCenter = SPA_AUDIO_CHANNEL_TRC,
TopRearLeft = SPA_AUDIO_CHANNEL_TRL,
TopRearRight = SPA_AUDIO_CHANNEL_TRR,
BottomCenter = SPA_AUDIO_CHANNEL_BC,
BottomLeftCenter = SPA_AUDIO_CHANNEL_BLC,
BottomRightCenter = SPA_AUDIO_CHANNEL_BRC,
/// The start of the aux channel range.
///
/// Values between AuxRangeStart and AuxRangeEnd are valid.
AuxRangeStart = SPA_AUDIO_CHANNEL_START_Aux,
/// The end of the aux channel range.
///
/// Values between AuxRangeStart and AuxRangeEnd are valid.
AuxRangeEnd = SPA_AUDIO_CHANNEL_LAST_Aux,
/// The end of the custom channel range.
///
/// Values starting at CustomRangeStart are valid.
CustomRangeStart = SPA_AUDIO_CHANNEL_START_Custom,
};
Q_ENUM(Enum);
/// Print a human readable representation of the given channel,
/// including aux and custom channel ranges.
Q_INVOKABLE static QString toString(PwAudioChannel::Enum value);
};
enum class PwNodeType {
Untracked,
Audio,
};
class PwNode;
class PwNodeBoundData {
public:
PwNodeBoundData() = default;
virtual ~PwNodeBoundData() = default;
Q_DISABLE_COPY_MOVE(PwNodeBoundData);
virtual void onInfo(const pw_node_info* /*info*/) {}
virtual void onSpaParam(quint32 /*id*/, quint32 /*index*/, const spa_pod* /*param*/) {}
virtual void onUnbind() {}
};
class PwNodeBoundAudio
: public QObject
, public PwNodeBoundData {
Q_OBJECT;
public:
explicit PwNodeBoundAudio(PwNode* node): node(node) {}
void onInfo(const pw_node_info* info) override;
void onSpaParam(quint32 id, quint32 index, const spa_pod* param) override;
void onUnbind() override;
[[nodiscard]] bool isMuted() const;
void setMuted(bool muted);
[[nodiscard]] float averageVolume() const;
void setAverageVolume(float volume);
[[nodiscard]] QVector<PwAudioChannel::Enum> channels() const;
[[nodiscard]] QVector<float> volumes() const;
void setVolumes(const QVector<float>& volumes);
signals:
void volumesChanged();
void channelsChanged();
void mutedChanged();
private:
void updateVolumeFromParam(const spa_pod* param);
void updateMutedFromParam(const spa_pod* param);
bool mMuted = false;
QVector<PwAudioChannel::Enum> mChannels;
QVector<float> mVolumes;
PwNode* node;
};
constexpr const char TYPE_INTERFACE_Node[] = PW_TYPE_INTERFACE_Node; // NOLINT
class PwNode: public PwBindable<pw_node, TYPE_INTERFACE_Node, PW_VERSION_NODE> { // NOLINT
Q_OBJECT;
public:
void bindHooks() override;
void unbindHooks() override;
void initProps(const spa_dict* props) override;
QString name;
QString description;
QString nick;
QMap<QString, QString> properties;
PwNodeType type = PwNodeType::Untracked;
bool isSink = false;
bool isStream = false;
PwNodeBoundData* boundData = nullptr;
signals:
void propertiesChanged();
private:
static const pw_node_events EVENTS;
static void onInfo(void* data, const pw_node_info* info);
static void
onParam(void* data, qint32 seq, quint32 id, quint32 index, quint32 next, const spa_pod* param);
SpaHook listener;
};
} // namespace qs::service::pipewire

View file

@ -0,0 +1,472 @@
#include "qml.hpp"
#include <qcontainerfwd.h>
#include <qlist.h>
#include <qobject.h>
#include <qqmllist.h>
#include <qtmetamacros.h>
#include <qtypes.h>
#include <qvariant.h>
#include "connection.hpp"
#include "link.hpp"
#include "metadata.hpp"
#include "node.hpp"
#include "registry.hpp"
namespace qs::service::pipewire {
void PwObjectIface::ref() {
this->refcount++;
if (this->refcount == 1) {
this->object->ref();
}
}
void PwObjectIface::unref() {
if (this->refcount == 0) return;
this->refcount--;
if (this->refcount == 0) {
this->object->unref();
}
}
Pipewire::Pipewire(QObject* parent): QObject(parent) {
auto* connection = PwConnection::instance();
for (auto* node: connection->registry.nodes.values()) {
this->onNodeAdded(node);
}
QObject::connect(&connection->registry, &PwRegistry::nodeAdded, this, &Pipewire::onNodeAdded);
for (auto* link: connection->registry.links.values()) {
this->onLinkAdded(link);
}
QObject::connect(&connection->registry, &PwRegistry::linkAdded, this, &Pipewire::onLinkAdded);
for (auto* group: connection->registry.linkGroups) {
this->onLinkGroupAdded(group);
}
QObject::connect(
&connection->registry,
&PwRegistry::linkGroupAdded,
this,
&Pipewire::onLinkGroupAdded
);
// clang-format off
QObject::connect(&connection->defaults, &PwDefaultsMetadata::defaultSinkChanged, this, &Pipewire::defaultAudioSinkChanged);
QObject::connect(&connection->defaults, &PwDefaultsMetadata::defaultSourceChanged, this, &Pipewire::defaultAudioSourceChanged);
// clang-format on
}
QQmlListProperty<PwNodeIface> Pipewire::nodes() {
return QQmlListProperty<PwNodeIface>(this, nullptr, &Pipewire::nodesCount, &Pipewire::nodeAt);
}
qsizetype Pipewire::nodesCount(QQmlListProperty<PwNodeIface>* property) {
return static_cast<Pipewire*>(property->object)->mNodes.count(); // NOLINT
}
PwNodeIface* Pipewire::nodeAt(QQmlListProperty<PwNodeIface>* property, qsizetype index) {
return static_cast<Pipewire*>(property->object)->mNodes.at(index); // NOLINT
}
void Pipewire::onNodeAdded(PwNode* node) {
auto* iface = PwNodeIface::instance(node);
QObject::connect(iface, &QObject::destroyed, this, &Pipewire::onNodeRemoved);
this->mNodes.push_back(iface);
emit this->nodesChanged();
}
void Pipewire::onNodeRemoved(QObject* object) {
auto* iface = static_cast<PwNodeIface*>(object); // NOLINT
this->mNodes.removeOne(iface);
emit this->nodesChanged();
}
QQmlListProperty<PwLinkIface> Pipewire::links() {
return QQmlListProperty<PwLinkIface>(this, nullptr, &Pipewire::linksCount, &Pipewire::linkAt);
}
qsizetype Pipewire::linksCount(QQmlListProperty<PwLinkIface>* property) {
return static_cast<Pipewire*>(property->object)->mLinks.count(); // NOLINT
}
PwLinkIface* Pipewire::linkAt(QQmlListProperty<PwLinkIface>* property, qsizetype index) {
return static_cast<Pipewire*>(property->object)->mLinks.at(index); // NOLINT
}
void Pipewire::onLinkAdded(PwLink* link) {
auto* iface = PwLinkIface::instance(link);
QObject::connect(iface, &QObject::destroyed, this, &Pipewire::onLinkRemoved);
this->mLinks.push_back(iface);
emit this->linksChanged();
}
void Pipewire::onLinkRemoved(QObject* object) {
auto* iface = static_cast<PwLinkIface*>(object); // NOLINT
this->mLinks.removeOne(iface);
emit this->linksChanged();
}
QQmlListProperty<PwLinkGroupIface> Pipewire::linkGroups() {
return QQmlListProperty<PwLinkGroupIface>(
this,
nullptr,
&Pipewire::linkGroupsCount,
&Pipewire::linkGroupAt
);
}
qsizetype Pipewire::linkGroupsCount(QQmlListProperty<PwLinkGroupIface>* property) {
return static_cast<Pipewire*>(property->object)->mLinkGroups.count(); // NOLINT
}
PwLinkGroupIface*
Pipewire::linkGroupAt(QQmlListProperty<PwLinkGroupIface>* property, qsizetype index) {
return static_cast<Pipewire*>(property->object)->mLinkGroups.at(index); // NOLINT
}
void Pipewire::onLinkGroupAdded(PwLinkGroup* linkGroup) {
auto* iface = PwLinkGroupIface::instance(linkGroup);
QObject::connect(iface, &QObject::destroyed, this, &Pipewire::onLinkGroupRemoved);
this->mLinkGroups.push_back(iface);
emit this->linkGroupsChanged();
}
void Pipewire::onLinkGroupRemoved(QObject* object) {
auto* iface = static_cast<PwLinkGroupIface*>(object); // NOLINT
this->mLinkGroups.removeOne(iface);
emit this->linkGroupsChanged();
}
PwNodeIface* Pipewire::defaultAudioSink() const { // NOLINT
auto* connection = PwConnection::instance();
auto name = connection->defaults.defaultSink();
for (auto* node: connection->registry.nodes.values()) {
if (name == node->name) {
return PwNodeIface::instance(node);
}
}
return nullptr;
}
PwNodeIface* Pipewire::defaultAudioSource() const { // NOLINT
auto* connection = PwConnection::instance();
auto name = connection->defaults.defaultSource();
for (auto* node: connection->registry.nodes.values()) {
if (name == node->name) {
return PwNodeIface::instance(node);
}
}
return nullptr;
}
PwNodeIface* PwNodeLinkTracker::node() const { return this->mNode; }
void PwNodeLinkTracker::setNode(PwNodeIface* node) {
if (node == this->mNode) return;
if (this->mNode != nullptr) {
if (node == nullptr) {
QObject::disconnect(&PwConnection::instance()->registry, nullptr, this, nullptr);
}
QObject::disconnect(this->mNode, nullptr, this, nullptr);
}
if (node != nullptr) {
if (this->mNode == nullptr) {
QObject::connect(
&PwConnection::instance()->registry,
&PwRegistry::linkGroupAdded,
this,
&PwNodeLinkTracker::onLinkGroupCreated
);
}
QObject::connect(node, &QObject::destroyed, this, &PwNodeLinkTracker::onNodeDestroyed);
}
this->mNode = node;
this->updateLinks();
emit this->nodeChanged();
}
void PwNodeLinkTracker::updateLinks() {
// done first to avoid unref->reref of nodes
auto newLinks = QVector<PwLinkGroupIface*>();
if (this->mNode != nullptr) {
auto* connection = PwConnection::instance();
for (auto* link: connection->registry.linkGroups) {
if ((!this->mNode->isSink() && link->outputNode() == this->mNode->id())
|| (this->mNode->isSink() && link->inputNode() == this->mNode->id()))
{
auto* iface = PwLinkGroupIface::instance(link);
// do not connect twice
if (!this->mLinkGroups.contains(iface)) {
QObject::connect(
iface,
&QObject::destroyed,
this,
&PwNodeLinkTracker::onLinkGroupDestroyed
);
}
newLinks.push_back(iface);
}
}
}
for (auto* iface: this->mLinkGroups) {
// only disconnect no longer used nodes
if (!newLinks.contains(iface)) {
QObject::disconnect(iface, nullptr, this, nullptr);
}
}
this->mLinkGroups = newLinks;
emit this->linkGroupsChanged();
}
QQmlListProperty<PwLinkGroupIface> PwNodeLinkTracker::linkGroups() {
return QQmlListProperty<PwLinkGroupIface>(
this,
nullptr,
&PwNodeLinkTracker::linkGroupsCount,
&PwNodeLinkTracker::linkGroupAt
);
}
qsizetype PwNodeLinkTracker::linkGroupsCount(QQmlListProperty<PwLinkGroupIface>* property) {
return static_cast<PwNodeLinkTracker*>(property->object)->mLinkGroups.count(); // NOLINT
}
PwLinkGroupIface*
PwNodeLinkTracker::linkGroupAt(QQmlListProperty<PwLinkGroupIface>* property, qsizetype index) {
return static_cast<PwNodeLinkTracker*>(property->object)->mLinkGroups.at(index); // NOLINT
}
void PwNodeLinkTracker::onNodeDestroyed() {
this->mNode = nullptr;
this->updateLinks();
emit this->nodeChanged();
}
void PwNodeLinkTracker::onLinkGroupCreated(PwLinkGroup* linkGroup) {
if ((!this->mNode->isSink() && linkGroup->outputNode() == this->mNode->id())
|| (this->mNode->isSink() && linkGroup->inputNode() == this->mNode->id()))
{
auto* iface = PwLinkGroupIface::instance(linkGroup);
QObject::connect(iface, &QObject::destroyed, this, &PwNodeLinkTracker::onLinkGroupDestroyed);
this->mLinkGroups.push_back(iface);
emit this->linkGroupsChanged();
}
}
void PwNodeLinkTracker::onLinkGroupDestroyed(QObject* object) {
if (this->mLinkGroups.removeOne(object)) {
emit this->linkGroupsChanged();
}
}
PwNodeAudioIface::PwNodeAudioIface(PwNodeBoundAudio* boundData, QObject* parent)
: QObject(parent)
, boundData(boundData) {
// clang-format off
QObject::connect(boundData, &PwNodeBoundAudio::mutedChanged, this, &PwNodeAudioIface::mutedChanged);
QObject::connect(boundData, &PwNodeBoundAudio::channelsChanged, this, &PwNodeAudioIface::channelsChanged);
QObject::connect(boundData, &PwNodeBoundAudio::volumesChanged, this, &PwNodeAudioIface::volumesChanged);
// clang-format on
}
bool PwNodeAudioIface::isMuted() const { return this->boundData->isMuted(); }
void PwNodeAudioIface::setMuted(bool muted) { this->boundData->setMuted(muted); }
float PwNodeAudioIface::averageVolume() const { return this->boundData->averageVolume(); }
void PwNodeAudioIface::setAverageVolume(float volume) { this->boundData->setAverageVolume(volume); }
QVector<PwAudioChannel::Enum> PwNodeAudioIface::channels() const {
return this->boundData->channels();
}
QVector<float> PwNodeAudioIface::volumes() const { return this->boundData->volumes(); }
void PwNodeAudioIface::setVolumes(const QVector<float>& volumes) {
this->boundData->setVolumes(volumes);
}
PwNodeIface::PwNodeIface(PwNode* node): PwObjectIface(node), mNode(node) {
QObject::connect(node, &PwNode::propertiesChanged, this, &PwNodeIface::propertiesChanged);
if (auto* audioBoundData = dynamic_cast<PwNodeBoundAudio*>(node->boundData)) {
this->audioIface = new PwNodeAudioIface(audioBoundData, this);
}
}
PwNode* PwNodeIface::node() const { return this->mNode; }
QString PwNodeIface::name() const { return this->mNode->name; }
quint32 PwNodeIface::id() const { return this->mNode->id; }
QString PwNodeIface::description() const { return this->mNode->description; }
QString PwNodeIface::nickname() const { return this->mNode->nick; }
bool PwNodeIface::isSink() const { return this->mNode->isSink; }
bool PwNodeIface::isStream() const { return this->mNode->isStream; }
QVariantMap PwNodeIface::properties() const {
auto map = QVariantMap();
for (auto [k, v]: this->mNode->properties.asKeyValueRange()) {
map.insert(k, QVariant::fromValue(v));
}
return map;
}
PwNodeAudioIface* PwNodeIface::audio() const { return this->audioIface; }
PwNodeIface* PwNodeIface::instance(PwNode* node) {
auto v = node->property("iface");
if (v.canConvert<PwNodeIface*>()) {
return v.value<PwNodeIface*>();
}
auto* instance = new PwNodeIface(node);
node->setProperty("iface", QVariant::fromValue(instance));
return instance;
}
PwLinkIface::PwLinkIface(PwLink* link): PwObjectIface(link), mLink(link) {
QObject::connect(link, &PwLink::stateChanged, this, &PwLinkIface::stateChanged);
}
PwLink* PwLinkIface::link() const { return this->mLink; }
quint32 PwLinkIface::id() const { return this->mLink->id; }
PwNodeIface* PwLinkIface::target() const {
return PwNodeIface::instance(
PwConnection::instance()->registry.nodes.value(this->mLink->inputNode())
);
}
PwNodeIface* PwLinkIface::source() const {
return PwNodeIface::instance(
PwConnection::instance()->registry.nodes.value(this->mLink->outputNode())
);
}
PwLinkState::Enum PwLinkIface::state() const { return this->mLink->state(); }
PwLinkIface* PwLinkIface::instance(PwLink* link) {
auto v = link->property("iface");
if (v.canConvert<PwLinkIface*>()) {
return v.value<PwLinkIface*>();
}
auto* instance = new PwLinkIface(link);
link->setProperty("iface", QVariant::fromValue(instance));
return instance;
}
PwLinkGroupIface::PwLinkGroupIface(PwLinkGroup* group): QObject(group), mGroup(group) {
QObject::connect(group, &PwLinkGroup::stateChanged, this, &PwLinkGroupIface::stateChanged);
QObject::connect(group, &QObject::destroyed, this, [this]() { delete this; });
}
void PwLinkGroupIface::ref() { this->mGroup->ref(); }
void PwLinkGroupIface::unref() { this->mGroup->unref(); }
PwLinkGroup* PwLinkGroupIface::group() const { return this->mGroup; }
PwNodeIface* PwLinkGroupIface::target() const {
return PwNodeIface::instance(
PwConnection::instance()->registry.nodes.value(this->mGroup->inputNode())
);
}
PwNodeIface* PwLinkGroupIface::source() const {
return PwNodeIface::instance(
PwConnection::instance()->registry.nodes.value(this->mGroup->outputNode())
);
}
PwLinkState::Enum PwLinkGroupIface::state() const { return this->mGroup->state(); }
PwLinkGroupIface* PwLinkGroupIface::instance(PwLinkGroup* group) {
auto v = group->property("iface");
if (v.canConvert<PwLinkGroupIface*>()) {
return v.value<PwLinkGroupIface*>();
}
auto* instance = new PwLinkGroupIface(group);
group->setProperty("iface", QVariant::fromValue(instance));
return instance;
}
PwObjectTracker::~PwObjectTracker() { this->clearList(); }
QList<QObject*> PwObjectTracker::objects() const { return this->trackedObjects; }
void PwObjectTracker::setObjects(const QList<QObject*>& objects) {
// +1 ref before removing old refs to avoid an unbind->bind.
for (auto* object: objects) {
if (auto* pwObject = dynamic_cast<PwObjectRefIface*>(object)) {
pwObject->ref();
}
}
this->clearList();
// connect destroy
for (auto* object: objects) {
if (auto* pwObject = dynamic_cast<PwObjectRefIface*>(object)) {
QObject::connect(object, &QObject::destroyed, this, &PwObjectTracker::objectDestroyed);
}
}
this->trackedObjects = objects;
}
void PwObjectTracker::clearList() {
for (auto* object: this->trackedObjects) {
if (auto* pwObject = dynamic_cast<PwObjectRefIface*>(object)) {
pwObject->unref();
QObject::disconnect(object, nullptr, this, nullptr);
}
}
this->trackedObjects.clear();
}
void PwObjectTracker::objectDestroyed(QObject* object) {
this->trackedObjects.removeOne(object);
emit this->objectsChanged();
}
} // namespace qs::service::pipewire

View file

@ -0,0 +1,368 @@
#pragma once
#include <qcontainerfwd.h>
#include <qobject.h>
#include <qqmlintegration.h>
#include <qqmllist.h>
#include <qtclasshelpermacros.h>
#include <qtmetamacros.h>
#include <qtypes.h>
#include "link.hpp"
#include "node.hpp"
#include "registry.hpp"
namespace qs::service::pipewire {
class PwNodeIface;
class PwLinkIface;
class PwLinkGroupIface;
class PwObjectRefIface {
public:
PwObjectRefIface() = default;
virtual ~PwObjectRefIface() = default;
Q_DISABLE_COPY_MOVE(PwObjectRefIface);
virtual void ref() = 0;
virtual void unref() = 0;
};
class PwObjectIface
: public QObject
, public PwObjectRefIface {
Q_OBJECT;
public:
explicit PwObjectIface(PwBindableObject* object): QObject(object), object(object) {};
// destructor should ONLY be called by the pw object destructor, making an unref unnecessary
~PwObjectIface() override = default;
Q_DISABLE_COPY_MOVE(PwObjectIface);
void ref() override;
void unref() override;
private:
quint32 refcount = 0;
PwBindableObject* object;
};
///! Contains links to all pipewire objects.
class Pipewire: public QObject {
Q_OBJECT;
// clang-format off
/// All pipewire nodes.
Q_PROPERTY(QQmlListProperty<PwNodeIface> nodes READ nodes NOTIFY nodesChanged);
/// All pipewire links.
Q_PROPERTY(QQmlListProperty<PwLinkIface> links READ links NOTIFY linksChanged);
/// All pipewire link groups.
Q_PROPERTY(QQmlListProperty<PwLinkGroupIface> linkGroups READ linkGroups NOTIFY linkGroupsChanged);
/// The default audio sink or `null`.
Q_PROPERTY(PwNodeIface* defaultAudioSink READ defaultAudioSink NOTIFY defaultAudioSinkChanged);
/// The default audio source or `null`.
Q_PROPERTY(PwNodeIface* defaultAudioSource READ defaultAudioSource NOTIFY defaultAudioSourceChanged);
// clang-format on
QML_ELEMENT;
QML_SINGLETON;
public:
explicit Pipewire(QObject* parent = nullptr);
[[nodiscard]] QQmlListProperty<PwNodeIface> nodes();
[[nodiscard]] QQmlListProperty<PwLinkIface> links();
[[nodiscard]] QQmlListProperty<PwLinkGroupIface> linkGroups();
[[nodiscard]] PwNodeIface* defaultAudioSink() const;
[[nodiscard]] PwNodeIface* defaultAudioSource() const;
signals:
void nodesChanged();
void linksChanged();
void linkGroupsChanged();
void defaultAudioSinkChanged();
void defaultAudioSourceChanged();
private slots:
void onNodeAdded(PwNode* node);
void onNodeRemoved(QObject* object);
void onLinkAdded(PwLink* link);
void onLinkRemoved(QObject* object);
void onLinkGroupAdded(PwLinkGroup* group);
void onLinkGroupRemoved(QObject* object);
private:
static qsizetype nodesCount(QQmlListProperty<PwNodeIface>* property);
static PwNodeIface* nodeAt(QQmlListProperty<PwNodeIface>* property, qsizetype index);
static qsizetype linksCount(QQmlListProperty<PwLinkIface>* property);
static PwLinkIface* linkAt(QQmlListProperty<PwLinkIface>* property, qsizetype index);
static qsizetype linkGroupsCount(QQmlListProperty<PwLinkGroupIface>* property);
static PwLinkGroupIface*
linkGroupAt(QQmlListProperty<PwLinkGroupIface>* property, qsizetype index);
QVector<PwNodeIface*> mNodes;
QVector<PwLinkIface*> mLinks;
QVector<PwLinkGroupIface*> mLinkGroups;
};
///! Tracks all link connections to a given node.
class PwNodeLinkTracker: public QObject {
Q_OBJECT;
// clang-format off
/// The node to track connections to.
Q_PROPERTY(PwNodeIface* node READ node WRITE setNode NOTIFY nodeChanged);
/// Link groups connected to the given node.
///
/// If the node is a sink, links which target the node will be tracked.
/// If the node is a source, links which source the node will be tracked.
Q_PROPERTY(QQmlListProperty<PwLinkGroupIface> linkGroups READ linkGroups NOTIFY linkGroupsChanged);
// clang-format on
QML_ELEMENT;
public:
explicit PwNodeLinkTracker(QObject* parent = nullptr): QObject(parent) {}
[[nodiscard]] PwNodeIface* node() const;
void setNode(PwNodeIface* node);
[[nodiscard]] QQmlListProperty<PwLinkGroupIface> linkGroups();
signals:
void nodeChanged();
void linkGroupsChanged();
private slots:
void onNodeDestroyed();
void onLinkGroupCreated(PwLinkGroup* linkGroup);
void onLinkGroupDestroyed(QObject* object);
private:
static qsizetype linkGroupsCount(QQmlListProperty<PwLinkGroupIface>* property);
static PwLinkGroupIface*
linkGroupAt(QQmlListProperty<PwLinkGroupIface>* property, qsizetype index);
void updateLinks();
PwNodeIface* mNode = nullptr;
QVector<PwLinkGroupIface*> mLinkGroups;
};
///! Audio specific properties of pipewire nodes.
class PwNodeAudioIface: public QObject {
Q_OBJECT;
/// If the node is currently muted. Setting this property changes the mute state.
///
/// **This property is invalid unless the node is [bound](../pwobjecttracker).**
Q_PROPERTY(bool muted READ isMuted WRITE setMuted NOTIFY mutedChanged);
/// The average volume over all channels of the node.
/// Setting this property modifies the volume of all channels proportionately.
///
/// **This property is invalid unless the node is [bound](../pwobjecttracker).**
Q_PROPERTY(float volume READ averageVolume WRITE setAverageVolume NOTIFY volumesChanged);
/// The audio channels present on the node.
///
/// **This property is invalid unless the node is [bound](../pwobjecttracker).**
Q_PROPERTY(QVector<PwAudioChannel::Enum> channels READ channels NOTIFY channelsChanged);
/// The volumes of each audio channel individually. Each entry corrosponds to
/// the channel at the same index in `channels`. `volumes` and `channels` will always be
/// the same length.
///
/// **This property is invalid unless the node is [bound](../pwobjecttracker).**
Q_PROPERTY(QVector<float> volumes READ volumes WRITE setVolumes NOTIFY volumesChanged);
QML_NAMED_ELEMENT(PwNodeAudio);
QML_UNCREATABLE("PwNodeAudio cannot be created directly");
public:
explicit PwNodeAudioIface(PwNodeBoundAudio* boundData, QObject* parent);
[[nodiscard]] bool isMuted() const;
void setMuted(bool muted);
[[nodiscard]] float averageVolume() const;
void setAverageVolume(float volume);
[[nodiscard]] QVector<PwAudioChannel::Enum> channels() const;
[[nodiscard]] QVector<float> volumes() const;
void setVolumes(const QVector<float>& volumes);
signals:
void mutedChanged();
void channelsChanged();
void volumesChanged();
private:
PwNodeBoundAudio* boundData;
};
///! A node in the pipewire connection graph.
class PwNodeIface: public PwObjectIface {
Q_OBJECT;
/// The pipewire object id of the node.
///
/// Mainly useful for debugging. you can inspect the node directly
/// with `pw-cli i <id>`.
Q_PROPERTY(quint32 id READ id CONSTANT);
/// The node's name, corrosponding to the object's `node.name` property.
Q_PROPERTY(QString name READ name CONSTANT);
/// The node's description, corrosponding to the object's `node.description` property.
///
/// May be empty. Generally more human readable than `name`.
Q_PROPERTY(QString description READ description CONSTANT);
/// The node's nickname, corrosponding to the object's `node.nickname` property.
///
/// May be empty. Generally but not always more human readable than `description`.
Q_PROPERTY(QString nickname READ nickname CONSTANT);
/// If `true`, then the node accepts audio input from other nodes,
/// if `false` the node outputs audio to other nodes.
Q_PROPERTY(bool isSink READ isSink CONSTANT);
/// If `true` then the node is likely to be a program, if false it is liekly to be hardware.
Q_PROPERTY(bool isStream READ isStream CONSTANT);
/// The property set present on the node, as an object containing key-value pairs.
/// You can inspect this directly with `pw-cli i <id>`.
///
/// A few properties of note, which may or may not be present:
/// - `application.name` - A suggested human readable name for the node.
/// - `application.icon-name` - The name of an icon recommended to display for the node.
/// - `media.name` - A description of the currently playing media.
/// (more likely to be present than `media.title` and `media.artist`)
/// - `media.title` - The title of the currently playing media.
/// - `media.artist` - The artist of the currently playing media.
///
/// **This property is invalid unless the node is [bound](../pwobjecttracker).**
Q_PROPERTY(QVariantMap properties READ properties NOTIFY propertiesChanged);
/// Extra information present only if the node sends or receives audio.
Q_PROPERTY(PwNodeAudioIface* audio READ audio CONSTANT);
QML_NAMED_ELEMENT(PwNode);
QML_UNCREATABLE("PwNodes cannot be created directly");
public:
explicit PwNodeIface(PwNode* node);
[[nodiscard]] PwNode* node() const;
[[nodiscard]] quint32 id() const;
[[nodiscard]] QString name() const;
[[nodiscard]] QString description() const;
[[nodiscard]] QString nickname() const;
[[nodiscard]] bool isSink() const;
[[nodiscard]] bool isStream() const;
[[nodiscard]] QVariantMap properties() const;
[[nodiscard]] PwNodeAudioIface* audio() const;
static PwNodeIface* instance(PwNode* node);
signals:
void propertiesChanged();
private:
PwNode* mNode;
PwNodeAudioIface* audioIface = nullptr;
};
///! A connection between pipewire nodes.
/// Note that there is one link per *channel* of a connection between nodes.
/// You usually want [PwLinkGroup](../pwlinkgroup).
class PwLinkIface: public PwObjectIface {
Q_OBJECT;
/// The pipewire object id of the link.
///
/// Mainly useful for debugging. you can inspect the link directly
/// with `pw-cli i <id>`.
Q_PROPERTY(quint32 id READ id CONSTANT);
/// The node that is *receiving* information. (the sink)
Q_PROPERTY(PwNodeIface* target READ target CONSTANT);
/// The node that is *sending* information. (the source)
Q_PROPERTY(PwNodeIface* source READ source CONSTANT);
/// The current state of the link.
///
/// **This property is invalid unless the link is [bound](../pwobjecttracker).**
Q_PROPERTY(PwLinkState::Enum state READ state NOTIFY stateChanged);
QML_NAMED_ELEMENT(PwLink);
QML_UNCREATABLE("PwLinks cannot be created directly");
public:
explicit PwLinkIface(PwLink* link);
[[nodiscard]] PwLink* link() const;
[[nodiscard]] quint32 id() const;
[[nodiscard]] PwNodeIface* target() const;
[[nodiscard]] PwNodeIface* source() const;
[[nodiscard]] PwLinkState::Enum state() const;
static PwLinkIface* instance(PwLink* link);
signals:
void stateChanged();
private:
PwLink* mLink;
};
///! A group of connections between pipewire nodes.
/// A group of connections between pipewire nodes, one per source->target pair.
class PwLinkGroupIface
: public QObject
, public PwObjectRefIface {
Q_OBJECT;
/// The node that is *receiving* information. (the sink)
Q_PROPERTY(PwNodeIface* target READ target CONSTANT);
/// The node that is *sending* information. (the source)
Q_PROPERTY(PwNodeIface* source READ source CONSTANT);
/// The current state of the link group.
///
/// **This property is invalid unless the link is [bound](../pwobjecttracker).**
Q_PROPERTY(PwLinkState::Enum state READ state NOTIFY stateChanged);
QML_NAMED_ELEMENT(PwLinkGroup);
QML_UNCREATABLE("PwLinkGroups cannot be created directly");
public:
explicit PwLinkGroupIface(PwLinkGroup* group);
// destructor should ONLY be called by the pw object destructor, making an unref unnecessary
~PwLinkGroupIface() override = default;
Q_DISABLE_COPY_MOVE(PwLinkGroupIface);
void ref() override;
void unref() override;
[[nodiscard]] PwLinkGroup* group() const;
[[nodiscard]] PwNodeIface* target() const;
[[nodiscard]] PwNodeIface* source() const;
[[nodiscard]] PwLinkState::Enum state() const;
static PwLinkGroupIface* instance(PwLinkGroup* group);
signals:
void stateChanged();
private:
PwLinkGroup* mGroup;
};
///! Binds pipewire objects.
/// If the object list of at least one PwObjectTracker contains a given pipewire object,
/// it will become *bound* and you will be able to interact with bound-only properties.
class PwObjectTracker: public QObject {
Q_OBJECT;
/// The list of objects to bind.
Q_PROPERTY(QList<QObject*> objects READ objects WRITE setObjects NOTIFY objectsChanged);
QML_ELEMENT;
public:
explicit PwObjectTracker(QObject* parent = nullptr): QObject(parent) {}
~PwObjectTracker() override;
Q_DISABLE_COPY_MOVE(PwObjectTracker);
[[nodiscard]] QList<QObject*> objects() const;
void setObjects(const QList<QObject*>& objects);
signals:
void objectsChanged();
private slots:
void objectDestroyed(QObject* object);
private:
void clearList();
QList<QObject*> trackedObjects;
};
} // namespace qs::service::pipewire

View file

@ -0,0 +1,193 @@
#include "registry.hpp"
#include <cstring>
#include <pipewire/core.h>
#include <pipewire/extensions/metadata.h>
#include <pipewire/link.h>
#include <pipewire/node.h>
#include <pipewire/proxy.h>
#include <qdebug.h>
#include <qlogging.h>
#include <qloggingcategory.h>
#include <qobject.h>
#include <qtmetamacros.h>
#include <qtypes.h>
#include "core.hpp"
#include "link.hpp"
#include "metadata.hpp"
#include "node.hpp"
namespace qs::service::pipewire {
Q_LOGGING_CATEGORY(logRegistry, "quickshell.service.pipewire.registry", QtWarningMsg);
PwBindableObject::~PwBindableObject() {
if (this->id != 0) {
qCFatal(logRegistry) << "Destroyed pipewire object" << this
<< "without causing safeDestroy. THIS IS UNDEFINED BEHAVIOR.";
}
}
void PwBindableObject::init(PwRegistry* registry, quint32 id, quint32 perms) {
this->id = id;
this->perms = perms;
this->registry = registry;
this->setParent(registry);
qCDebug(logRegistry) << "Creating object" << this;
}
void PwBindableObject::safeDestroy() {
this->unbind();
qCDebug(logRegistry) << "Destroying object" << this;
emit this->destroying(this);
this->id = 0;
delete this;
}
void PwBindableObject::debugId(QDebug& debug) const {
auto saver = QDebugStateSaver(debug);
debug.nospace() << this->id << "/" << (this->object == nullptr ? "unbound" : "bound");
}
void PwBindableObject::ref() {
this->refcount++;
if (this->refcount == 1) this->bind();
}
void PwBindableObject::unref() {
this->refcount--;
if (this->refcount == 0) this->unbind();
}
void PwBindableObject::bind() {
qCDebug(logRegistry) << "Bound object" << this;
this->bindHooks();
}
void PwBindableObject::unbind() {
if (this->object == nullptr) return;
qCDebug(logRegistry) << "Unbinding object" << this;
this->unbindHooks();
pw_proxy_destroy(this->object);
this->object = nullptr;
}
QDebug operator<<(QDebug debug, const PwBindableObject* object) {
if (object == nullptr) {
debug << "PwBindableObject(0x0)";
} else {
auto saver = QDebugStateSaver(debug);
// 0 if not present, start of class name if present
auto idx = QString(object->metaObject()->className()).lastIndexOf(':') + 1;
debug.nospace() << (object->metaObject()->className() + idx) << '(' // NOLINT
<< static_cast<const void*>(object) << ", id=";
object->debugId(debug);
debug << ')';
}
return debug;
}
PwBindableObjectRef::PwBindableObjectRef(PwBindableObject* object) { this->setObject(object); }
PwBindableObjectRef::~PwBindableObjectRef() { this->setObject(nullptr); }
void PwBindableObjectRef::setObject(PwBindableObject* object) {
if (this->mObject != nullptr) {
this->mObject->unref();
QObject::disconnect(this->mObject, nullptr, this, nullptr);
}
this->mObject = object;
if (object != nullptr) {
this->mObject->ref();
QObject::connect(object, &QObject::destroyed, this, &PwBindableObjectRef::onObjectDestroyed);
}
}
void PwBindableObjectRef::onObjectDestroyed() {
// allow references to it so consumers can disconnect themselves
emit this->objectDestroyed();
this->mObject = nullptr;
}
void PwRegistry::init(PwCore& core) {
this->object = pw_core_get_registry(core.core, PW_VERSION_REGISTRY, 0);
pw_registry_add_listener(this->object, &this->listener.hook, &PwRegistry::EVENTS, this);
}
const pw_registry_events PwRegistry::EVENTS = {
.version = PW_VERSION_REGISTRY_EVENTS,
.global = &PwRegistry::onGlobal,
.global_remove = &PwRegistry::onGlobalRemoved,
};
void PwRegistry::onGlobal(
void* data,
quint32 id,
quint32 permissions,
const char* type,
quint32 /*version*/,
const spa_dict* props
) {
auto* self = static_cast<PwRegistry*>(data);
if (strcmp(type, PW_TYPE_INTERFACE_Metadata) == 0) {
auto* meta = new PwMetadata();
meta->init(self, id, permissions);
meta->initProps(props);
self->metadata.emplace(id, meta);
meta->bind();
} else if (strcmp(type, PW_TYPE_INTERFACE_Link) == 0) {
auto* link = new PwLink();
link->init(self, id, permissions);
link->initProps(props);
self->links.emplace(id, link);
self->addLinkToGroup(link);
emit self->linkAdded(link);
} else if (strcmp(type, PW_TYPE_INTERFACE_Node) == 0) {
auto* node = new PwNode();
node->init(self, id, permissions);
node->initProps(props);
self->nodes.emplace(id, node);
emit self->nodeAdded(node);
}
}
void PwRegistry::onGlobalRemoved(void* data, quint32 id) {
auto* self = static_cast<PwRegistry*>(data);
if (auto* meta = self->metadata.value(id)) {
self->metadata.remove(id);
meta->safeDestroy();
} else if (auto* link = self->links.value(id)) {
self->links.remove(id);
link->safeDestroy();
} else if (auto* node = self->nodes.value(id)) {
self->nodes.remove(id);
node->safeDestroy();
}
}
void PwRegistry::addLinkToGroup(PwLink* link) {
for (auto* group: this->linkGroups) {
if (group->tryAddLink(link)) return;
}
auto* group = new PwLinkGroup(link);
QObject::connect(group, &QObject::destroyed, this, &PwRegistry::onLinkGroupDestroyed);
this->linkGroups.push_back(group);
emit this->linkGroupAdded(group);
}
void PwRegistry::onLinkGroupDestroyed(QObject* object) {
auto* group = static_cast<PwLinkGroup*>(object); // NOLINT
this->linkGroups.removeOne(group);
}
} // namespace qs::service::pipewire

View file

@ -0,0 +1,160 @@
#pragma once
#include <pipewire/core.h>
#include <pipewire/proxy.h>
#include <qcontainerfwd.h>
#include <qdebug.h>
#include <qhash.h>
#include <qloggingcategory.h>
#include <qobject.h>
#include <qtclasshelpermacros.h>
#include <qtmetamacros.h>
#include <qtypes.h>
#include "core.hpp"
namespace qs::service::pipewire {
Q_DECLARE_LOGGING_CATEGORY(logRegistry);
class PwRegistry;
class PwMetadata;
class PwNode;
class PwLink;
class PwLinkGroup;
class PwBindableObject: public QObject {
Q_OBJECT;
public:
PwBindableObject() = default;
~PwBindableObject() override;
Q_DISABLE_COPY_MOVE(PwBindableObject);
// constructors and destructors can't do virtual calls.
virtual void init(PwRegistry* registry, quint32 id, quint32 perms);
virtual void initProps(const spa_dict* /*props*/) {}
virtual void safeDestroy();
quint32 id = 0;
quint32 perms = 0;
void debugId(QDebug& debug) const;
void ref();
void unref();
signals:
// goes with safeDestroy
void destroying(PwBindableObject* self);
protected:
virtual void bind();
void unbind();
virtual void bindHooks() {};
virtual void unbindHooks() {};
quint32 refcount = 0;
pw_proxy* object = nullptr;
PwRegistry* registry = nullptr;
};
QDebug operator<<(QDebug debug, const PwBindableObject* object);
template <typename T, const char* INTERFACE, quint32 VERSION>
class PwBindable: public PwBindableObject {
public:
T* proxy() {
return reinterpret_cast<T*>(this->object); // NOLINT
}
protected:
void bind() override {
if (this->object != nullptr) return;
auto* object =
pw_registry_bind(this->registry->object, this->id, INTERFACE, VERSION, 0); // NOLINT
this->object = static_cast<pw_proxy*>(object);
this->PwBindableObject::bind();
}
friend class PwRegistry;
};
class PwBindableObjectRef: public QObject {
Q_OBJECT;
public:
explicit PwBindableObjectRef(PwBindableObject* object = nullptr);
~PwBindableObjectRef() override;
Q_DISABLE_COPY_MOVE(PwBindableObjectRef);
signals:
void objectDestroyed();
private slots:
void onObjectDestroyed();
protected:
void setObject(PwBindableObject* object);
PwBindableObject* mObject = nullptr;
};
template <typename T>
class PwBindableRef: public PwBindableObjectRef {
public:
explicit PwBindableRef(T* object = nullptr): PwBindableObjectRef(object) {}
void setObject(T* object) { this->PwBindableObjectRef::setObject(object); }
T* object() { return this->mObject; }
};
class PwRegistry
: public QObject
, public PwObject<pw_registry> {
Q_OBJECT;
public:
void init(PwCore& core);
//QHash<quint32, PwClient*> clients;
QHash<quint32, PwMetadata*> metadata;
QHash<quint32, PwNode*> nodes;
QHash<quint32, PwLink*> links;
QVector<PwLinkGroup*> linkGroups;
signals:
void nodeAdded(PwNode* node);
void linkAdded(PwLink* link);
void linkGroupAdded(PwLinkGroup* group);
void metadataUpdate(
PwMetadata* owner,
quint32 subject,
const char* key,
const char* type,
const char* value
);
private slots:
void onLinkGroupDestroyed(QObject* object);
private:
static const pw_registry_events EVENTS;
static void onGlobal(
void* data,
quint32 id,
quint32 permissions,
const char* type,
quint32 version,
const spa_dict* props
);
static void onGlobalRemoved(void* data, quint32 id);
void addLinkToGroup(PwLink* link);
SpaHook listener;
};
} // namespace qs::service::pipewire

View file

@ -1,4 +1,4 @@
name = "Quickshell.Service.SystemTray" name = "Quickshell.Services.SystemTray"
description = "Types for implementing a system tray" description = "Types for implementing a system tray"
headers = [ "qml.hpp" ] headers = [ "qml.hpp" ]
----- -----

View file

@ -68,6 +68,10 @@ if (WAYLAND_SESSION_LOCK)
add_subdirectory(session_lock) add_subdirectory(session_lock)
endif() endif()
if (HYPRLAND)
add_subdirectory(hyprland)
endif()
target_link_libraries(quickshell-wayland PRIVATE ${QT_DEPS}) target_link_libraries(quickshell-wayland PRIVATE ${QT_DEPS})
target_link_libraries(quickshell-wayland-init PRIVATE ${QT_DEPS}) target_link_libraries(quickshell-wayland-init PRIVATE ${QT_DEPS})

View file

@ -0,0 +1,17 @@
qt_add_library(quickshell-hyprland STATIC)
qt_add_qml_module(quickshell-hyprland URI Quickshell.Hyprland VERSION 0.1)
if (HYPRLAND_FOCUS_GRAB)
add_subdirectory(focus_grab)
endif()
if (HYPRLAND_GLOBAL_SHORTCUTS)
add_subdirectory(global_shortcuts)
endif()
target_link_libraries(quickshell-hyprland PRIVATE ${QT_DEPS})
qs_pch(quickshell-hyprland)
qs_pch(quickshell-hyprlandplugin)
target_link_libraries(quickshell PRIVATE quickshell-hyprlandplugin)

View file

@ -0,0 +1,29 @@
qt_add_library(quickshell-hyprland-focus-grab STATIC
manager.cpp
grab.cpp
qml.cpp
)
qt_add_qml_module(quickshell-hyprland-focus-grab
URI Quickshell.Hyprland._FocusGrab
VERSION 0.1
)
add_library(quickshell-hyprland-focus-grab-init OBJECT init.cpp)
wl_proto(quickshell-hyprland-focus-grab
hyprland-focus-grab-v1
"${CMAKE_CURRENT_SOURCE_DIR}/hyprland-focus-grab-v1.xml"
)
target_link_libraries(quickshell-hyprland-focus-grab PRIVATE ${QT_DEPS} wayland-client)
target_link_libraries(quickshell-hyprland-focus-grab-init PRIVATE ${QT_DEPS})
qs_pch(quickshell-hyprland-focus-grab)
qs_pch(quickshell-hyprland-focus-grabplugin)
qs_pch(quickshell-hyprland-focus-grab-init)
target_link_libraries(quickshell PRIVATE
quickshell-hyprland-focus-grabplugin
quickshell-hyprland-focus-grab-init
)

View file

@ -0,0 +1,78 @@
#include "grab.hpp"
#include <private/qwaylandwindow_p.h>
#include <qobject.h>
#include <qtmetamacros.h>
#include <qwindow.h>
#include <wayland-hyprland-focus-grab-v1-client-protocol.h>
namespace qs::hyprland::focus_grab {
FocusGrab::FocusGrab(::hyprland_focus_grab_v1* grab) { this->init(grab); }
FocusGrab::~FocusGrab() {
if (this->isInitialized()) {
this->destroy();
}
}
bool FocusGrab::isActive() const { return this->active; }
void FocusGrab::addWindow(QWindow* window) {
if (auto* waylandWindow = dynamic_cast<QWaylandWindow*>(window->handle())) {
this->addWaylandWindow(waylandWindow);
} else {
QObject::connect(window, &QWindow::visibleChanged, this, [this, window]() {
if (window->isVisible()) {
if (window->handle() == nullptr) {
window->create();
}
auto* waylandWindow = dynamic_cast<QWaylandWindow*>(window->handle());
this->addWaylandWindow(waylandWindow);
this->sync();
}
});
}
}
void FocusGrab::removeWindow(QWindow* window) {
QObject::disconnect(window, nullptr, this, nullptr);
if (auto* waylandWindow = dynamic_cast<QWaylandWindow*>(window->handle())) {
this->pendingAdditions.removeAll(waylandWindow);
this->remove_surface(waylandWindow->surface());
this->commitRequired = true;
}
}
void FocusGrab::addWaylandWindow(QWaylandWindow* window) {
this->add_surface(window->surface());
this->pendingAdditions.append(window);
this->commitRequired = true;
}
void FocusGrab::sync() {
if (this->commitRequired) {
this->commit();
this->commitRequired = false;
// the protocol will always send cleared() when the grab is deactivated,
// even if it was due to window destruction, so we don't need to track it.
if (!this->pendingAdditions.isEmpty()) {
this->pendingAdditions.clear();
if (!this->active) {
this->active = true;
emit this->activated();
}
}
}
}
void FocusGrab::hyprland_focus_grab_v1_cleared() {
this->active = false;
emit this->cleared();
}
} // namespace qs::hyprland::focus_grab

View file

@ -0,0 +1,46 @@
#pragma once
#include <private/qwaylandwindow_p.h>
#include <qlist.h>
#include <qobject.h>
#include <qtclasshelpermacros.h>
#include <qtmetamacros.h>
#include <qwayland-hyprland-focus-grab-v1.h>
#include <qwindow.h>
#include <wayland-hyprland-focus-grab-v1-client-protocol.h>
namespace qs::hyprland::focus_grab {
using HyprlandFocusGrab = QtWayland::hyprland_focus_grab_v1;
class FocusGrab
: public QObject
, public HyprlandFocusGrab {
using QWaylandWindow = QtWaylandClient::QWaylandWindow;
Q_OBJECT;
public:
explicit FocusGrab(::hyprland_focus_grab_v1* grab);
~FocusGrab() override;
Q_DISABLE_COPY_MOVE(FocusGrab);
[[nodiscard]] bool isActive() const;
void addWindow(QWindow* window);
void removeWindow(QWindow* window);
void sync();
signals:
void activated();
void cleared();
private:
void hyprland_focus_grab_v1_cleared() override;
void addWaylandWindow(QWaylandWindow* window);
QList<QWaylandWindow*> pendingAdditions;
bool commitRequired = false;
bool active = false;
};
} // namespace qs::hyprland::focus_grab

View file

@ -0,0 +1,128 @@
<?xml version="1.0" encoding="UTF-8"?>
<protocol name="hyprland_focus_grab_v1">
<copyright>
Copyright © 2024 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.
</copyright>
<description summary="limit input focus to a set of surfaces">
This protocol allows clients to limit input focus to a specific set
of surfaces and receive a notification when the limiter is removed as
detailed below.
</description>
<interface name="hyprland_focus_grab_manager_v1" version="1">
<description summary="manager for focus grab objects">
This interface allows a client to create surface grab objects.
</description>
<request name="create_grab">
<description summary="create a focus grab object">
Create a surface grab object.
</description>
<arg name="grab" type="new_id" interface="hyprland_focus_grab_v1"/>
</request>
<request name="destroy" type="destructor">
<description summary="destroy the focus grab manager">
Destroy the focus grab manager.
This doesn't destroy existing focus grab objects.
</description>
</request>
</interface>
<interface name="hyprland_focus_grab_v1" version="1">
<description summary="input focus limiter">
This interface restricts input focus to a specified whitelist of
surfaces as long as the focus grab object exists and has at least
one comitted surface.
Mouse and touch events inside a whitelisted surface will be passed
to the surface normally, while events outside of a whitelisted surface
will clear the grab object. Keyboard events will be passed to the client
and a compositor-picked surface in the whitelist will receive a
wl_keyboard::enter event if a whitelisted surface is not already entered.
Upon meeting implementation-defined criteria usually meaning a mouse or
touch input outside of any whitelisted surfaces, the compositor will
clear the whitelist, rendering the grab inert and sending the cleared
event. The same will happen if another focus grab or similar action
is started at the compositor's discretion.
</description>
<request name="add_surface">
<description summary="add a surface to the focus whitelist">
Add a surface to the whitelist. Destroying the surface is treated the
same as an explicit call to remove_surface and duplicate additions are
ignored.
Does not take effect until commit is called.
</description>
<arg name="surface" type="object" interface="wl_surface"/>
</request>
<request name="remove_surface">
<description summary="remove a surface from the focus whitelist">
Remove a surface from the whitelist. Destroying the surface is treated
the same as an explicit call to this function.
If the grab was active and the removed surface was entered by the
keyboard, another surface will be entered on commit.
Does not take effect until commit is called.
</description>
<arg name="surface" type="object" interface="wl_surface"/>
</request>
<request name="commit">
<description summary="commit the focus whitelist">
Commit pending changes to the surface whitelist.
If the list previously had no entries and now has at least one, the grab
will start. If it previously had entries and now has none, the grab will
become inert.
</description>
</request>
<request name="destroy" type="destructor">
<description summary="destroy the focus grab">
Destroy the grab object and remove the grab if active.
</description>
</request>
<event name="cleared">
<description summary="the focus grab was cleared">
Sent when an active grab is cancelled by the compositor,
regardless of cause.
</description>
</event>
</interface>
</protocol>

View file

@ -0,0 +1,20 @@
#include <qqml.h>
#include "../../../core/plugin.hpp"
namespace {
class HyprlandFocusGrabPlugin: public QuickshellPlugin {
void registerTypes() override {
qmlRegisterModuleImport(
"Quickshell.Hyprland",
QQmlModuleImportModuleAny,
"Quickshell.Hyprland._FocusGrab",
QQmlModuleImportLatest
);
}
};
QS_REGISTER_PLUGIN(HyprlandFocusGrabPlugin);
} // namespace

View file

@ -0,0 +1,27 @@
#include "manager.hpp"
#include <qwaylandclientextension.h>
#include "grab.hpp"
namespace qs::hyprland::focus_grab {
FocusGrabManager::FocusGrabManager(): QWaylandClientExtensionTemplate<FocusGrabManager>(1) {
this->initialize();
}
bool FocusGrabManager::available() const { return this->isActive(); }
FocusGrab* FocusGrabManager::createGrab() { return new FocusGrab(this->create_grab()); }
FocusGrabManager* FocusGrabManager::instance() {
static FocusGrabManager* instance = nullptr; // NOLINT
if (instance == nullptr) {
instance = new FocusGrabManager();
}
return instance;
}
} // namespace qs::hyprland::focus_grab

View file

@ -0,0 +1,22 @@
#pragma once
#include <qwayland-hyprland-focus-grab-v1.h>
#include <qwaylandclientextension.h>
namespace qs::hyprland::focus_grab {
using HyprlandFocusGrabManager = QtWayland::hyprland_focus_grab_manager_v1;
class FocusGrab;
class FocusGrabManager
: public QWaylandClientExtensionTemplate<FocusGrabManager>
, public HyprlandFocusGrabManager {
public:
explicit FocusGrabManager();
[[nodiscard]] bool available() const;
[[nodiscard]] FocusGrab* createGrab();
static FocusGrabManager* instance();
};
} // namespace qs::hyprland::focus_grab

View file

@ -0,0 +1,131 @@
#include "qml.hpp"
#include <utility>
#include <qlist.h>
#include <qlogging.h>
#include <qobject.h>
#include <qqmllist.h>
#include <qtmetamacros.h>
#include <qwindow.h>
#include "../../../core/proxywindow.hpp"
#include "../../../core/windowinterface.hpp"
#include "grab.hpp"
#include "manager.hpp"
namespace qs::hyprland {
using focus_grab::FocusGrab;
using focus_grab::FocusGrabManager;
void HyprlandFocusGrab::componentComplete() { this->tryActivate(); }
bool HyprlandFocusGrab::isActive() const { return this->grab != nullptr && this->grab->isActive(); }
void HyprlandFocusGrab::setActive(bool active) {
if (active == this->targetActive) return;
this->targetActive = active;
if (!active) {
delete this->grab;
this->grab = nullptr;
emit this->activeChanged();
} else {
this->tryActivate();
}
}
QObjectList HyprlandFocusGrab::windows() const { return this->windowObjects; }
void HyprlandFocusGrab::setWindows(QObjectList windows) {
if (windows == this->windowObjects) return;
this->windowObjects = std::move(windows);
this->syncWindows();
emit this->windowsChanged();
}
void HyprlandFocusGrab::onGrabActivated() { emit this->activeChanged(); }
void HyprlandFocusGrab::onGrabCleared() {
emit this->cleared();
this->setActive(false);
}
void HyprlandFocusGrab::onProxyConnected() {
if (this->grab != nullptr) {
this->grab->addWindow(qobject_cast<ProxyWindowBase*>(this->sender())->backingWindow());
this->grab->sync();
}
}
void HyprlandFocusGrab::tryActivate() {
if (!this->targetActive || this->isActive()) return;
auto* manager = FocusGrabManager::instance();
if (!manager->isActive()) {
qWarning() << "The active compositor does not support the hyprland_focus_grab_v1 protocol. "
"HyprlandFocusGrab will not work.";
qWarning() << "** Learn why $XDG_CURRENT_DESKTOP sucks and download a better compositor "
"today at https://hyprland.org";
return;
}
this->grab = manager->createGrab();
this->grab->setParent(this);
QObject::connect(this->grab, &FocusGrab::activated, this, &HyprlandFocusGrab::onGrabActivated);
QObject::connect(this->grab, &FocusGrab::cleared, this, &HyprlandFocusGrab::onGrabCleared);
for (auto* proxy: this->trackedProxies) {
if (proxy->backingWindow() != nullptr) {
this->grab->addWindow(proxy->backingWindow());
}
}
this->grab->sync();
}
void HyprlandFocusGrab::syncWindows() {
auto newProxy = QList<ProxyWindowBase*>();
for (auto* windowObject: this->windowObjects) {
auto* proxyWindow = qobject_cast<ProxyWindowBase*>(windowObject);
if (proxyWindow == nullptr) {
if (auto* iface = qobject_cast<WindowInterface*>(windowObject)) {
proxyWindow = iface->proxyWindow();
}
}
if (proxyWindow != nullptr) {
newProxy.push_back(proxyWindow);
}
}
for (auto* oldWindow: this->trackedProxies) {
if (!newProxy.contains(oldWindow)) {
QObject::disconnect(oldWindow, nullptr, this, nullptr);
if (this->grab != nullptr && oldWindow->backingWindow() != nullptr) {
this->grab->removeWindow(oldWindow->backingWindow());
}
}
}
for (auto* newProxy: newProxy) {
if (!this->trackedProxies.contains(newProxy)) {
QObject::connect(
newProxy,
&ProxyWindowBase::windowConnected,
this,
&HyprlandFocusGrab::onProxyConnected
);
if (this->grab != nullptr && newProxy->backingWindow() != nullptr) {
this->grab->addWindow(newProxy->backingWindow());
}
}
}
this->trackedProxies = newProxy;
if (this->grab != nullptr) this->grab->sync();
}
} // namespace qs::hyprland

View file

@ -0,0 +1,111 @@
#pragma once
#include <qlist.h>
#include <qobject.h>
#include <qqmlintegration.h>
#include <qqmllist.h>
#include <qqmlparserstatus.h>
#include <qtmetamacros.h>
#include <qtypes.h>
#include <qwindow.h>
class ProxyWindowBase;
namespace qs::hyprland {
namespace focus_grab {
class FocusGrab;
}
///! Input focus grabber
/// Object for managing input focus grabs via the [hyprland_focus_grab_v1]
/// wayland protocol.
///
/// When enabled, all of the windows listed in the `windows` property will
/// receive input normally, and will retain keyboard focus even if the mouse
/// is moved off of them. When areas of the screen that are not part of a listed
/// window are clicked or touched, the grab will become inactive and emit the
/// cleared signal.
///
/// This is useful for implementing dismissal of popup type windows.
/// ```qml
/// import Quickshell
/// import Quickshell.Hyprland
/// import QtQuick.Controls
///
/// ShellRoot {
/// FloatingWindow {
/// id: window
///
/// Button {
/// anchors.centerIn: parent
/// text: grab.active ? "Remove exclusive focus" : "Take exclusive focus"
/// onClicked: grab.active = !grab.active
/// }
///
/// HyprlandFocusGrab {
/// id: grab
/// windows: [ window ]
/// }
/// }
/// }
/// ```
///
/// [hyprland_focus_grab_v1]: https://github.com/hyprwm/hyprland-protocols/blob/main/protocols/hyprland-global-shortcuts-v1.xml
class HyprlandFocusGrab
: public QObject
, public QQmlParserStatus {
Q_OBJECT;
/// If the focus grab is active. Defaults to false.
///
/// When set to true, an input grab will be created for the listed windows.
///
/// This property will change to false once the grab is dismissed.
/// It will not change to true until the grab begins, which requires
/// at least one visible window.
Q_PROPERTY(bool active READ isActive WRITE setActive NOTIFY activeChanged);
/// The list of windows to whitelist for input.
Q_PROPERTY(QList<QObject*> windows READ windows WRITE setWindows NOTIFY windowsChanged);
QML_ELEMENT;
public:
explicit HyprlandFocusGrab(QObject* parent = nullptr): QObject(parent) {}
void classBegin() override {}
void componentComplete() override;
[[nodiscard]] bool isActive() const;
void setActive(bool active);
[[nodiscard]] QObjectList windows() const;
void setWindows(QObjectList windows);
signals:
/// Sent whenever the compositor clears the focus grab.
///
/// This may be in response to all windows being removed
/// from the list or simultaneously hidden, in addition to
/// a normal clear.
void cleared();
void activeChanged();
void windowsChanged();
private slots:
void onGrabActivated();
void onGrabCleared();
void onProxyConnected();
private:
void tryActivate();
void syncWindows();
bool targetActive = false;
QObjectList windowObjects;
QList<ProxyWindowBase*> trackedProxies;
QList<QWindow*> trackedWindows;
focus_grab::FocusGrab* grab = nullptr;
};
}; // namespace qs::hyprland

View file

@ -0,0 +1,29 @@
qt_add_library(quickshell-hyprland-global-shortcuts STATIC
qml.cpp
manager.cpp
shortcut.cpp
)
qt_add_qml_module(quickshell-hyprland-global-shortcuts
URI Quickshell.Hyprland._GlobalShortcuts
VERSION 0.1
)
add_library(quickshell-hyprland-global-shortcuts-init OBJECT init.cpp)
wl_proto(quickshell-hyprland-global-shortcuts
hyprland-global-shortcuts-v1
"${CMAKE_CURRENT_SOURCE_DIR}/hyprland-global-shortcuts-v1.xml"
)
target_link_libraries(quickshell-hyprland-global-shortcuts PRIVATE ${QT_DEPS} wayland-client)
target_link_libraries(quickshell-hyprland-global-shortcuts-init PRIVATE ${QT_DEPS})
qs_pch(quickshell-hyprland-global-shortcuts)
qs_pch(quickshell-hyprland-global-shortcutsplugin)
qs_pch(quickshell-hyprland-global-shortcuts-init)
target_link_libraries(quickshell PRIVATE
quickshell-hyprland-global-shortcutsplugin
quickshell-hyprland-global-shortcuts-init
)

View file

@ -0,0 +1,112 @@
<?xml version="1.0" encoding="UTF-8"?>
<protocol name="hyprland_global_shortcuts_v1">
<copyright>
Copyright © 2022 Vaxry
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.
</copyright>
<description summary="registering global shortcuts">
This protocol allows a client to register triggerable actions,
meant to be global shortcuts.
</description>
<interface name="hyprland_global_shortcuts_manager_v1" version="1">
<description summary="manager to register global shortcuts">
This object is a manager which offers requests to create global shortcuts.
</description>
<request name="register_shortcut">
<description summary="register a shortcut">
Register a new global shortcut.
A global shortcut is anonymous, meaning the app does not know what key(s) trigger it.
The shortcut's keybinding shall be dealt with by the compositor.
In the case of a duplicate app_id + id combination, the already_taken protocol error is raised.
</description>
<arg name="shortcut" type="new_id" interface="hyprland_global_shortcut_v1"/>
<arg name="id" type="string" summary="a unique id for the shortcut"/>
<arg name="app_id" type="string" summary="the app_id of the application requesting the shortcut"/>
<arg name="description" type="string" summary="user-readable text describing what the shortcut does."/>
<arg name="trigger_description" type="string" summary="user-readable text describing how to trigger the shortcut for the client to render."/>
</request>
<request name="destroy" type="destructor">
<description summary="destroy the manager">
All objects created by the manager will still remain valid, until their
appropriate destroy request has been called.
</description>
</request>
<enum name="error">
<entry name="already_taken" value="0"
summary="the app_id + id combination has already been registered."/>
</enum>
</interface>
<interface name="hyprland_global_shortcut_v1" version="1">
<description summary="a shortcut">
This object represents a single shortcut.
</description>
<event name="pressed">
<description summary="keystroke pressed">
The keystroke was pressed.
tv_ values hold the timestamp of the occurrence.
</description>
<arg name="tv_sec_hi" type="uint"
summary="high 32 bits of the seconds part of the timestamp"/>
<arg name="tv_sec_lo" type="uint"
summary="low 32 bits of the seconds part of the timestamp"/>
<arg name="tv_nsec" type="uint"
summary="nanoseconds part of the timestamp"/>
</event>
<event name="released">
<description summary="keystroke released">
The keystroke was released.
tv_ values hold the timestamp of the occurrence.
</description>
<arg name="tv_sec_hi" type="uint"
summary="high 32 bits of the seconds part of the timestamp"/>
<arg name="tv_sec_lo" type="uint"
summary="low 32 bits of the seconds part of the timestamp"/>
<arg name="tv_nsec" type="uint"
summary="nanoseconds part of the timestamp"/>
</event>
<request name="destroy" type="destructor">
<description summary="delete this object, used or not">
Destroys the shortcut. Can be sent at any time by the client.
</description>
</request>
</interface>
</protocol>

View file

@ -0,0 +1,20 @@
#include <qqml.h>
#include "../../../core/plugin.hpp"
namespace {
class HyprlandFocusGrabPlugin: public QuickshellPlugin {
void registerTypes() override {
qmlRegisterModuleImport(
"Quickshell.Hyprland",
QQmlModuleImportModuleAny,
"Quickshell.Hyprland._GlobalShortcuts",
QQmlModuleImportLatest
);
}
};
QS_REGISTER_PLUGIN(HyprlandFocusGrabPlugin);
} // namespace

View file

@ -0,0 +1,55 @@
#include "manager.hpp"
#include <qstring.h>
#include <qwaylandclientextension.h>
#include "shortcut.hpp"
namespace qs::hyprland::global_shortcuts::impl {
GlobalShortcutManager::GlobalShortcutManager()
: QWaylandClientExtensionTemplate<GlobalShortcutManager>(1) {
this->initialize();
}
GlobalShortcut* GlobalShortcutManager::registerShortcut(
const QString& appid,
const QString& name,
const QString& description,
const QString& triggerDescription
) {
auto shortcut = this->shortcuts.value({appid, name});
if (shortcut.second != nullptr) {
this->shortcuts.insert({appid, name}, {shortcut.first + 1, shortcut.second});
return shortcut.second;
} else {
auto* shortcutObj = this->register_shortcut(name, appid, description, triggerDescription);
auto* managedObj = new GlobalShortcut(shortcutObj);
this->shortcuts.insert({appid, name}, {1, managedObj});
return managedObj;
}
}
void GlobalShortcutManager::unregisterShortcut(const QString& appid, const QString& name) {
auto shortcut = this->shortcuts.value({appid, name});
if (shortcut.first > 1) {
this->shortcuts.insert({appid, name}, {shortcut.first - 1, shortcut.second});
} else {
delete shortcut.second;
this->shortcuts.remove({appid, name});
}
}
GlobalShortcutManager* GlobalShortcutManager::instance() {
static GlobalShortcutManager* instance = nullptr; // NOLINT
if (instance == nullptr) {
instance = new GlobalShortcutManager();
}
return instance;
}
} // namespace qs::hyprland::global_shortcuts::impl

View file

@ -0,0 +1,34 @@
#pragma once
#include <qcontainerfwd.h>
#include <qhash.h>
#include <qstring.h>
#include <qwayland-hyprland-global-shortcuts-v1.h>
#include <qwaylandclientextension.h>
#include "shortcut.hpp"
namespace qs::hyprland::global_shortcuts::impl {
class GlobalShortcutManager
: public QWaylandClientExtensionTemplate<GlobalShortcutManager>
, public QtWayland::hyprland_global_shortcuts_manager_v1 {
public:
explicit GlobalShortcutManager();
GlobalShortcut* registerShortcut(
const QString& appid,
const QString& name,
const QString& description,
const QString& triggerDescription
);
void unregisterShortcut(const QString& appid, const QString& name);
static GlobalShortcutManager* instance();
private:
QHash<QPair<QString, QString>, QPair<qint32, GlobalShortcut*>> shortcuts;
};
} // namespace qs::hyprland::global_shortcuts::impl

View file

@ -0,0 +1,115 @@
#include "qml.hpp"
#include <utility>
#include <qlogging.h>
#include <qobject.h>
#include <qtmetamacros.h>
#include "manager.hpp"
#include "shortcut.hpp"
namespace qs::hyprland::global_shortcuts {
using impl::GlobalShortcutManager;
GlobalShortcut::~GlobalShortcut() {
auto* manager = GlobalShortcutManager::instance();
if (manager != nullptr) {
manager->unregisterShortcut(this->mAppid, this->mName);
}
}
void GlobalShortcut::onPostReload() {
if (this->mName.isEmpty()) {
qWarning() << "Unable to create GlobalShortcut with empty name.";
return;
}
auto* manager = GlobalShortcutManager::instance();
if (!manager->isActive()) {
qWarning() << "The active compositor does not support hyprland_global_shortcuts_v1.";
qWarning() << "GlobalShortcut will not work.";
return;
}
this->shortcut = manager->registerShortcut(
this->mAppid,
this->mName,
this->mDescription,
this->mTriggerDescription
);
QObject::connect(this->shortcut, &ShortcutImpl::pressed, this, &GlobalShortcut::onPressed);
QObject::connect(this->shortcut, &ShortcutImpl::released, this, &GlobalShortcut::onReleased);
}
bool GlobalShortcut::isPressed() const { return this->mPressed; }
QString GlobalShortcut::appid() const { return this->mAppid; }
void GlobalShortcut::setAppid(QString appid) {
if (this->shortcut != nullptr) {
qWarning() << "GlobalShortcut cannot be modified after creation.";
return;
}
if (appid == this->mAppid) return;
this->mAppid = std::move(appid);
emit this->appidChanged();
}
QString GlobalShortcut::name() const { return this->mName; }
void GlobalShortcut::setName(QString name) {
if (this->shortcut != nullptr) {
qWarning() << "GlobalShortcut cannot be modified after creation.";
return;
}
if (name == this->mName) return;
this->mName = std::move(name);
emit this->nameChanged();
}
QString GlobalShortcut::description() const { return this->mDescription; }
void GlobalShortcut::setDescription(QString description) {
if (this->shortcut != nullptr) {
qWarning() << "GlobalShortcut cannot be modified after creation.";
return;
}
if (description == this->mDescription) return;
this->mDescription = std::move(description);
emit this->descriptionChanged();
}
QString GlobalShortcut::triggerDescription() const { return this->mTriggerDescription; }
void GlobalShortcut::setTriggerDescription(QString triggerDescription) {
if (this->shortcut != nullptr) {
qWarning() << "GlobalShortcut cannot be modified after creation.";
return;
}
if (triggerDescription == this->mTriggerDescription) return;
this->mTriggerDescription = std::move(triggerDescription);
emit this->triggerDescriptionChanged();
}
void GlobalShortcut::onPressed() {
this->mPressed = true;
emit this->pressed();
emit this->pressedChanged();
}
void GlobalShortcut::onReleased() {
this->mPressed = false;
emit this->released();
emit this->pressedChanged();
}
} // namespace qs::hyprland::global_shortcuts

View file

@ -0,0 +1,108 @@
#pragma once
#include <qobject.h>
#include <qqmlintegration.h>
#include <qtclasshelpermacros.h>
#include <qtmetamacros.h>
#include "../../../core/reload.hpp"
#include "shortcut.hpp"
namespace qs::hyprland::global_shortcuts {
///! Hyprland global shortcut.
/// Global shortcut implemented with [hyprland_global_shortcuts_v1].
///
/// You can use this within hyprland as a global shortcut:
/// ```
/// bind = <modifiers>, <key>, global, <appid>:<name>
/// ```
/// See [the wiki] for details.
///
/// > [!WARNING] The shortcuts protocol does not allow duplicate appid + name pairs.
/// > Within a single instance of quickshell this is handled internally, and both
/// > users will be notified, but multiple instances of quickshell or XDPH may collide.
/// >
/// > If that happens, whichever client that tries to register the shortcuts last will crash.
///
/// > [!INFO] This type does *not* use the xdg-desktop-portal global shortcuts protocol,
/// > as it is not fully functional without flatpak and would cause a considerably worse
/// > user experience from other limitations. It will only work with Hyprland.
/// > Note that, as this type bypasses xdg-desktop-portal, XDPH is not required.
///
/// [hyprland_global_shortcuts_v1]: https://github.com/hyprwm/hyprland-protocols/blob/main/protocols/hyprland-global-shortcuts-v1.xml
/// [the wiki]: https://wiki.hyprland.org/Configuring/Binds/#dbus-global-shortcuts
class GlobalShortcut
: public QObject
, public PostReloadHook {
using ShortcutImpl = impl::GlobalShortcut;
Q_OBJECT;
// clang-format off
/// If the keybind is currently pressed.
Q_PROPERTY(bool pressed READ isPressed NOTIFY pressedChanged);
/// The appid of the shortcut. Defaults to `quickshell`.
/// You cannot change this at runtime.
///
/// If you have more than one shortcut we recommend subclassing
/// GlobalShortcut to set this.
Q_PROPERTY(QString appid READ appid WRITE setAppid NOTIFY appidChanged);
/// The name of the shortcut.
/// You cannot change this at runtime.
Q_PROPERTY(QString name READ name WRITE setName NOTIFY nameChanged);
/// The description of the shortcut that appears in `hyprctl globalshortcuts`.
/// You cannot change this at runtime.
Q_PROPERTY(QString description READ description WRITE setDescription NOTIFY descriptionChanged);
/// Have not seen this used ever, but included for completeness. Safe to ignore.
Q_PROPERTY(QString triggerDescription READ triggerDescription WRITE setTriggerDescription NOTIFY triggerDescriptionChanged);
// clang-format on
QML_ELEMENT;
public:
explicit GlobalShortcut(QObject* parent = nullptr): QObject(parent) {}
~GlobalShortcut() override;
Q_DISABLE_COPY_MOVE(GlobalShortcut);
void onPostReload() override;
[[nodiscard]] bool isPressed() const;
[[nodiscard]] QString appid() const;
void setAppid(QString appid);
[[nodiscard]] QString name() const;
void setName(QString name);
[[nodiscard]] QString description() const;
void setDescription(QString description);
[[nodiscard]] QString triggerDescription() const;
void setTriggerDescription(QString triggerDescription);
signals:
/// Fired when the keybind is pressed.
void pressed();
/// Fired when the keybind is released.
void released();
void pressedChanged();
void appidChanged();
void nameChanged();
void descriptionChanged();
void triggerDescriptionChanged();
private slots:
void onPressed();
void onReleased();
private:
impl::GlobalShortcut* shortcut = nullptr;
bool mPressed = false;
QString mAppid = "quickshell";
QString mName;
QString mDescription;
QString mTriggerDescription;
};
} // namespace qs::hyprland::global_shortcuts

View file

@ -0,0 +1,33 @@
#include "shortcut.hpp"
#include <qtmetamacros.h>
#include <qtypes.h>
#include <wayland-hyprland-global-shortcuts-v1-client-protocol.h>
namespace qs::hyprland::global_shortcuts::impl {
GlobalShortcut::GlobalShortcut(::hyprland_global_shortcut_v1* shortcut) { this->init(shortcut); }
GlobalShortcut::~GlobalShortcut() {
if (this->isInitialized()) {
this->destroy();
}
}
void GlobalShortcut::hyprland_global_shortcut_v1_pressed(
quint32 /*unused*/,
quint32 /*unused*/,
quint32 /*unused*/
) {
emit this->pressed();
}
void GlobalShortcut::hyprland_global_shortcut_v1_released(
quint32 /*unused*/,
quint32 /*unused*/,
quint32 /*unused*/
) {
emit this->released();
}
} // namespace qs::hyprland::global_shortcuts::impl

View file

@ -0,0 +1,32 @@
#pragma once
#include <qobject.h>
#include <qtclasshelpermacros.h>
#include <qtmetamacros.h>
#include <qtypes.h>
#include <qwayland-hyprland-global-shortcuts-v1.h>
namespace qs::hyprland::global_shortcuts::impl {
class GlobalShortcut
: public QObject
, public QtWayland::hyprland_global_shortcut_v1 {
Q_OBJECT;
public:
explicit GlobalShortcut(::hyprland_global_shortcut_v1* shortcut);
~GlobalShortcut() override;
Q_DISABLE_COPY_MOVE(GlobalShortcut);
signals:
void pressed();
void released();
private:
// clang-format off
void hyprland_global_shortcut_v1_pressed(quint32 tvSecHi, quint32 tvSecLo, quint32 tvNsec) override;
void hyprland_global_shortcut_v1_released(quint32 tvSecHi, quint32 tvSecLo, quint32 tvNsec) override;
// clang-format on
};
} // namespace qs::hyprland::global_shortcuts::impl

View file

@ -0,0 +1,7 @@
name = "Quickshell.Hyprland"
description = "Hyprland specific Quickshell types"
headers = [
"focus_grab/qml.hpp",
"global_shortcuts/qml.hpp",
]
-----