From f3a7093a91ead389a9e6a4695343fa8639e7d76c Mon Sep 17 00:00:00 2001 From: vk-hr Date: Tue, 11 Aug 2026 19:56:06 +0300 Subject: [PATCH] niri/ipc: add niri IPC module --- BUILD.md | 11 + CMakeLists.txt | 2 + changelog/next.md | 8 + src/wayland/CMakeLists.txt | 4 + src/wayland/niri/CMakeLists.txt | 23 + src/wayland/niri/ipc/CMakeLists.txt | 24 + src/wayland/niri/ipc/cast.cpp | 48 ++ src/wayland/niri/ipc/cast.hpp | 81 +++ src/wayland/niri/ipc/connection.cpp | 732 ++++++++++++++++++++ src/wayland/niri/ipc/connection.hpp | 201 ++++++ src/wayland/niri/ipc/monitor.cpp | 69 ++ src/wayland/niri/ipc/monitor.hpp | 105 +++ src/wayland/niri/ipc/qml.cpp | 99 +++ src/wayland/niri/ipc/qml.hpp | 133 ++++ src/wayland/niri/ipc/window.cpp | 92 +++ src/wayland/niri/ipc/window.hpp | 112 +++ src/wayland/niri/ipc/workspace.cpp | 71 ++ src/wayland/niri/ipc/workspace.hpp | 86 +++ src/wayland/niri/module.md | 11 + src/wayland/niri/test/manual/connection.qml | 16 + src/wayland/niri/test/manual/misc.qml | 48 ++ src/wayland/niri/test/manual/monitors.qml | 55 ++ src/wayland/niri/test/manual/windows.qml | 30 + src/wayland/niri/test/manual/workspaces.qml | 33 + 24 files changed, 2094 insertions(+) create mode 100644 src/wayland/niri/CMakeLists.txt create mode 100644 src/wayland/niri/ipc/CMakeLists.txt create mode 100644 src/wayland/niri/ipc/cast.cpp create mode 100644 src/wayland/niri/ipc/cast.hpp create mode 100644 src/wayland/niri/ipc/connection.cpp create mode 100644 src/wayland/niri/ipc/connection.hpp create mode 100644 src/wayland/niri/ipc/monitor.cpp create mode 100644 src/wayland/niri/ipc/monitor.hpp create mode 100644 src/wayland/niri/ipc/qml.cpp create mode 100644 src/wayland/niri/ipc/qml.hpp create mode 100644 src/wayland/niri/ipc/window.cpp create mode 100644 src/wayland/niri/ipc/window.hpp create mode 100644 src/wayland/niri/ipc/workspace.cpp create mode 100644 src/wayland/niri/ipc/workspace.hpp create mode 100644 src/wayland/niri/module.md create mode 100644 src/wayland/niri/test/manual/connection.qml create mode 100644 src/wayland/niri/test/manual/misc.qml create mode 100644 src/wayland/niri/test/manual/monitors.qml create mode 100644 src/wayland/niri/test/manual/windows.qml create mode 100644 src/wayland/niri/test/manual/workspaces.qml diff --git a/BUILD.md b/BUILD.md index 97ef93bf3..4cceb57e6 100644 --- a/BUILD.md +++ b/BUILD.md @@ -221,6 +221,17 @@ To disable: `-DHYPRLAND_FOCUS_GRAB=OFF` [hyprland-focus-grab-v1]: https://github.com/hyprwm/hyprland-protocols/blob/main/protocols/hyprland-focus-grab-v1.xml +### Niri +This feature enables niri specific integrations. It requires wayland support +but has no extra dependencies. + +To disable: `-DNIRI=OFF` + +#### Niri IPC +Enables interfacing with niri's IPC. + +To disable: `-DNIRI_IPC=OFF` + ### i3/Sway Enables i3 and Sway specific features, does not have any dependency on Wayland or x11. diff --git a/CMakeLists.txt b/CMakeLists.txt index dca78f7de..1c246d0e8 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -64,6 +64,8 @@ boption(HYPRLAND_IPC " Hyprland IPC" ON REQUIRES HYPRLAND) boption(HYPRLAND_GLOBAL_SHORTCUTS " Hyprland Global Shortcuts" ON REQUIRES HYPRLAND) boption(HYPRLAND_FOCUS_GRAB " Hyprland Focus Grabbing" ON REQUIRES HYPRLAND) boption(HYPRLAND_SURFACE_EXTENSIONS " Hyprland Surface Extensions" ON REQUIRES HYPRLAND) +boption(NIRI " Niri" ON REQUIRES WAYLAND) +boption(NIRI_IPC " Niri IPC" ON REQUIRES NIRI) boption(SCREENCOPY " Screencopy" ON REQUIRES WAYLAND) boption(SCREENCOPY_ICC " Image Copy Capture" ON REQUIRES WAYLAND) boption(SCREENCOPY_WLR " Wlroots Screencopy" ON REQUIRES WAYLAND) diff --git a/changelog/next.md b/changelog/next.md index fd3909163..c4ecf0c04 100644 --- a/changelog/next.md +++ b/changelog/next.md @@ -1,3 +1,11 @@ +## New Features + +### Niri IPC module + +Added the `Quickshell.Niri` module, providing live niri compositor state over IPC: +workspaces, windows, monitors, screencasts, overview state, and keyboard layouts, +plus action dispatch (`Niri.dispatch`) and a raw event stream (`Niri.rawEvent`). + ## Other Changes - Added support for Hyprland's new workspace address format. diff --git a/src/wayland/CMakeLists.txt b/src/wayland/CMakeLists.txt index 509fd91d3..15affa3b2 100644 --- a/src/wayland/CMakeLists.txt +++ b/src/wayland/CMakeLists.txt @@ -120,6 +120,10 @@ if (HYPRLAND) add_subdirectory(hyprland) endif() +if (NIRI) + add_subdirectory(niri) +endif() + add_subdirectory(background_effect) list(APPEND WAYLAND_MODULES Quickshell.Wayland._BackgroundEffect) diff --git a/src/wayland/niri/CMakeLists.txt b/src/wayland/niri/CMakeLists.txt new file mode 100644 index 000000000..f6e0d3b95 --- /dev/null +++ b/src/wayland/niri/CMakeLists.txt @@ -0,0 +1,23 @@ +qt_add_library(quickshell-niri STATIC) + +target_link_libraries(quickshell-niri PRIVATE ${QT_DEPS}) + +set(NIRI_MODULES) + +if (NIRI_IPC) + add_subdirectory(ipc) + list(APPEND NIRI_MODULES Quickshell.Niri._Ipc) +endif() + +qt_add_qml_module(quickshell-niri + URI Quickshell.Niri + VERSION 0.1 + IMPORTS ${NIRI_MODULES} +) + +qs_add_module_deps_light(quickshell-io Quickshell) +install_qml_module(quickshell-niri) + +# intentionally no pch as the module is empty + +target_link_libraries(quickshell PRIVATE quickshell-niriplugin) diff --git a/src/wayland/niri/ipc/CMakeLists.txt b/src/wayland/niri/ipc/CMakeLists.txt new file mode 100644 index 000000000..c19256c15 --- /dev/null +++ b/src/wayland/niri/ipc/CMakeLists.txt @@ -0,0 +1,24 @@ +qt_add_library(quickshell-niri-ipc STATIC + cast.cpp + connection.cpp + monitor.cpp + workspace.cpp + window.cpp + qml.cpp +) + +qt_add_qml_module(quickshell-niri-ipc + URI Quickshell.Niri._Ipc + VERSION 0.1 + DEPENDENCIES QtQuick +) + +qs_add_module_deps_light(quickshell-niri-ipc Quickshell) + +install_qml_module(quickshell-niri-ipc) + +target_link_libraries(quickshell-niri-ipc PRIVATE Qt::Quick quickshell-core) + +qs_module_pch(quickshell-niri-ipc SET large) + +target_link_libraries(quickshell PRIVATE quickshell-niri-ipcplugin) diff --git a/src/wayland/niri/ipc/cast.cpp b/src/wayland/niri/ipc/cast.cpp new file mode 100644 index 000000000..6e716380d --- /dev/null +++ b/src/wayland/niri/ipc/cast.cpp @@ -0,0 +1,48 @@ +#include "cast.hpp" + +#include +#include +#include +#include +#include + +#include "connection.hpp" + +namespace qs::niri::ipc { + +NiriCast::NiriCast(NiriIpc* ipc): QObject(ipc), ipc(ipc) {} + +void NiriCast::updateFromObject(const QVariantMap& object) { + Qt::beginPropertyUpdateGroup(); + + // Stream ID cannot be updated after creation. + if (this->bStreamId.value() == 0) { + this->bStreamId = object.value("stream_id").value(); + } + + this->bSessionId = object.value("session_id").value(); + this->bKind = object.value("kind").value(); + this->bDynamicTarget = object.value("is_dynamic_target").value(); + this->bActive = object.value("is_active").value(); + this->bPid = object.value("pid").value(); + auto pwNodeId = object.value("pw_node_id"); + this->bPwNodeId = pwNodeId.isNull() ? -1 : pwNodeId.value(); + + // The target is an enum: Nothing, Output {name}, or Window {id}. + auto target = object.value("target").toMap(); + + if (auto output = target.value("Output").toMap(); !output.isEmpty()) { + this->bTargetOutput = output.value("name").value(); + this->bTargetWindowId = 0; + } else if (auto window = target.value("Window").toMap(); !window.isEmpty()) { + this->bTargetOutput = QString(); + this->bTargetWindowId = window.value("id").value(); + } else { + this->bTargetOutput = QString(); + this->bTargetWindowId = 0; + } + + Qt::endPropertyUpdateGroup(); +} + +} // namespace qs::niri::ipc diff --git a/src/wayland/niri/ipc/cast.hpp b/src/wayland/niri/ipc/cast.hpp new file mode 100644 index 000000000..b06c4767a --- /dev/null +++ b/src/wayland/niri/ipc/cast.hpp @@ -0,0 +1,81 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +#include "connection.hpp" + +namespace qs::niri::ipc { + +class NiriCast: public QObject { + Q_OBJECT; + // clang-format off + /// Stream id of the screencast. Uniquely identifies it. + Q_PROPERTY(qint64 streamId READ default NOTIFY streamIdChanged BINDABLE bindableStreamId); + /// Session id of the screencast. A session can have multiple streams. + Q_PROPERTY(qint64 sessionId READ default NOTIFY sessionIdChanged BINDABLE bindableSessionId); + /// Kind of the screencast: "PipeWire" or "WlrScreencopy". + Q_PROPERTY(QString kind READ default NOTIFY kindChanged BINDABLE bindableKind); + /// Name of the screencasted output, or empty if the cast does not target an output. + Q_PROPERTY(QString targetOutput READ default NOTIFY targetOutputChanged BINDABLE bindableTargetOutput); + /// Id of the screencasted window, or 0 if the cast does not target a window. + Q_PROPERTY(qint64 targetWindowId READ default NOTIFY targetWindowIdChanged BINDABLE bindableTargetWindowId); + /// Whether this cast's target can be changed by actions like SetDynamicCastWindow. + Q_PROPERTY(bool dynamicTarget READ default NOTIFY dynamicTargetChanged BINDABLE bindableDynamicTarget); + /// Whether the cast is currently streaming frames. + Q_PROPERTY(bool active READ default NOTIFY activeChanged BINDABLE bindableActive); + /// Process ID of the screencast consumer, or 0 if unknown. + Q_PROPERTY(qint32 pid READ default NOTIFY pidChanged BINDABLE bindablePid); + /// PipeWire node ID of the screencast stream, or -1 if none. + Q_PROPERTY(qint32 pwNodeId READ default NOTIFY pwNodeIdChanged BINDABLE bindablePwNodeId); + // clang-format on + QML_ELEMENT; + QML_UNCREATABLE("NiriCasts must be retrieved from the Niri singleton."); + +public: + explicit NiriCast(NiriIpc* ipc); + + void updateFromObject(const QVariantMap& object); + + [[nodiscard]] QBindable bindableStreamId() { return &this->bStreamId; } + [[nodiscard]] QBindable bindableSessionId() { return &this->bSessionId; } + [[nodiscard]] QBindable bindableKind() { return &this->bKind; } + [[nodiscard]] QBindable bindableTargetOutput() { return &this->bTargetOutput; } + [[nodiscard]] QBindable bindableTargetWindowId() { return &this->bTargetWindowId; } + [[nodiscard]] QBindable bindableDynamicTarget() { return &this->bDynamicTarget; } + [[nodiscard]] QBindable bindableActive() { return &this->bActive; } + [[nodiscard]] QBindable bindablePid() { return &this->bPid; } + [[nodiscard]] QBindable bindablePwNodeId() { return &this->bPwNodeId; } + +signals: + void streamIdChanged(); + void sessionIdChanged(); + void kindChanged(); + void targetOutputChanged(); + void targetWindowIdChanged(); + void dynamicTargetChanged(); + void activeChanged(); + void pidChanged(); + void pwNodeIdChanged(); + +private: + NiriIpc* ipc; + + // clang-format off + Q_OBJECT_BINDABLE_PROPERTY_WITH_ARGS(NiriCast, qint64, bStreamId, 0, &NiriCast::streamIdChanged); + Q_OBJECT_BINDABLE_PROPERTY(NiriCast, qint64, bSessionId, &NiriCast::sessionIdChanged); + Q_OBJECT_BINDABLE_PROPERTY(NiriCast, QString, bKind, &NiriCast::kindChanged); + Q_OBJECT_BINDABLE_PROPERTY(NiriCast, QString, bTargetOutput, &NiriCast::targetOutputChanged); + Q_OBJECT_BINDABLE_PROPERTY(NiriCast, qint64, bTargetWindowId, &NiriCast::targetWindowIdChanged); + Q_OBJECT_BINDABLE_PROPERTY(NiriCast, bool, bDynamicTarget, &NiriCast::dynamicTargetChanged); + Q_OBJECT_BINDABLE_PROPERTY(NiriCast, bool, bActive, &NiriCast::activeChanged); + Q_OBJECT_BINDABLE_PROPERTY(NiriCast, qint32, bPid, &NiriCast::pidChanged); + Q_OBJECT_BINDABLE_PROPERTY_WITH_ARGS(NiriCast, qint32, bPwNodeId, -1, &NiriCast::pwNodeIdChanged); + // clang-format on +}; + +} // namespace qs::niri::ipc diff --git a/src/wayland/niri/ipc/connection.cpp b/src/wayland/niri/ipc/connection.cpp new file mode 100644 index 000000000..18fb28414 --- /dev/null +++ b/src/wayland/niri/ipc/connection.cpp @@ -0,0 +1,732 @@ +#include "connection.hpp" +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "../../../core/logcat.hpp" +#include "../../../core/model.hpp" +#include "../../../core/qmlscreen.hpp" +#include "cast.hpp" +#include "monitor.hpp" +#include "window.hpp" +#include "workspace.hpp" +namespace qs::niri::ipc { + +namespace { +QS_LOGGING_CATEGORY(logNiriIpc, "quickshell.niri.ipc", QtWarningMsg); +QS_LOGGING_CATEGORY(logNiriIpcEvents, "quickshell.niri.ipc.events", QtWarningMsg); + +/// Write one JSON request line to a niri socket. +void writeRequestLine(QLocalSocket* socket, const QByteArray& json) { + socket->write(json + '\n'); + socket->flush(); +} +} // namespace + +NiriIpc::NiriIpc() { + auto socketPath = qEnvironmentVariable("NIRI_SOCKET"); + if (socketPath.isEmpty()) { + qCWarning(logNiriIpc) << "$NIRI_SOCKET is unset. Cannot connect to niri."; + return; + } + + this->mSocketPath = socketPath; + + // clang-format off + QObject::connect(&this->eventSocket, &QLocalSocket::errorOccurred, this, &NiriIpc::socketError); + QObject::connect(&this->eventSocket, &QLocalSocket::stateChanged, this, &NiriIpc::socketStateChanged); + QObject::connect(&this->eventSocket, &QLocalSocket::readyRead, this, &NiriIpc::socketReady); + QObject::connect(&this->eventSocket, &QLocalSocket::connected, this, [this]() { + this->eventReader.setDevice(&this->eventSocket); + writeRequestLine(&this->eventSocket, "\"EventStream\""); + }); + + this->reconnectTimer.setSingleShot(true); + this->reconnectTimer.setInterval(2000); + QObject::connect(&this->reconnectTimer, &QTimer::timeout, this, [this]() { + qCInfo(logNiriIpc) << "Attempting to reconnect to niri event socket."; + this->eventSocket.connectToServer(this->mSocketPath, QLocalSocket::ReadWrite); + }); + // clang-format on + + this->makeRequest("\"Version\"", [&, this](bool success, const QByteArray& resp) { + if (!success) { + qCWarning(logNiriIpc) << "Niri ipc status request failed."; + } + + qCDebug(logNiriIpc) << "Niri version response:" << success << resp; + + this->eventSocket.connectToServer(this->mSocketPath, QLocalSocket::ReadWrite); + // Niri sends no output events, so monitors must be queried explicitly. + this->refreshMonitors(); + // Workspaces are delivered by the event stream's initial WorkspacesChanged. + }); +} + +QString NiriIpc::socketPath() const { return this->mSocketPath; } + +void NiriIpc::socketError(QLocalSocket::LocalSocketError error) const { + if (!this->valid) { + qCWarning(logNiriIpc) << "Unable to connect to niri socket:" << error; + } else { + qCWarning(logNiriIpc) << "Niri socket error:" << error; + } +} + +void NiriIpc::socketStateChanged(QLocalSocket::LocalSocketState state) { + if (state == QLocalSocket::ConnectedState) { + this->reconnectTimer.stop(); + qCInfo(logNiriIpc) << "Niri event socket connected."; + emit this->connected(); + } else if (state == QLocalSocket::UnconnectedState && this->valid) { + qCWarning(logNiriIpc) << "Niri event socket disconnected. Will retry."; + this->valid = false; + this->reconnectTimer.start(); + return; + } + + this->valid = state == QLocalSocket::ConnectedState; +} + +void NiriIpc::socketReady() { + while (true) { + this->eventReader.startTransaction(); + auto rawEvent = this->eventReader.readUntil('\n'); + if (!this->eventReader.commitTransaction()) return; + + auto json = QJsonDocument::fromJson(rawEvent); + if (!json.isObject()) { + qCWarning(logNiriIpc) << "Failed to parse niri event:" << rawEvent; + continue; + } + + auto object = json.object(); + + // The EventStream reply line, not an event. + if (object.contains("Ok")) continue; + if (object.contains("Err")) { + qCWarning(logNiriIpc) << "Niri event stream error:" << object.value("Err").toString(); + continue; + } + + // An event object has exactly one key: the event name. + if (object.isEmpty()) continue; + auto entry = object.constBegin(); + auto name = entry.key().toUtf8(); + auto data = QJsonDocument(entry.value().toObject()).toJson(QJsonDocument::Compact); + + qCDebug(logNiriIpcEvents) << "Received event:" << name << data; + + this->event.name = name; + this->event.data = data; + this->onEvent(&this->event); + emit this->rawEvent(&this->event); + } +} + +void NiriIpc::makeRequest( + const QByteArray& request, + const std::function& callback +) { + auto* requestSocket = new QLocalSocket(this); + qCDebug(logNiriIpc) << "Making request:" << request; + + auto connectedCallback = [this, request, requestSocket, callback]() { + auto responseCallback = [requestSocket, callback]() { + if (!requestSocket->canReadLine()) return; + + auto response = requestSocket->readLine(); + response.chop(1); // trailing newline + callback(true, std::move(response)); + requestSocket->deleteLater(); + }; + + QObject::connect(requestSocket, &QLocalSocket::readyRead, this, responseCallback); + + writeRequestLine(requestSocket, request); + }; + + auto errorCallback = [=](QLocalSocket::LocalSocketError error) { + qCWarning(logNiriIpc) << "Error making request:" << error << "request:" << request; + requestSocket->deleteLater(); + callback(false, {}); + }; + + QObject::connect(requestSocket, &QLocalSocket::connected, this, connectedCallback); + QObject::connect(requestSocket, &QLocalSocket::errorOccurred, this, errorCallback); + + requestSocket->connectToServer(this->mSocketPath); +} + +void NiriIpc::dispatch(const QString& request) { + this->makeRequest( + "{\"Action\":" + request.toUtf8() + "}", + [request](bool success, const QByteArray& response) { + if (!success) { + qCWarning(logNiriIpc) << "Failed to request dispatch of" << request; + return; + } + + auto json = QJsonDocument::fromJson(response).object(); + if (json.contains("Err")) { + qCWarning(logNiriIpc) << "Dispatch request" << request << "failed with error" + << json.value("Err").toString(); + } + } + ); +} + +ObjectModel* NiriIpc::monitors() { return &this->mMonitors; } + +ObjectModel* NiriIpc::workspaces() { return &this->mWorkspaces; } + +ObjectModel* NiriIpc::windows() { return &this->mWindows; } + +ObjectModel* NiriIpc::casts() { return &this->mCasts; } + +QVariantMap NiriIpcEvent::parse() const { + return QJsonDocument::fromJson(this->data).object().toVariantMap(); +} + +QString NiriIpcEvent::nameStr() const { return QString::fromUtf8(this->name); } +QString NiriIpcEvent::dataStr() const { return QString::fromUtf8(this->data); } + +NiriIpc* NiriIpc::instance() { + static NiriIpc* instance = nullptr; + + if (instance == nullptr) { + instance = new NiriIpc(); + } + + return instance; +} + +void NiriIpc::onEvent(NiriIpcEvent* event) { + if (event->name == "WorkspacesChanged") { + this->updateWorkspaces(event->parse().value("workspaces").toList()); + } else if (event->name == "WorkspaceActivated") { + auto data = event->parse(); + auto id = data.value("id").value(); + auto* workspace = this->findWorkspaceById(id); + + if (!workspace) { + qCWarning(logNiriIpc) << "WorkspaceActivated for unknown workspace id" << id; + return; + } + + auto* monitor = workspace->bindableMonitor().value(); + + // All other workspaces on the same output become inactive. + for (auto* other: this->mWorkspaces.valueList()) { + if (other->bindableMonitor().value() == monitor) { + other->bindableActive().setValue(other == workspace); + } + } + + if (monitor) monitor->setActiveWorkspace(workspace); + + if (data.value("focused").toBool()) { + this->bFocusedWorkspace = workspace; + this->setFocusedMonitor(monitor); + } + } else if (event->name == "WorkspaceUrgencyChanged") { + auto data = event->parse(); + auto id = data.value("id").value(); + auto* workspace = this->findWorkspaceById(id); + + if (!workspace) { + qCWarning(logNiriIpc) << "WorkspaceUrgencyChanged for unknown workspace id" << id; + return; + } + + workspace->bindableUrgent().setValue(data.value("urgent").toBool()); + } else if (event->name == "WorkspaceActiveWindowChanged") { + auto data = event->parse(); + auto id = data.value("workspace_id").value(); + auto* workspace = this->findWorkspaceById(id); + + if (!workspace) { + qCWarning(logNiriIpc) << "WorkspaceActiveWindowChanged for unknown workspace id" << id; + return; + } + + workspace->bindableActiveWindowId().setValue(data.value("active_window_id").value()); + } else if (event->name == "WindowsChanged") { + this->updateWindows(event->parse().value("windows").toList()); + } else if (event->name == "WindowOpenedOrChanged") { + auto window = event->parse().value("window").toMap(); + auto id = window.value("id").value(); + + auto* existing = this->findWindowById(id); + auto* windowObj = existing; + + if (!windowObj) { + windowObj = new NiriWindow(this); + } + + windowObj->updateFromObject(window); + + if (!existing) { + this->mWindows.insertObject(windowObj); + } + + // If this window is focused, all other windows are no longer focused. + if (windowObj->bindableFocused().value()) { + this->setActiveWindow(windowObj); + } + } else if (event->name == "WindowClosed") { + auto id = event->parse().value("id").value(); + auto* window = this->findWindowById(id); + + if (!window) { + qCWarning(logNiriIpc) << "WindowClosed for unknown window id" << id; + return; + } + + if (this->bActiveWindow == window) { + this->bActiveWindow = nullptr; + } + + this->mWindows.removeObject(window); + window->deleteLater(); + } else if (event->name == "WindowFocusChanged") { + auto id = event->parse().value("id").value(); + + if (id == 0) { + this->setActiveWindow(nullptr); + } else { + auto* window = this->findWindowById(id); + + if (!window) { + qCWarning(logNiriIpc) << "WindowFocusChanged for unknown window id" << id; + return; + } + + this->setActiveWindow(window); + } + } else if (event->name == "WindowUrgencyChanged") { + auto data = event->parse(); + auto id = data.value("id").value(); + auto* window = this->findWindowById(id); + + if (!window) { + qCWarning(logNiriIpc) << "WindowUrgencyChanged for unknown window id" << id; + return; + } + + window->bindableUrgent().setValue(data.value("urgent").toBool()); + } else if (event->name == "WindowLayoutsChanged") { + auto changes = event->parse().value("changes").toList(); + + for (const auto& change: changes) { + // Each change is an (id, layout) pair. + auto pair = change.toList(); + if (pair.size() != 2) continue; + + auto* window = this->findWindowById(pair[0].value()); + if (!window) continue; + + window->setLayout(pair[1].toMap()); + } + } else if (event->name == "WindowFocusTimestampChanged") { + auto data = event->parse(); + auto id = data.value("id").value(); + auto* window = this->findWindowById(id); + + if (!window) { + qCWarning(logNiriIpc) << "WindowFocusTimestampChanged for unknown window id" << id; + return; + } + + window->setFocusTimestamp(data.value("focus_timestamp").toMap()); + } else if (event->name == "OverviewOpenedOrClosed") { + this->bOverviewOpen = event->parse().value("is_open").toBool(); + } else if (event->name == "KeyboardLayoutsChanged") { + auto layouts = event->parse().value("keyboard_layouts").toMap(); + + auto names = QStringList(); + for (const auto& name: layouts.value("names").toList()) { + names.push_back(name.toString()); + } + + this->mKeyboardLayouts = names; + emit this->keyboardLayoutsChanged(); + this->bCurrentKeyboardLayout = layouts.value("current_idx").value(); + } else if (event->name == "KeyboardLayoutSwitched") { + this->bCurrentKeyboardLayout = event->parse().value("idx").value(); + } else if (event->name == "ConfigLoaded") { + emit this->configLoaded(event->parse().value("failed").toBool()); + } else if (event->name == "ScreenshotCaptured") { + emit this->screenshotCaptured(event->parse().value("path").toString()); + } else if (event->name == "CastsChanged") { + this->updateCasts(event->parse().value("casts").toList()); + } else if (event->name == "CastStartedOrChanged") { + auto cast = event->parse().value("cast").toMap(); + auto streamId = cast.value("stream_id").value(); + + auto* existing = this->findCastByStreamId(streamId); + auto* castObj = existing; + + if (!castObj) { + castObj = new NiriCast(this); + } + + castObj->updateFromObject(cast); + + if (!existing) { + this->mCasts.insertObject(castObj); + } + } else if (event->name == "CastStopped") { + auto streamId = event->parse().value("stream_id").value(); + auto* cast = this->findCastByStreamId(streamId); + + if (!cast) { + qCWarning(logNiriIpc) << "CastStopped for unknown stream id" << streamId; + return; + } + + this->mCasts.removeObject(cast); + cast->deleteLater(); + } else { + qCDebug(logNiriIpcEvents) << "Unhandled niri event:" << event->name; + } +} + +NiriWorkspace* NiriIpc::findWorkspaceById(qint64 id) { + const auto& mList = this->mWorkspaces.valueList(); + + auto workspaceIter = + std::ranges::find_if(mList, [id](NiriWorkspace* m) { return m->bindableId().value() == id; }); + + return workspaceIter == mList.end() ? nullptr : *workspaceIter; +} + +NiriWindow* NiriIpc::findWindowById(qint64 id) { + const auto& mList = this->mWindows.valueList(); + + auto windowIter = + std::ranges::find_if(mList, [id](NiriWindow* m) { return m->bindableId().value() == id; }); + + return windowIter == mList.end() ? nullptr : *windowIter; +} + +NiriCast* NiriIpc::findCastByStreamId(qint64 streamId) { + const auto& mList = this->mCasts.valueList(); + + auto castIter = std::ranges::find_if(mList, [streamId](NiriCast* m) { + return m->bindableStreamId().value() == streamId; + }); + + return castIter == mList.end() ? nullptr : *castIter; +} + +void NiriIpc::updateWorkspaces(const QVariantList& workspaces) { + // Clear monitors' active workspace links before destroying workspace objects. + for (auto* monitor: this->mMonitors.valueList()) { + monitor->setActiveWorkspace(nullptr); + } + + this->bFocusedWorkspace = nullptr; + this->setFocusedMonitor(nullptr); + + // WorkspacesChanged is a full replace — clear and rebuild so the model is + // always correctly sorted even when existing workspaces change idx or output. + for (auto* workspace: this->mWorkspaces.valueList()) { + this->mWorkspaces.removeObject(workspace); + workspace->deleteLater(); + } + + NiriWorkspace* focused = nullptr; + + for (const auto& entry: workspaces) { + auto object = entry.toMap(); + auto* workspace = new NiriWorkspace(this); + workspace->updateFromObject(object); + this->mWorkspaces.insertObjectSorted(workspace, &NiriIpc::compareWorkspaces); + + if (object.value("is_focused").toBool()) { + focused = workspace; + } + } + + this->bFocusedWorkspace = focused; + this->setFocusedMonitor(focused ? focused->bindableMonitor().value() : nullptr); + + // Relink the active workspace of every monitor. + for (auto* monitor: this->mMonitors.valueList()) { + NiriWorkspace* active = nullptr; + + for (auto* workspace: this->mWorkspaces.valueList()) { + if (workspace->bindableActive().value() && workspace->bindableMonitor().value() == monitor) { + active = workspace; + break; + } + } + + monitor->setActiveWorkspace(active); + } +} + +void NiriIpc::refreshWorkspaces() { + if (this->requestingWorkspaces) return; + this->requestingWorkspaces = true; + + this->makeRequest("\"Workspaces\"", [this](bool success, const QByteArray& resp) { + this->requestingWorkspaces = false; + if (!success) return; + + qCDebug(logNiriIpc) << "Parsing workspaces response"; + auto reply = QJsonDocument::fromJson(resp).object(); + auto workspaces = reply.value("Ok").toObject().value("Workspaces").toArray(); + this->updateWorkspaces(workspaces.toVariantList()); + }); +} + +void NiriIpc::updateWindows(const QVariantList& windows) { + const auto& mList = this->mWindows.valueList(); + auto ids = QVector(); + NiriWindow* focused = nullptr; + + for (const auto& entry: windows) { + auto object = entry.toMap(); + auto id = object.value("id").value(); + + auto* window = this->findWindowById(id); + auto existed = window != nullptr; + + if (!existed) { + window = new NiriWindow(this); + } + + window->updateFromObject(object); + + if (!existed) { + this->mWindows.insertObject(window); + } + + if (object.value("is_focused").toBool()) { + focused = window; + } + + ids.push_back(id); + } + + // Windows missing from the full state were closed. + auto removedWindows = QVector(); + + for (auto* window: mList) { + if (!ids.contains(window->bindableId().value())) { + removedWindows.push_back(window); + } + } + + if (removedWindows.contains(this->bActiveWindow.value())) { + this->bActiveWindow = nullptr; + } + + for (auto* window: removedWindows) { + this->mWindows.removeObject(window); + window->deleteLater(); + } + + this->bActiveWindow = focused; +} + +void NiriIpc::updateCasts(const QVariantList& casts) { + const auto& mList = this->mCasts.valueList(); + auto streamIds = QVector(); + + for (const auto& entry: casts) { + auto object = entry.toMap(); + auto streamId = object.value("stream_id").value(); + + auto* cast = this->findCastByStreamId(streamId); + auto existed = cast != nullptr; + + if (!existed) { + cast = new NiriCast(this); + } + + cast->updateFromObject(object); + + if (!existed) { + this->mCasts.insertObject(cast); + } + + streamIds.push_back(streamId); + } + + // Casts missing from the full state were stopped. + auto removedCasts = QVector(); + + for (auto* cast: mList) { + if (!streamIds.contains(cast->bindableStreamId().value())) { + removedCasts.push_back(cast); + } + } + + for (auto* cast: removedCasts) { + this->mCasts.removeObject(cast); + cast->deleteLater(); + } +} + +void NiriIpc::refreshCasts() { + if (this->requestingCasts) return; + this->requestingCasts = true; + + this->makeRequest("\"Casts\"", [this](bool success, const QByteArray& resp) { + this->requestingCasts = false; + if (!success) return; + + qCDebug(logNiriIpc) << "Parsing casts response"; + auto reply = QJsonDocument::fromJson(resp).object(); + auto casts = reply.value("Ok").toObject().value("Casts").toArray(); + this->updateCasts(casts.toVariantList()); + }); +} + +void NiriIpc::refreshWindows() { + if (this->requestingWindows) return; + this->requestingWindows = true; + + this->makeRequest("\"Windows\"", [this](bool success, const QByteArray& resp) { + this->requestingWindows = false; + if (!success) return; + + qCDebug(logNiriIpc) << "Parsing windows response"; + auto reply = QJsonDocument::fromJson(resp).object(); + auto windows = reply.value("Ok").toObject().value("Windows").toArray(); + this->updateWindows(windows.toVariantList()); + }); +} + +NiriMonitor* NiriIpc::findMonitorByName(const QString& name, bool createIfMissing) { + const auto& mList = this->mMonitors.valueList(); + + auto monitorIter = std::ranges::find_if(mList, [name](NiriMonitor* m) { + return m->bindableName().value() == name; + }); + + if (monitorIter != mList.end()) { + return *monitorIter; + } else if (createIfMissing) { + qCDebug(logNiriIpc) << "Monitor" << name << "requested before creation, performing early init"; + auto* monitor = new NiriMonitor(this); + monitor->updateInitial(name); + this->mMonitors.insertObject(monitor); + return monitor; + } else { + return nullptr; + } +} + +NiriMonitor* NiriIpc::monitorFor(QuickshellScreenInfo* screen) { + // Niri monitors are created by refreshMonitors, which runs at startup, so a monitor + // will usually already exist. If this call creates the NiriIpc singleton before the + // first refresh returns, preemptively create one. + if (screen == nullptr) return nullptr; + return this->findMonitorByName(screen->name(), !this->monitorsRequested); +} + +void NiriIpc::setFocusedMonitor(NiriMonitor* monitor) { + auto* oldMonitor = this->bFocusedMonitor.value(); + if (monitor == oldMonitor) return; + + if (this->bFocusedMonitor != nullptr) { + QObject::disconnect(this->bFocusedMonitor, nullptr, this, nullptr); + } + + if (monitor != nullptr) { + QObject::connect(monitor, &QObject::destroyed, this, &NiriIpc::onFocusedMonitorDestroyed); + } + + this->bFocusedMonitor = monitor; +} + +void NiriIpc::onFocusedMonitorDestroyed() { this->bFocusedMonitor = nullptr; } + +void NiriIpc::setActiveWindow(NiriWindow* window) { + // At most one window is focused at a time. + for (auto* other: this->mWindows.valueList()) { + other->bindableFocused().setValue(other == window); + } + + this->bActiveWindow = window; +} + +void NiriIpc::refreshMonitors() { + if (this->requestingMonitors) return; + this->requestingMonitors = true; + + this->makeRequest("\"Outputs\"", [this](bool success, const QByteArray& resp) { + this->requestingMonitors = false; + if (!success) return; + + this->monitorsRequested = true; + + qCDebug(logNiriIpc) << "Parsing outputs response"; + auto reply = QJsonDocument::fromJson(resp).object(); + auto outputs = reply.value("Ok").toObject().value("Outputs").toObject(); + + auto monitors = QVariantList(); + for (const auto& output: outputs) { + monitors.push_back(output.toObject().toVariantMap()); + } + + this->updateMonitors(monitors); + }); +} + +void NiriIpc::updateMonitors(const QVariantList& monitors) { + const auto& mList = this->mMonitors.valueList(); + auto names = QVector(); + + for (const auto& entry: monitors) { + auto object = entry.toMap(); + auto name = object.value("name").toString(); + + auto* monitor = this->findMonitorByName(name, true); + monitor->updateFromObject(object); + + names.push_back(name); + } + + // Monitors missing from the full state were disconnected. + auto removedMonitors = QVector(); + + for (auto* monitor: mList) { + if (!names.contains(monitor->bindableName().value())) { + removedMonitors.push_back(monitor); + } + } + + for (auto* monitor: removedMonitors) { + this->mMonitors.removeObject(monitor); + monitor->deleteLater(); + } +} + +bool NiriIpc::compareWorkspaces(NiriWorkspace* a, NiriWorkspace* b) { + auto* am = a->bindableMonitor().value(); + auto* bm = b->bindableMonitor().value(); + auto ax = am ? am->bindableX().value() : 0; + auto bx = bm ? bm->bindableX().value() : 0; + + if (ax != bx) return ax > bx; + return a->bindableIdx().value() > b->bindableIdx().value(); +} + +} // namespace qs::niri::ipc diff --git a/src/wayland/niri/ipc/connection.hpp b/src/wayland/niri/ipc/connection.hpp new file mode 100644 index 000000000..c8b52cb74 --- /dev/null +++ b/src/wayland/niri/ipc/connection.hpp @@ -0,0 +1,201 @@ +#pragma once + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "../../../core/model.hpp" +#include "../../../core/qmlscreen.hpp" +#include "../../../core/streamreader.hpp" + +namespace qs::niri::ipc { + +class NiriCast; +class NiriMonitor; +class NiriWindow; +class NiriWorkspace; + +} // namespace qs::niri::ipc + +Q_DECLARE_OPAQUE_POINTER(qs::niri::ipc::NiriWorkspace*); +Q_DECLARE_OPAQUE_POINTER(qs::niri::ipc::NiriMonitor*); +Q_DECLARE_OPAQUE_POINTER(qs::niri::ipc::NiriWindow*); +Q_DECLARE_OPAQUE_POINTER(qs::niri::ipc::NiriCast*); + +namespace qs::niri::ipc { + +///! Live niri IPC event. +/// Live niri IPC event. Holding this object after the +/// signal handler exits is undefined as the event instance +/// is reused. +/// +/// Emitted by @@Niri.rawEvent(s). +class NiriIpcEvent: public QObject { + Q_OBJECT; + + /// The name of the event. + /// + /// See the [niri-ipc documentation](https://niri-wm.github.io/niri/niri_ipc/enum.Event.html) + /// for a list of events. + Q_PROPERTY(QString name READ nameStr CONSTANT); + /// The unparsed data of the event, as a JSON string. + Q_PROPERTY(QString data READ dataStr CONSTANT); + QML_NAMED_ELEMENT(NiriEvent); + QML_UNCREATABLE("NiriIpcEvents cannot be created."); + +public: + NiriIpcEvent(QObject* parent): QObject(parent) {} + + /// Parse the event data as a javascript object. + Q_INVOKABLE [[nodiscard]] QVariantMap parse() const; + + [[nodiscard]] QString nameStr() const; + [[nodiscard]] QString dataStr() const; + + QByteArray name; + QByteArray data; +}; + +class NiriIpc: public QObject { + Q_OBJECT; + +public: + static NiriIpc* instance(); + + [[nodiscard]] QString socketPath() const; + + void + makeRequest(const QByteArray& request, const std::function& callback); + void dispatch(const QString& request); + + [[nodiscard]] NiriMonitor* monitorFor(QuickshellScreenInfo* screen); + + [[nodiscard]] QBindable bindableFocusedMonitor() const { + return &this->bFocusedMonitor; + } + + [[nodiscard]] QBindable bindableFocusedWorkspace() const { + return &this->bFocusedWorkspace; + } + + [[nodiscard]] QBindable bindableActiveWindow() const { return &this->bActiveWindow; } + + [[nodiscard]] QBindable bindableOverviewOpen() const { return &this->bOverviewOpen; } + + [[nodiscard]] QBindable bindableCurrentKeyboardLayout() const { + return &this->bCurrentKeyboardLayout; + } + + [[nodiscard]] QStringList keyboardLayouts() const { return this->mKeyboardLayouts; } + + void setFocusedMonitor(NiriMonitor* monitor); + void setActiveWindow(NiriWindow* window); + + [[nodiscard]] ObjectModel* monitors(); + [[nodiscard]] ObjectModel* workspaces(); + [[nodiscard]] ObjectModel* windows(); + [[nodiscard]] ObjectModel* casts(); + + NiriMonitor* findMonitorByName(const QString& name, bool createIfMissing); + NiriWorkspace* findWorkspaceById(qint64 id); + NiriWindow* findWindowById(qint64 id); + NiriCast* findCastByStreamId(qint64 streamId); + + void refreshMonitors(); + void refreshWorkspaces(); + void refreshWindows(); + void refreshCasts(); + +signals: + void connected(); + void rawEvent(NiriIpcEvent* event); + + /// Emitted when niri reloads its configuration. Also emitted once when + /// the event stream connects, indicating the last config load attempt. + void configLoaded(bool failed); + /// Emitted when niri captures a screenshot. Empty path when the screenshot + /// was only copied to the clipboard. + void screenshotCaptured(const QString& path); + + void focusedMonitorChanged(); + void focusedWorkspaceChanged(); + void activeWindowChanged(); + void overviewOpenChanged(); + void keyboardLayoutsChanged(); + void currentKeyboardLayoutChanged(); + +private slots: + void socketError(QLocalSocket::LocalSocketError error) const; + void socketStateChanged(QLocalSocket::LocalSocketState state); + void socketReady(); + + void onFocusedMonitorDestroyed(); + +private: + explicit NiriIpc(); + + void onEvent(NiriIpcEvent* event); + + static bool compareWorkspaces(NiriWorkspace* a, NiriWorkspace* b); + + void updateWorkspaces(const QVariantList& workspaces); + void updateMonitors(const QVariantList& monitors); + void updateWindows(const QVariantList& windows); + void updateCasts(const QVariantList& casts); + + QLocalSocket eventSocket; + StreamReader eventReader; + QTimer reconnectTimer; + QString mSocketPath; + bool valid = false; + bool requestingMonitors = false; + bool requestingWorkspaces = false; + bool requestingWindows = false; + bool requestingCasts = false; + bool monitorsRequested = false; + + ObjectModel mMonitors {this}; + ObjectModel mWorkspaces {this}; + ObjectModel mWindows {this}; + ObjectModel mCasts {this}; + + QStringList mKeyboardLayouts; + + NiriIpcEvent event {this}; + + Q_OBJECT_BINDABLE_PROPERTY( + NiriIpc, + NiriMonitor*, + bFocusedMonitor, + &NiriIpc::focusedMonitorChanged + ); + + Q_OBJECT_BINDABLE_PROPERTY( + NiriIpc, + NiriWorkspace*, + bFocusedWorkspace, + &NiriIpc::focusedWorkspaceChanged + ); + + Q_OBJECT_BINDABLE_PROPERTY(NiriIpc, NiriWindow*, bActiveWindow, &NiriIpc::activeWindowChanged); + Q_OBJECT_BINDABLE_PROPERTY(NiriIpc, bool, bOverviewOpen, &NiriIpc::overviewOpenChanged); + + Q_OBJECT_BINDABLE_PROPERTY( + NiriIpc, + qint32, + bCurrentKeyboardLayout, + &NiriIpc::currentKeyboardLayoutChanged + ); +}; + +} // namespace qs::niri::ipc diff --git a/src/wayland/niri/ipc/monitor.cpp b/src/wayland/niri/ipc/monitor.cpp new file mode 100644 index 000000000..bdfcda598 --- /dev/null +++ b/src/wayland/niri/ipc/monitor.cpp @@ -0,0 +1,69 @@ +#include "monitor.hpp" + +#include +#include +#include +#include +#include +#include + +#include "connection.hpp" +#include "workspace.hpp" + +namespace qs::niri::ipc { + +NiriMonitor::NiriMonitor(NiriIpc* ipc): QObject(ipc), ipc(ipc) { + this->bFocused.setBinding([this]() { + return this->ipc->bindableFocusedMonitor().value() == this; + }); +} + +void NiriMonitor::updateInitial(const QString& name) { this->bName = name; } + +void NiriMonitor::updateFromObject(const QVariantMap& object) { + Qt::beginPropertyUpdateGroup(); + + this->bName = object.value("name").value(); + this->bMake = object.value("make").value(); + this->bModel = object.value("model").value(); + + // logical is null when the output is disabled. + auto logical = object.value("logical").toMap(); + + this->bX = logical.value("x").value(); + this->bY = logical.value("y").value(); + this->bWidth = logical.value("width").value(); + this->bHeight = logical.value("height").value(); + this->bScale = logical.value("scale").value(); + + this->mLastIpcObject = object; + emit this->lastIpcObjectChanged(); + + Qt::endPropertyUpdateGroup(); +} + +QVariantMap NiriMonitor::lastIpcObject() const { return this->mLastIpcObject; } + +void NiriMonitor::setActiveWorkspace(NiriWorkspace* workspace) { + auto* oldWorkspace = this->bActiveWorkspace.value(); + if (workspace == oldWorkspace) return; + + if (oldWorkspace != nullptr) { + QObject::disconnect(oldWorkspace, nullptr, this, nullptr); + } + + if (workspace != nullptr) { + QObject::connect( + workspace, + &QObject::destroyed, + this, + &NiriMonitor::onActiveWorkspaceDestroyed + ); + } + + this->bActiveWorkspace = workspace; +} + +void NiriMonitor::onActiveWorkspaceDestroyed() { this->bActiveWorkspace = nullptr; } + +} // namespace qs::niri::ipc diff --git a/src/wayland/niri/ipc/monitor.hpp b/src/wayland/niri/ipc/monitor.hpp new file mode 100644 index 000000000..32be47aa2 --- /dev/null +++ b/src/wayland/niri/ipc/monitor.hpp @@ -0,0 +1,105 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include "connection.hpp" +#include "workspace.hpp" + +namespace qs::niri::ipc { + +class NiriMonitor: public QObject { + Q_OBJECT; + // clang-format off + /// Name of the output (e.g. "DP-1"). + Q_PROPERTY(QString name READ default NOTIFY nameChanged BINDABLE bindableName); + /// Textual description of the manufacturer. + Q_PROPERTY(QString make READ default NOTIFY makeChanged BINDABLE bindableMake); + /// Textual description of the model. + Q_PROPERTY(QString model READ default NOTIFY modelChanged BINDABLE bindableModel); + /// Logical position of the output. Zero when the output is disabled. + Q_PROPERTY(qint32 x READ default NOTIFY xChanged BINDABLE bindableX); + /// Logical position of the output. Zero when the output is disabled. + Q_PROPERTY(qint32 y READ default NOTIFY yChanged BINDABLE bindableY); + /// Logical width of the output. Zero when the output is disabled. + Q_PROPERTY(qint32 width READ default NOTIFY widthChanged BINDABLE bindableWidth); + /// Logical height of the output. Zero when the output is disabled. + Q_PROPERTY(qint32 height READ default NOTIFY heightChanged BINDABLE bindableHeight); + /// Scale factor of the output. Zero when the output is disabled. + Q_PROPERTY(qreal scale READ default NOTIFY scaleChanged BINDABLE bindableScale); + /// Last json returned for this monitor, as a javascript object. + /// + /// > [!WARNING] This is *not* updated automatically. Niri sends no output events, + /// > run @@Niri.refreshMonitors() and wait for this property to update. + Q_PROPERTY(QVariantMap lastIpcObject READ lastIpcObject NOTIFY lastIpcObjectChanged); + /// The currently active workspace on this monitor. May be null. + Q_PROPERTY(qs::niri::ipc::NiriWorkspace* activeWorkspace READ default NOTIFY activeWorkspaceChanged BINDABLE bindableActiveWorkspace); + /// If the monitor is currently focused. + Q_PROPERTY(bool focused READ default NOTIFY focusedChanged BINDABLE bindableFocused); + // clang-format on + QML_ELEMENT; + QML_UNCREATABLE("NiriMonitors must be retrieved from the Niri singleton."); + +public: + explicit NiriMonitor(NiriIpc* ipc); + + void updateInitial(const QString& name); + void updateFromObject(const QVariantMap& object); + + [[nodiscard]] QBindable bindableName() { return &this->bName; } + [[nodiscard]] QBindable bindableMake() { return &this->bMake; } + [[nodiscard]] QBindable bindableModel() { return &this->bModel; } + [[nodiscard]] QBindable bindableX() { return &this->bX; } + [[nodiscard]] QBindable bindableY() { return &this->bY; } + [[nodiscard]] QBindable bindableWidth() { return &this->bWidth; } + [[nodiscard]] QBindable bindableHeight() { return &this->bHeight; } + [[nodiscard]] QBindable bindableScale() { return &this->bScale; } + + [[nodiscard]] QBindable bindableActiveWorkspace() { + return &this->bActiveWorkspace; + } + + [[nodiscard]] QBindable bindableFocused() { return &this->bFocused; } + + [[nodiscard]] QVariantMap lastIpcObject() const; + + void setActiveWorkspace(NiriWorkspace* workspace); + +signals: + void nameChanged(); + void makeChanged(); + void modelChanged(); + void xChanged(); + void yChanged(); + void widthChanged(); + void heightChanged(); + void scaleChanged(); + void lastIpcObjectChanged(); + void activeWorkspaceChanged(); + void focusedChanged(); + +private slots: + void onActiveWorkspaceDestroyed(); + +private: + NiriIpc* ipc; + QVariantMap mLastIpcObject; + + // clang-format off + Q_OBJECT_BINDABLE_PROPERTY(NiriMonitor, QString, bName, &NiriMonitor::nameChanged); + Q_OBJECT_BINDABLE_PROPERTY(NiriMonitor, QString, bMake, &NiriMonitor::makeChanged); + Q_OBJECT_BINDABLE_PROPERTY(NiriMonitor, QString, bModel, &NiriMonitor::modelChanged); + Q_OBJECT_BINDABLE_PROPERTY(NiriMonitor, qint32, bX, &NiriMonitor::xChanged); + Q_OBJECT_BINDABLE_PROPERTY(NiriMonitor, qint32, bY, &NiriMonitor::yChanged); + Q_OBJECT_BINDABLE_PROPERTY(NiriMonitor, qint32, bWidth, &NiriMonitor::widthChanged); + Q_OBJECT_BINDABLE_PROPERTY(NiriMonitor, qint32, bHeight, &NiriMonitor::heightChanged); + Q_OBJECT_BINDABLE_PROPERTY(NiriMonitor, qreal, bScale, &NiriMonitor::scaleChanged); + Q_OBJECT_BINDABLE_PROPERTY(NiriMonitor, NiriWorkspace*, bActiveWorkspace, &NiriMonitor::activeWorkspaceChanged); + Q_OBJECT_BINDABLE_PROPERTY(NiriMonitor, bool, bFocused, &NiriMonitor::focusedChanged); + // clang-format on +}; + +} // namespace qs::niri::ipc diff --git a/src/wayland/niri/ipc/qml.cpp b/src/wayland/niri/ipc/qml.cpp new file mode 100644 index 000000000..9c23ac546 --- /dev/null +++ b/src/wayland/niri/ipc/qml.cpp @@ -0,0 +1,99 @@ +#include "qml.hpp" + +#include +#include +#include +#include +#include + +#include "../../../core/model.hpp" +#include "../../../core/qmlscreen.hpp" +#include "cast.hpp" +#include "connection.hpp" +#include "monitor.hpp" +#include "window.hpp" +#include "workspace.hpp" + +namespace qs::niri::ipc { + +NiriIpcQml::NiriIpcQml() { + auto* instance = NiriIpc::instance(); + + QObject::connect(instance, &NiriIpc::rawEvent, this, &NiriIpcQml::rawEvent); + + QObject::connect( + instance, + &NiriIpc::focusedMonitorChanged, + this, + &NiriIpcQml::focusedMonitorChanged + ); + + QObject::connect( + instance, + &NiriIpc::focusedWorkspaceChanged, + this, + &NiriIpcQml::focusedWorkspaceChanged + ); + + QObject::connect(instance, &NiriIpc::activeWindowChanged, this, &NiriIpcQml::activeWindowChanged); + + QObject::connect(instance, &NiriIpc::overviewOpenChanged, this, &NiriIpcQml::overviewOpenChanged); + + QObject::connect( + instance, + &NiriIpc::keyboardLayoutsChanged, + this, + &NiriIpcQml::keyboardLayoutsChanged + ); + + QObject::connect( + instance, + &NiriIpc::currentKeyboardLayoutChanged, + this, + &NiriIpcQml::currentKeyboardLayoutChanged + ); + + QObject::connect(instance, &NiriIpc::configLoaded, this, &NiriIpcQml::configLoaded); + QObject::connect(instance, &NiriIpc::screenshotCaptured, this, &NiriIpcQml::screenshotCaptured); +} + +void NiriIpcQml::dispatch(const QString& request) { NiriIpc::instance()->dispatch(request); } + +NiriMonitor* NiriIpcQml::monitorFor(QuickshellScreenInfo* screen) { + return NiriIpc::instance()->monitorFor(screen); +} + +void NiriIpcQml::refreshMonitors() { NiriIpc::instance()->refreshMonitors(); } +void NiriIpcQml::refreshWorkspaces() { NiriIpc::instance()->refreshWorkspaces(); } +void NiriIpcQml::refreshWindows() { NiriIpc::instance()->refreshWindows(); } +void NiriIpcQml::refreshCasts() { NiriIpc::instance()->refreshCasts(); } +QString NiriIpcQml::socketPath() { return NiriIpc::instance()->socketPath(); } + +QBindable NiriIpcQml::bindableFocusedMonitor() { + return NiriIpc::instance()->bindableFocusedMonitor(); +} + +QBindable NiriIpcQml::bindableFocusedWorkspace() { + return NiriIpc::instance()->bindableFocusedWorkspace(); +} + +QBindable NiriIpcQml::bindableActiveWindow() { + return NiriIpc::instance()->bindableActiveWindow(); +} + +QBindable NiriIpcQml::bindableOverviewOpen() { + return NiriIpc::instance()->bindableOverviewOpen(); +} + +QBindable NiriIpcQml::bindableCurrentKeyboardLayout() { + return NiriIpc::instance()->bindableCurrentKeyboardLayout(); +} + +QStringList NiriIpcQml::keyboardLayouts() { return NiriIpc::instance()->keyboardLayouts(); } + +ObjectModel* NiriIpcQml::monitors() { return NiriIpc::instance()->monitors(); } +ObjectModel* NiriIpcQml::workspaces() { return NiriIpc::instance()->workspaces(); } +ObjectModel* NiriIpcQml::windows() { return NiriIpc::instance()->windows(); } +ObjectModel* NiriIpcQml::casts() { return NiriIpc::instance()->casts(); } + +} // namespace qs::niri::ipc diff --git a/src/wayland/niri/ipc/qml.hpp b/src/wayland/niri/ipc/qml.hpp new file mode 100644 index 000000000..75ca0cd04 --- /dev/null +++ b/src/wayland/niri/ipc/qml.hpp @@ -0,0 +1,133 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include "../../../core/model.hpp" +#include "../../../core/qmlscreen.hpp" +#include "cast.hpp" +#include "connection.hpp" +#include "monitor.hpp" +#include "window.hpp" +#include "workspace.hpp" + +namespace qs::niri::ipc { + +///! Niri IPC integration. +/// Exposes live niri compositor state: workspaces, windows, monitors, +/// screencasts, the overview, and keyboard layouts. +/// +/// > [!NOTE] Unlike the Hyprland module, niri windows cannot be associated with +/// > @@Quickshell.Wayland.Toplevel handles, as niri does not expose its window +/// > ids over wayland. @@NiriWindow objects are purely informational. +class NiriIpcQml: public QObject { + Q_OBJECT; + // clang-format off + /// Path to the niri IPC socket ($NIRI_SOCKET). + Q_PROPERTY(QString socketPath READ socketPath CONSTANT); + /// The currently focused niri monitor. May be null. + Q_PROPERTY(qs::niri::ipc::NiriMonitor* focusedMonitor READ default NOTIFY focusedMonitorChanged BINDABLE bindableFocusedMonitor); + /// The currently focused niri workspace. May be null. + Q_PROPERTY(qs::niri::ipc::NiriWorkspace* focusedWorkspace READ default NOTIFY focusedWorkspaceChanged BINDABLE bindableFocusedWorkspace); + /// Currently active window. May be null. + Q_PROPERTY(qs::niri::ipc::NiriWindow* activeWindow READ default NOTIFY activeWindowChanged BINDABLE bindableActiveWindow); + /// If the niri overview is currently open. + Q_PROPERTY(bool overviewOpen READ default NOTIFY overviewOpenChanged BINDABLE bindableOverviewOpen); + /// XKB names of the configured keyboard layouts. + Q_PROPERTY(QStringList keyboardLayouts READ keyboardLayouts NOTIFY keyboardLayoutsChanged); + /// Index of the currently active layout in @@keyboardLayouts. + Q_PROPERTY(qint32 currentKeyboardLayout READ default NOTIFY currentKeyboardLayoutChanged BINDABLE bindableCurrentKeyboardLayout); + /// All niri monitors. + QSDOC_TYPE_OVERRIDE(ObjectModel*); + Q_PROPERTY(UntypedObjectModel* monitors READ monitors CONSTANT); + /// All niri workspaces, sorted by monitor and index. + QSDOC_TYPE_OVERRIDE(ObjectModel*); + Q_PROPERTY(UntypedObjectModel* workspaces READ workspaces CONSTANT); + /// All niri windows. + QSDOC_TYPE_OVERRIDE(ObjectModel*); + Q_PROPERTY(UntypedObjectModel* windows READ windows CONSTANT); + /// All niri screencasts. + QSDOC_TYPE_OVERRIDE(ObjectModel*); + Q_PROPERTY(UntypedObjectModel* casts READ casts CONSTANT); + // clang-format on + QML_NAMED_ELEMENT(Niri); + QML_SINGLETON; + +public: + explicit NiriIpcQml(); + + /// Execute a niri action, given as a JSON string. + /// + /// ```qml + /// Niri.dispatch('{"FocusWorkspaceDown":{}}'); + /// ``` + /// + /// See the [niri-ipc documentation](https://niri-wm.github.io/niri/niri_ipc/enum.Action.html) + /// for available actions. + Q_INVOKABLE static void dispatch(const QString& request); + + /// Get the NiriMonitor object that corresponds to a quickshell screen. + Q_INVOKABLE static NiriMonitor* monitorFor(QuickshellScreenInfo* screen); + + /// Refresh monitor information. + /// + /// Niri sends no events for output changes, so this function must be + /// called to update monitor state (e.g. after hotplugging a display). + Q_INVOKABLE static void refreshMonitors(); + + /// Refresh workspace information. + /// + /// The event stream keeps workspaces up to date, so this is usually + /// not required. + Q_INVOKABLE static void refreshWorkspaces(); + + /// Refresh window information. + /// + /// The event stream keeps windows up to date, so this is usually + /// not required. + Q_INVOKABLE static void refreshWindows(); + + /// Refresh screencast information. + /// + /// The event stream keeps casts up to date, so this is usually + /// not required. + Q_INVOKABLE static void refreshCasts(); + + [[nodiscard]] static QString socketPath(); + [[nodiscard]] static QBindable bindableFocusedMonitor(); + [[nodiscard]] static QBindable bindableFocusedWorkspace(); + [[nodiscard]] static QBindable bindableActiveWindow(); + [[nodiscard]] static QBindable bindableOverviewOpen(); + [[nodiscard]] static QBindable bindableCurrentKeyboardLayout(); + [[nodiscard]] static QStringList keyboardLayouts(); + [[nodiscard]] static ObjectModel* monitors(); + [[nodiscard]] static ObjectModel* workspaces(); + [[nodiscard]] static ObjectModel* windows(); + [[nodiscard]] static ObjectModel* casts(); + +signals: + /// Emitted for every event that comes in through the niri event stream. + /// + /// See the [niri-ipc documentation](https://niri-wm.github.io/niri/niri_ipc/enum.Event.html) + /// for a list of events. + void rawEvent(qs::niri::ipc::NiriIpcEvent* event); + + /// Emitted when niri reloads its configuration. Also emitted once when + /// quickshell connects to niri, indicating the last config load attempt. + void configLoaded(bool failed); + /// Emitted when niri captures a screenshot. Empty path when the screenshot + /// was only copied to the clipboard. + void screenshotCaptured(const QString& path); + + void focusedMonitorChanged(); + void focusedWorkspaceChanged(); + void activeWindowChanged(); + void overviewOpenChanged(); + void keyboardLayoutsChanged(); + void currentKeyboardLayoutChanged(); +}; + +} // namespace qs::niri::ipc diff --git a/src/wayland/niri/ipc/window.cpp b/src/wayland/niri/ipc/window.cpp new file mode 100644 index 000000000..9f597aaf3 --- /dev/null +++ b/src/wayland/niri/ipc/window.cpp @@ -0,0 +1,92 @@ +#include "window.hpp" + +#include +#include +#include +#include +#include +#include + +#include "connection.hpp" +#include "workspace.hpp" + +namespace qs::niri::ipc { + +NiriWindow::NiriWindow(NiriIpc* ipc): QObject(ipc), ipc(ipc) {} + +void NiriWindow::updateFromObject(const QVariantMap& object) { + Qt::beginPropertyUpdateGroup(); + + // ID cannot be updated after creation. + if (this->bId.value() == 0) { + this->bId = object.value("id").value(); + } + + this->bTitle = object.value("title").value(); + this->bAppId = object.value("app_id").value(); + this->bPid = object.value("pid").value(); + this->bFocused = object.value("is_focused").value(); + this->bFloating = object.value("is_floating").value(); + this->bUrgent = object.value("is_urgent").value(); + + // workspace_id may reference a workspace that was already removed, as niri + // events are not always atomic. See NiriIpc::onEvent. + auto workspaceId = object.value("workspace_id").value(); + auto* currentWorkspace = this->bWorkspace.value(); + + if (workspaceId == 0) { + this->setWorkspace(nullptr); + } else if (currentWorkspace == nullptr || currentWorkspace->bindableId().value() != workspaceId) { + this->setWorkspace(this->ipc->findWorkspaceById(workspaceId)); + } + + this->mLastIpcObject = object; + emit this->lastIpcObjectChanged(); + + this->mLayout = object.value("layout").toMap(); + emit this->layoutChanged(); + + this->mFocusTimestamp = object.value("focus_timestamp").toMap(); + emit this->focusTimestampChanged(); + + Qt::endPropertyUpdateGroup(); +} + +void NiriWindow::setLayout(const QVariantMap& layout) { + this->mLayout = layout; + emit this->layoutChanged(); +} + +void NiriWindow::setFocusTimestamp(const QVariantMap& focusTimestamp) { + this->mFocusTimestamp = focusTimestamp; + emit this->focusTimestampChanged(); +} + +void NiriWindow::setWorkspace(NiriWorkspace* workspace) { + auto* oldWorkspace = this->bWorkspace.value(); + if (workspace == oldWorkspace) return; + + if (oldWorkspace != nullptr) { + QObject::disconnect(oldWorkspace, nullptr, this, nullptr); + } + + if (workspace != nullptr) { + QObject::connect(workspace, &QObject::destroyed, this, &NiriWindow::onWorkspaceDestroyed); + } + + this->bWorkspace = workspace; +} + +void NiriWindow::onWorkspaceDestroyed() { this->bWorkspace = nullptr; } + +QVariantMap NiriWindow::lastIpcObject() const { return this->mLastIpcObject; } + +QVariantMap NiriWindow::layout() const { return this->mLayout; } + +QVariantMap NiriWindow::focusTimestamp() const { return this->mFocusTimestamp; } + +void NiriWindow::activate() { + this->ipc->dispatch(QString(R"({"FocusWindow":{"id":%1}})").arg(this->bId.value())); +} + +} // namespace qs::niri::ipc diff --git a/src/wayland/niri/ipc/window.hpp b/src/wayland/niri/ipc/window.hpp new file mode 100644 index 000000000..9cb85b5da --- /dev/null +++ b/src/wayland/niri/ipc/window.hpp @@ -0,0 +1,112 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +#include "connection.hpp" + +namespace qs::niri::ipc { + +class NiriWorkspace; + +class NiriWindow: public QObject { + Q_OBJECT; + // clang-format off + /// Niri's unique window id. Remains constant while the window is open. + Q_PROPERTY(qint64 id READ default NOTIFY idChanged BINDABLE bindableId); + /// Title of the window. Empty when unset. + Q_PROPERTY(QString title READ default NOTIFY titleChanged BINDABLE bindableTitle); + /// Application ID of the window. Empty when unset. + Q_PROPERTY(QString appId READ default NOTIFY appIdChanged BINDABLE bindableAppId); + /// Process ID that created the window, or 0 if unknown. + Q_PROPERTY(qint32 pid READ default NOTIFY pidChanged BINDABLE bindablePid); + /// The workspace this window is on. May be null. + Q_PROPERTY(qs::niri::ipc::NiriWorkspace* workspace READ default NOTIFY workspaceChanged BINDABLE bindableWorkspace); + /// If this window is currently focused. At most one window is focused at a time. + Q_PROPERTY(bool focused READ default NOTIFY focusedChanged BINDABLE bindableFocused); + /// If this window is in the floating layout. Otherwise it is tiled. + Q_PROPERTY(bool floating READ default NOTIFY floatingChanged BINDABLE bindableFloating); + /// If this window requests attention. + Q_PROPERTY(bool urgent READ default NOTIFY urgentChanged BINDABLE bindableUrgent); + /// Last json returned for this window, as a javascript object. + /// + /// > [!WARNING] This is *not* updated for every property change. Niri sends some + /// > updates, such as layout changes, separately. If you need a value that does not + /// > have a dedicated property, run @@Niri.refreshWindows() and wait for this + /// > property to update. + Q_PROPERTY(QVariantMap lastIpcObject READ lastIpcObject NOTIFY lastIpcObjectChanged); + /// Position- and size-related properties of the window, as a javascript object. + /// Updated when niri reports layout changes. Optional properties of the layout + /// may be absent for some windows. + Q_PROPERTY(QVariantMap layout READ layout NOTIFY layoutChanged); + /// Timestamp when the window was most recently focused, as a javascript object + /// with `secs` and `nanos` fields from the monotonic clock. Empty if never focused. + Q_PROPERTY(QVariantMap focusTimestamp READ focusTimestamp NOTIFY focusTimestampChanged); + // clang-format on + QML_ELEMENT; + QML_UNCREATABLE("NiriWindows must be retrieved from the Niri singleton."); + +public: + explicit NiriWindow(NiriIpc* ipc); + + void updateFromObject(const QVariantMap& object); + + /// Focus this window. + Q_INVOKABLE void activate(); + + [[nodiscard]] QBindable bindableId() { return &this->bId; } + [[nodiscard]] QBindable bindableTitle() { return &this->bTitle; } + [[nodiscard]] QBindable bindableAppId() { return &this->bAppId; } + [[nodiscard]] QBindable bindablePid() { return &this->bPid; } + [[nodiscard]] QBindable bindableWorkspace() { return &this->bWorkspace; } + [[nodiscard]] QBindable bindableFocused() { return &this->bFocused; } + [[nodiscard]] QBindable bindableFloating() { return &this->bFloating; } + [[nodiscard]] QBindable bindableUrgent() { return &this->bUrgent; } + + [[nodiscard]] QVariantMap lastIpcObject() const; + [[nodiscard]] QVariantMap layout() const; + [[nodiscard]] QVariantMap focusTimestamp() const; + + void setWorkspace(NiriWorkspace* workspace); + void setLayout(const QVariantMap& layout); + void setFocusTimestamp(const QVariantMap& focusTimestamp); + +signals: + void idChanged(); + void titleChanged(); + void appIdChanged(); + void pidChanged(); + void workspaceChanged(); + void focusedChanged(); + void floatingChanged(); + void urgentChanged(); + void lastIpcObjectChanged(); + void layoutChanged(); + void focusTimestampChanged(); + +private slots: + void onWorkspaceDestroyed(); + +private: + NiriIpc* ipc; + QVariantMap mLastIpcObject; + QVariantMap mLayout; + QVariantMap mFocusTimestamp; + + // clang-format off + Q_OBJECT_BINDABLE_PROPERTY_WITH_ARGS(NiriWindow, qint64, bId, 0, &NiriWindow::idChanged); + Q_OBJECT_BINDABLE_PROPERTY(NiriWindow, QString, bTitle, &NiriWindow::titleChanged); + Q_OBJECT_BINDABLE_PROPERTY(NiriWindow, QString, bAppId, &NiriWindow::appIdChanged); + Q_OBJECT_BINDABLE_PROPERTY(NiriWindow, qint32, bPid, &NiriWindow::pidChanged); + Q_OBJECT_BINDABLE_PROPERTY(NiriWindow, NiriWorkspace*, bWorkspace, &NiriWindow::workspaceChanged); + Q_OBJECT_BINDABLE_PROPERTY(NiriWindow, bool, bFocused, &NiriWindow::focusedChanged); + Q_OBJECT_BINDABLE_PROPERTY(NiriWindow, bool, bFloating, &NiriWindow::floatingChanged); + Q_OBJECT_BINDABLE_PROPERTY(NiriWindow, bool, bUrgent, &NiriWindow::urgentChanged); + // clang-format on +}; + +} // namespace qs::niri::ipc diff --git a/src/wayland/niri/ipc/workspace.cpp b/src/wayland/niri/ipc/workspace.cpp new file mode 100644 index 000000000..17a18bb06 --- /dev/null +++ b/src/wayland/niri/ipc/workspace.cpp @@ -0,0 +1,71 @@ +#include "workspace.hpp" + +#include +#include +#include +#include +#include + +#include "connection.hpp" +#include "monitor.hpp" + +namespace qs::niri::ipc { + +NiriWorkspace::NiriWorkspace(NiriIpc* ipc): QObject(ipc), ipc(ipc) { + this->bFocused.setBinding([this]() { + return this->ipc->bindableFocusedWorkspace().value() == this; + }); +} + +void NiriWorkspace::updateFromObject(const QVariantMap& object) { + Qt::beginPropertyUpdateGroup(); + + // ID cannot be updated after creation. + if (this->bId.value() == 0) { + this->bId = object.value("id").value(); + } + + this->bIdx = object.value("idx").value(); + this->bName = object.value("name").value(); + this->bActive = object.value("is_active").value(); + this->bUrgent = object.value("is_urgent").value(); + this->bActiveWindowId = object.value("active_window_id").value(); + + // output is null when no outputs are connected. + auto outputName = object.value("output").value(); + if (outputName.isEmpty()) { + this->setMonitor(nullptr); + } else if (this->bMonitor == nullptr + || this->bMonitor->bindableName().value() != outputName) + { + auto* monitor = this->ipc->findMonitorByName(outputName, true); + this->setMonitor(monitor); + } + + Qt::endPropertyUpdateGroup(); +} + +void NiriWorkspace::setMonitor(NiriMonitor* monitor) { + auto* oldMonitor = this->bMonitor.value(); + if (monitor == oldMonitor) return; + + if (oldMonitor != nullptr) { + QObject::disconnect(oldMonitor, nullptr, this, nullptr); + } + + if (monitor != nullptr) { + QObject::connect(monitor, &QObject::destroyed, this, &NiriWorkspace::onMonitorDestroyed); + } + + this->bMonitor = monitor; +} + +void NiriWorkspace::onMonitorDestroyed() { this->bMonitor = nullptr; } + +void NiriWorkspace::activate() { + this->ipc->dispatch( + QString(R"({"FocusWorkspace":{"reference":{"Id":%1}}})").arg(this->bId.value()) + ); +} + +} // namespace qs::niri::ipc diff --git a/src/wayland/niri/ipc/workspace.hpp b/src/wayland/niri/ipc/workspace.hpp new file mode 100644 index 000000000..3152d316c --- /dev/null +++ b/src/wayland/niri/ipc/workspace.hpp @@ -0,0 +1,86 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +#include "connection.hpp" + +namespace qs::niri::ipc { + +class NiriMonitor; + +class NiriWorkspace: public QObject { + Q_OBJECT; + // clang-format off + /// Niri's unique workspace id. Remains constant while the workspace exists. + Q_PROPERTY(qint64 id READ default NOTIFY idChanged BINDABLE bindableId); + /// Index of the workspace on its monitor. Changes as workspaces are moved. + Q_PROPERTY(qint32 idx READ default NOTIFY idxChanged BINDABLE bindableIdx); + /// Name of the workspace. Empty for unnamed workspaces. + Q_PROPERTY(QString name READ default NOTIFY nameChanged BINDABLE bindableName); + /// If this workspace is currently active on its monitor. See also @@focused. + Q_PROPERTY(bool active READ default NOTIFY activeChanged BINDABLE bindableActive); + /// If this workspace is currently focused. Only one workspace is focused across all outputs. + Q_PROPERTY(bool focused READ default NOTIFY focusedChanged BINDABLE bindableFocused); + /// If this workspace contains an urgent window. + Q_PROPERTY(bool urgent READ default NOTIFY urgentChanged BINDABLE bindableUrgent); + /// The monitor this workspace is on. May be null if no outputs are connected. + Q_PROPERTY(qs::niri::ipc::NiriMonitor* monitor READ default NOTIFY monitorChanged BINDABLE bindableMonitor); + /// Id of the active window on this workspace, or 0 if none. + Q_PROPERTY(qint64 activeWindowId READ default NOTIFY activeWindowIdChanged BINDABLE bindableActiveWindowId); + // clang-format on + QML_ELEMENT; + QML_UNCREATABLE("NiriWorkspaces must be retrieved from the Niri singleton."); + +public: + explicit NiriWorkspace(NiriIpc* ipc); + + void updateFromObject(const QVariantMap& object); + + /// Focus this workspace. + Q_INVOKABLE void activate(); + + [[nodiscard]] QBindable bindableId() { return &this->bId; } + [[nodiscard]] QBindable bindableIdx() { return &this->bIdx; } + [[nodiscard]] QBindable bindableName() { return &this->bName; } + [[nodiscard]] QBindable bindableActive() { return &this->bActive; } + [[nodiscard]] QBindable bindableFocused() { return &this->bFocused; } + [[nodiscard]] QBindable bindableUrgent() { return &this->bUrgent; } + [[nodiscard]] QBindable bindableMonitor() { return &this->bMonitor; } + [[nodiscard]] QBindable bindableActiveWindowId() { return &this->bActiveWindowId; } + + void setMonitor(NiriMonitor* monitor); + +signals: + void idChanged(); + void idxChanged(); + void nameChanged(); + void activeChanged(); + void focusedChanged(); + void urgentChanged(); + void monitorChanged(); + void activeWindowIdChanged(); + +private slots: + void onMonitorDestroyed(); + +private: + NiriIpc* ipc; + + // clang-format off + Q_OBJECT_BINDABLE_PROPERTY_WITH_ARGS(NiriWorkspace, qint64, bId, 0, &NiriWorkspace::idChanged); + Q_OBJECT_BINDABLE_PROPERTY(NiriWorkspace, qint32, bIdx, &NiriWorkspace::idxChanged); + Q_OBJECT_BINDABLE_PROPERTY(NiriWorkspace, QString, bName, &NiriWorkspace::nameChanged); + Q_OBJECT_BINDABLE_PROPERTY(NiriWorkspace, bool, bActive, &NiriWorkspace::activeChanged); + Q_OBJECT_BINDABLE_PROPERTY(NiriWorkspace, bool, bFocused, &NiriWorkspace::focusedChanged); + Q_OBJECT_BINDABLE_PROPERTY(NiriWorkspace, bool, bUrgent, &NiriWorkspace::urgentChanged); + Q_OBJECT_BINDABLE_PROPERTY(NiriWorkspace, NiriMonitor*, bMonitor, &NiriWorkspace::monitorChanged); + Q_OBJECT_BINDABLE_PROPERTY(NiriWorkspace, qint64, bActiveWindowId, &NiriWorkspace::activeWindowIdChanged); + // clang-format on +}; + +} // namespace qs::niri::ipc diff --git a/src/wayland/niri/module.md b/src/wayland/niri/module.md new file mode 100644 index 000000000..8157224f2 --- /dev/null +++ b/src/wayland/niri/module.md @@ -0,0 +1,11 @@ +name = "Quickshell.Niri" +description = "Niri specific Quickshell types" +headers = [ + "ipc/cast.hpp", + "ipc/connection.hpp", + "ipc/monitor.hpp", + "ipc/workspace.hpp", + "ipc/window.hpp", + "ipc/qml.hpp", +] +----- diff --git a/src/wayland/niri/test/manual/connection.qml b/src/wayland/niri/test/manual/connection.qml new file mode 100644 index 000000000..2d6e444df --- /dev/null +++ b/src/wayland/niri/test/manual/connection.qml @@ -0,0 +1,16 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Widgets +import Quickshell.Niri + +FloatingWindow { + Component.onCompleted: console.log("niri socket:", Niri.socketPath) + + Connections { + target: Niri + function onRawEvent(event: NiriEvent) { + console.log("event:", event.name, event.data) + } + } +} diff --git a/src/wayland/niri/test/manual/misc.qml b/src/wayland/niri/test/manual/misc.qml new file mode 100644 index 000000000..91c754cba --- /dev/null +++ b/src/wayland/niri/test/manual/misc.qml @@ -0,0 +1,48 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Widgets +import Quickshell.Niri + +FloatingWindow { + Connections { + target: Niri + function onConfigLoaded(failed: bool) { console.log("niri config loaded, failed:", failed) } + function onScreenshotCaptured(path: string) { console.log("niri screenshot captured:", path) } + } + + ColumnLayout { + anchors.fill: parent + + WrapperRectangle { + color: Niri.overviewOpen ? "lightblue" : "white" + + Text { text: `overview open: ${Niri.overviewOpen}` } + } + + WrapperRectangle { + color: "white" + + Text { text: `keyboard layout: ${Niri.keyboardLayouts[Niri.currentKeyboardLayout] ?? "none"} (${Niri.currentKeyboardLayout + 1}/${Niri.keyboardLayouts.length})` } + } + + ListView { + Layout.fillWidth: true + Layout.fillHeight: true + model: Niri.casts + spacing: 5 + + delegate: WrapperRectangle { + id: castDelegate + required property NiriCast modelData + color: castDelegate.modelData.active ? "lightgreen" : "white" + + RowLayout { + Text { + text: `cast ${castDelegate.modelData.streamId} ${castDelegate.modelData.kind} target: ${castDelegate.modelData.targetOutput || castDelegate.modelData.targetWindowId || "none"} | active: ${castDelegate.modelData.active} dynamic: ${castDelegate.modelData.dynamicTarget} pid: ${castDelegate.modelData.pid} node: ${castDelegate.modelData.pwNodeId}` + } + } + } + } + } +} diff --git a/src/wayland/niri/test/manual/monitors.qml b/src/wayland/niri/test/manual/monitors.qml new file mode 100644 index 000000000..3e4f7f1ac --- /dev/null +++ b/src/wayland/niri/test/manual/monitors.qml @@ -0,0 +1,55 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Widgets +import Quickshell.Niri + +FloatingWindow { + ColumnLayout { + anchors.fill: parent + + ListView { + Layout.fillWidth: true + Layout.fillHeight: true + model: Niri.monitors + spacing: 5 + + delegate: WrapperRectangle { + id: monDelegate + required property NiriMonitor modelData + color: monDelegate.modelData.focused ? "lightblue" : "white" + + RowLayout { + Text { + text: `${monDelegate.modelData.name} "${monDelegate.modelData.make} ${monDelegate.modelData.model}" ${monDelegate.modelData.width}x${monDelegate.modelData.height}@${monDelegate.modelData.scale} pos ${monDelegate.modelData.x},${monDelegate.modelData.y} | focused: ${monDelegate.modelData.focused} activeWorkspace: ${monDelegate.modelData.activeWorkspace?.idx ?? "none"}` + } + } + } + } + + // Niri sends no output events, so refresh must be triggered manually. + WrapperRectangle { + color: "lightyellow" + + Text { + text: "Refresh monitors" + + MouseArea { + anchors.fill: parent + onClicked: Niri.refreshMonitors() + } + } + } + + // Verify monitorFor against quickshell screens. + Repeater { + model: Quickshell.screens + + delegate: Text { + id: screenDelegate + required property ShellScreen modelData + text: `screen ${screenDelegate.modelData.name} -> niri monitor ${Niri.monitorFor(screenDelegate.modelData)?.name ?? "none"}` + } + } + } +} diff --git a/src/wayland/niri/test/manual/windows.qml b/src/wayland/niri/test/manual/windows.qml new file mode 100644 index 000000000..dbe5161db --- /dev/null +++ b/src/wayland/niri/test/manual/windows.qml @@ -0,0 +1,30 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Widgets +import Quickshell.Niri + +FloatingWindow { + ListView { + anchors.fill: parent + model: Niri.windows + spacing: 5 + + delegate: WrapperRectangle { + id: winDelegate + required property NiriWindow modelData + color: winDelegate.modelData.focused ? "lightblue" : winDelegate.modelData.urgent ? "orange" : "white" + + RowLayout { + Text { + text: `${winDelegate.modelData.appId} "${winDelegate.modelData.title}" (id ${winDelegate.modelData.id}) ws ${winDelegate.modelData.workspace?.idx ?? "none"} | floating: ${winDelegate.modelData.floating} focused: ${winDelegate.modelData.focused} urgent: ${winDelegate.modelData.urgent} | tile: ${winDelegate.modelData.layout.tile_size ?? "none"} focusTs: ${winDelegate.modelData.focusTimestamp.secs ?? "never"}` + + MouseArea { + anchors.fill: parent + onClicked: winDelegate.modelData.activate() + } + } + } + } + } +} diff --git a/src/wayland/niri/test/manual/workspaces.qml b/src/wayland/niri/test/manual/workspaces.qml new file mode 100644 index 000000000..f6449ae7c --- /dev/null +++ b/src/wayland/niri/test/manual/workspaces.qml @@ -0,0 +1,33 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Widgets +import Quickshell.Niri + +FloatingWindow { + ListView { + anchors.fill: parent + model: Niri.workspaces + spacing: 5 + + delegate: WrapperRectangle { + id: wsDelegate + required property NiriWorkspace modelData + color: wsDelegate.modelData.focused ? "lightblue" : wsDelegate.modelData.active ? "lightgray" : "white" + + RowLayout { + Text { + text: `Workspace ${wsDelegate.modelData.idx} "${wsDelegate.modelData.name}" (id ${wsDelegate.modelData.id}) on ${wsDelegate.modelData.monitor?.name ?? "none"} | active: ${wsDelegate.modelData.active} focused: ${wsDelegate.modelData.focused} urgent: ${wsDelegate.modelData.urgent}` + + MouseArea { + anchors.fill: parent + onClicked: { + console.log("clicked workspace id", wsDelegate.modelData.id) + wsDelegate.modelData.activate() + } + } + } + } + } + } +}