From 1e06d76b21046feaa470505146d72311122fdd1d Mon Sep 17 00:00:00 2001 From: Ardox Date: Fri, 14 Nov 2025 10:44:08 +0100 Subject: [PATCH 1/5] FHT: added new cpp modules --- .../sleex/plugins/src/Sleex/services/fhtc.cpp | 143 ++++++++++++++++++ .../sleex/plugins/src/Sleex/services/fhtc.hpp | 57 +++++++ 2 files changed, 200 insertions(+) create mode 100644 src/share/sleex/plugins/src/Sleex/services/fhtc.cpp create mode 100644 src/share/sleex/plugins/src/Sleex/services/fhtc.hpp diff --git a/src/share/sleex/plugins/src/Sleex/services/fhtc.cpp b/src/share/sleex/plugins/src/Sleex/services/fhtc.cpp new file mode 100644 index 0000000..d5c3ec9 --- /dev/null +++ b/src/share/sleex/plugins/src/Sleex/services/fhtc.cpp @@ -0,0 +1,143 @@ +#include "fhtc.hpp" +#include +#include +#include + +FhtCompositor::FhtCompositor(QObject *parent) + : QObject(parent) +{ + QString socketPath = QProcessEnvironment::systemEnvironment().value("FHTC_SOCKET_PATH"); + if (socketPath.isEmpty()) { + qWarning() << "FhtCompositor: FHTC_SOCKET_PATH not set."; + return; + } + + m_socket = new QTcpSocket(this); + connect(m_socket, &QTcpSocket::readyRead, this, &FhtCompositor::onReadyRead); + + m_socket->connectToHost(socketPath, 0, QIODevice::ReadWrite); +} + +void FhtCompositor::onReadyRead() +{ + while (m_socket->canReadLine()) { + QByteArray line = m_socket->readLine().trimmed(); + QJsonParseError err; + QJsonDocument doc = QJsonDocument::fromJson(line, &err); + if (err.error != QJsonParseError::NoError) { + qWarning() << "FhtCompositor: failed to parse event:" << line << err.errorString(); + continue; + } + + if (doc.isObject()) + handleEvent(doc.object()); + } +} + +void FhtCompositor::handleEvent(const QJsonObject &event) +{ + QString type = event.value("event").toString(); + QJsonValue data = event.value("data"); + + enum EventType { + Windows, + FocusedWindowChanged, + WindowClosed, + WindowChanged, + Workspaces, + ActiveWorkspaceChanged, + WorkspaceChanged, + WorkspaceRemoved, + Space, + Unknown + }; + + static const QHash eventMap = { + {"windows", Windows}, + {"focused-window-changed", FocusedWindowChanged}, + {"window-closed", WindowClosed}, + {"window-changed", WindowChanged}, + {"workspaces", Workspaces}, + {"active-workspace-changed", ActiveWorkspaceChanged}, + {"workspace-changed", WorkspaceChanged}, + {"workspace-removed", WorkspaceRemoved}, + {"space", Space} + }; + + switch (eventMap.value(type, Unknown)) { + case Windows: + m_windows = data.toObject().toVariantMap(); + emit windowsChanged(); + break; + + case FocusedWindowChanged: { + int newId = data.toObject().value("id").toInt(-1); + if (newId == -1) { + m_focusedWindowId = -1; + m_focusedWindow = QVariant(); + } else { + m_focusedWindowId = newId; + m_focusedWindow = m_windows.value(QString::number(newId)); + } + emit focusedWindowIdChanged(); + emit focusedWindowChanged(); + break; + } + + case WindowClosed: { + QString id = QString::number(data.toObject().value("id").toInt()); + m_windows.remove(id); + emit windowsChanged(); + break; + } + + case WindowChanged: { + QJsonObject win = data.toObject(); + m_windows[QString::number(win.value("id").toInt())] = win.toVariantMap(); + emit windowsChanged(); + break; + } + + case Workspaces: + m_workspaces = data.toObject().toVariantMap(); + emit workspacesChanged(); + break; + + case ActiveWorkspaceChanged: { + int newId = data.toObject().value("id").toInt(-1); + if (newId == -1) { + m_activeWorkspaceId = -1; + m_activeWorkspace = QVariant(); + } else { + m_activeWorkspaceId = newId; + m_activeWorkspace = m_workspaces.value(QString::number(newId)); + } + emit activeWorkspaceIdChanged(); + emit activeWorkspaceChanged(); + break; + } + + case WorkspaceChanged: { + QJsonObject ws = data.toObject(); + m_workspaces[QString::number(ws.value("id").toInt())] = ws.toVariantMap(); + emit workspacesChanged(); + break; + } + + case WorkspaceRemoved: { + QString id = QString::number(data.toObject().value("id").toInt()); + m_workspaces.remove(id); + emit workspacesChanged(); + break; + } + + case Space: + m_space = data.toVariant(); + emit spaceChanged(); + break; + + case Unknown: + default: + break; + } +} diff --git a/src/share/sleex/plugins/src/Sleex/services/fhtc.hpp b/src/share/sleex/plugins/src/Sleex/services/fhtc.hpp new file mode 100644 index 0000000..b64b9cf --- /dev/null +++ b/src/share/sleex/plugins/src/Sleex/services/fhtc.hpp @@ -0,0 +1,57 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +class FhtCompositor : public QObject { + Q_OBJECT + QML_SINGLETON + QML_ELEMENT + + Q_PROPERTY(QVariantMap windows READ windows NOTIFY windowsChanged) + Q_PROPERTY(QVariantMap workspaces READ workspaces NOTIFY workspacesChanged) + Q_PROPERTY(QVariant space READ space NOTIFY spaceChanged) + Q_PROPERTY(int focusedWindowId READ focusedWindowId NOTIFY focusedWindowIdChanged) + Q_PROPERTY(QVariant focusedWindow READ focusedWindow NOTIFY focusedWindowChanged) + Q_PROPERTY(int activeWorkspaceId READ activeWorkspaceId NOTIFY activeWorkspaceIdChanged) + Q_PROPERTY(QVariant activeWorkspace READ activeWorkspace NOTIFY activeWorkspaceChanged) + +public: + explicit FhtCompositor(QObject *parent = nullptr); + + QVariantMap windows() const { return m_windows; } + QVariantMap workspaces() const { return m_workspaces; } + QVariant space() const { return m_space; } + int focusedWindowId() const { return m_focusedWindowId; } + QVariant focusedWindow() const { return m_focusedWindow; } + int activeWorkspaceId() const { return m_activeWorkspaceId; } + QVariant activeWorkspace() const { return m_activeWorkspace; } + +signals: + void windowsChanged(); + void workspacesChanged(); + void spaceChanged(); + void focusedWindowIdChanged(); + void focusedWindowChanged(); + void activeWorkspaceIdChanged(); + void activeWorkspaceChanged(); + +private slots: + void onReadyRead(); + void handleEvent(const QJsonObject &event); + +private: + QTcpSocket *m_socket = nullptr; + QVariantMap m_windows; + QVariantMap m_workspaces; + QVariant m_space; + int m_focusedWindowId = -1; + QVariant m_focusedWindow; + int m_activeWorkspaceId = -1; + QVariant m_activeWorkspace; +}; From a40b08f01ea39faab9c9d8d3c34fa2c73eb30219 Mon Sep 17 00:00:00 2001 From: Ardox Date: Mon, 17 Nov 2025 14:15:13 +0100 Subject: [PATCH 2/5] Added new qml modules Added a fhtc IPC and WORKSPACES entry for qml --- .../plugins/src/Sleex/fhtc/CMakeLists.txt | 16 ++ .../sleex/plugins/src/Sleex/fhtc/ipc.cpp | 207 ++++++++++++++++++ .../sleex/plugins/src/Sleex/fhtc/ipc.hpp | 55 +++++ .../sleex/plugins/src/Sleex/fhtc/plugin.cpp | 14 ++ .../plugins/src/Sleex/fhtc/workspaces.cpp | 124 +++++++++++ .../plugins/src/Sleex/fhtc/workspaces.hpp | 51 +++++ 6 files changed, 467 insertions(+) create mode 100644 src/share/sleex/plugins/src/Sleex/fhtc/CMakeLists.txt create mode 100644 src/share/sleex/plugins/src/Sleex/fhtc/ipc.cpp create mode 100644 src/share/sleex/plugins/src/Sleex/fhtc/ipc.hpp create mode 100644 src/share/sleex/plugins/src/Sleex/fhtc/plugin.cpp create mode 100644 src/share/sleex/plugins/src/Sleex/fhtc/workspaces.cpp create mode 100644 src/share/sleex/plugins/src/Sleex/fhtc/workspaces.hpp diff --git a/src/share/sleex/plugins/src/Sleex/fhtc/CMakeLists.txt b/src/share/sleex/plugins/src/Sleex/fhtc/CMakeLists.txt new file mode 100644 index 0000000..abdfe1c --- /dev/null +++ b/src/share/sleex/plugins/src/Sleex/fhtc/CMakeLists.txt @@ -0,0 +1,16 @@ + +qml_module(sleex-fhtc + URI Sleex.Fhtc + SOURCES + ipc.hpp ipc.cpp + workspaces.hpp workspaces.cpp + plugin.cpp +) + +target_link_libraries(sleex-fhtc + PRIVATE + Qt6::Core + Qt6::Qml + Qt6::Network + Qt6::Json +) \ No newline at end of file diff --git a/src/share/sleex/plugins/src/Sleex/fhtc/ipc.cpp b/src/share/sleex/plugins/src/Sleex/fhtc/ipc.cpp new file mode 100644 index 0000000..392e6c2 --- /dev/null +++ b/src/share/sleex/plugins/src/Sleex/fhtc/ipc.cpp @@ -0,0 +1,207 @@ +#include "ipc.hpp" +#include +#include +#include +#include +#include + +Ipc::Ipc(QObject *parent) +: QObject(parent), +m_requestSocket(new QLocalSocket(this)), +m_eventSocket(new QLocalSocket(this)) +{ + m_socketPath = qEnvironmentVariable("FHTC_SOCKET_PATH"); + if (m_socketPath.isEmpty()) { + emit error("The environment variable FHTC_SOCKET_PATH is not set."); + qWarning() << "Ipc: FHTC_SOCKET_PATH is not set."; + } + + // Connections for the request socket + connect(m_requestSocket, &QLocalSocket::connected, this, &Ipc::onRequestConnected); + connect(m_requestSocket, &QLocalSocket::readyRead, this, &Ipc::onRequestReadyRead); + connect(m_requestSocket, &QLocalSocket::errorOccurred, this, &Ipc::onRequestError); + connect(m_requestSocket, &QLocalSocket::disconnected, this, &Ipc::onRequestDisconnected); + + // Connections for the event socket + connect(m_eventSocket, &QLocalSocket::connected, this, &Ipc::onEventConnected); + connect(m_eventSocket, &QLocalSocket::readyRead, this, &Ipc::onEventReadyRead); + connect(m_eventSocket, &QLocalSocket::errorOccurred, this, &Ipc::onEventError); + connect(m_eventSocket, &QLocalSocket::disconnected, this, &Ipc::onEventDisconnected); +} + +Ipc::~Ipc() +{ + // Sockets are automatically destroyed because 'this' is their parent +} + +// --- Public slots --- + +void Ipc::subscribe() +{ + if (m_socketPath.isEmpty()) return; + if (m_eventSocket->state() == QLocalSocket::UnconnectedState) { + qDebug() << "Ipc: Connecting to the event socket..."; + m_eventSocket->connectToServer(m_socketPath); + } +} + +void Ipc::sendRequest(const QVariant &request) +{ + if (m_socketPath.isEmpty()) return; + + m_pendingRequests.enqueue(request); + if (m_requestSocket->state() == QLocalSocket::UnconnectedState) { + qDebug() << "Ipc: Connecting to the request socket..."; + m_requestSocket->connectToServer(m_socketPath); + } else if (m_requestSocket->state() == QLocalSocket::ConnectedState) { + // If already connected, send the pending request + writeJson(m_requestSocket, m_pendingRequests.dequeue()); + } + // If 'Connecting', the request will be sent in onRequestConnected +} + +void Ipc::sendAction(const QVariant &action) +{ + QVariantMap request; + request.insert("action", action); + sendRequest(request); +} + +// --- Private slots: Request socket --- + +void Ipc::onRequestConnected() +{ + qDebug() << "Ipc: Request socket connected."; + // Send the first pending request (if any) + if (!m_pendingRequests.isEmpty()) { + writeJson(m_requestSocket, m_pendingRequests.dequeue()); + } +} + +void Ipc::onRequestReadyRead() +{ + m_requestBuffer.append(m_requestSocket->readAll()); + + // The fhtc IPC guarantees one response per request on this socket. + // We do not loop, we just wait for a complete line. + QVariant response = parseLine(m_requestBuffer); + if (!response.isNull()) { + emit requestResponse(response); + + // If there are other requests, send them + if (!m_pendingRequests.isEmpty()) { + writeJson(m_requestSocket, m_pendingRequests.dequeue()); + } + } +} + +void Ipc::onRequestError(QLocalSocket::LocalSocketError socketError) +{ + Q_UNUSED(socketError); + QString msg = "Ipc (Request): " + m_requestSocket->errorString(); + qWarning() << msg; + emit error(msg); + m_pendingRequests.clear(); // Clear pending requests +} + +void Ipc::onRequestDisconnected() +{ + qDebug() << "Ipc: Request socket disconnected."; + if (!m_requestBuffer.isEmpty()) { + qWarning() << "Ipc (Request): DDisconnected with data in buffer:" << m_requestBuffer; + m_requestBuffer.clear(); + } +} + +// --- Private slots: Event socket --- + +void Ipc::onEventConnected() +{ + qDebug() << "Ipc: Event socket connected. Sending 'subscribe' request."; + emit subscribed(); + + // Send the subscribe request + QVariantMap subRequest; + subRequest.insert("subscribe", QVariant()); // "subscribe": null + writeJson(m_eventSocket, subRequest); +} + +void Ipc::onEventReadyRead() +{ + m_eventBuffer.append(m_eventSocket->readAll()); + + // There may be multiple JSON events in the buffer + while (true) { + QVariant event = parseLine(m_eventBuffer); + if (event.isNull()) { + break; // Incomplete line, wait for more data + } + emit newEvent(event); + } +} + +void Ipc::onEventError(QLocalSocket::LocalSocketError socketError) +{ + Q_UNUSED(socketError); + QString msg = "Ipc (Event): " + m_eventSocket->errorString(); + qWarning() << msg; + emit error(msg); +} + +void Ipc::onEventDisconnected() +{ + QString msg = "Ipc: Event socket disconnected. Attempting to reconnect in 5s..."; + qWarning() << msg; + emit error(msg); + + // Attempt automatic reconnection for the event socket + QTimer::singleShot(5000, this, &Ipc::subscribe); +} + +// --- Utility functions --- +void Ipc::writeJson(QLocalSocket *socket, const QVariant &data) +{ + if (socket->state() != QLocalSocket::ConnectedState) { + qWarning() << "Ipc: Attempt to write to a socket that is not connected."; + return; + } + + QJsonDocument doc = QJsonDocument::fromVariant(data); + if (doc.isNull()) { + qWarning() << "Ipc: Failed to convert QVariant to JSON."; + return; + } + + QByteArray json = doc.toJson(QJsonDocument::Compact); + json.append('\n'); // fhtc IPC is delimited by newlines + + socket->write(json); +} + +QVariant Ipc::parseLine(QByteArray &buffer) +{ + int newlineIdx = buffer.indexOf('\n'); + if (newlineIdx == -1) { + return QVariant(); // Incomplete line + } + + // Extract the line and remove it from the buffer + QByteArray line = buffer.left(newlineIdx); + buffer = buffer.mid(newlineIdx + 1); + + if (line.isEmpty()) { + return QVariant(); // Empty line, ignore + } + + QJsonParseError parseError; + QJsonDocument doc = QJsonDocument::fromJson(line, &parseError); + + if (parseError.error != QJsonParseError::NoError) { + QString msg = "Ipc: JSON parse error: " + parseError.errorString() + " | Line: " + QString(line); + qWarning() << msg; + emit error(msg); + return QVariant(); + } + + return doc.toVariant(); +} diff --git a/src/share/sleex/plugins/src/Sleex/fhtc/ipc.hpp b/src/share/sleex/plugins/src/Sleex/fhtc/ipc.hpp new file mode 100644 index 0000000..c1cdad4 --- /dev/null +++ b/src/share/sleex/plugins/src/Sleex/fhtc/ipc.hpp @@ -0,0 +1,55 @@ +#ifndef IPC_H +#define IPC_H + +#include +#include +#include +#include +#include + +class Ipc : public QObject +{ + Q_OBJECT + QML_NAMED_ELEMENT(Ipc) + QML_SINGLETON + +public: + explicit Ipc(QObject *parent = nullptr); + ~Ipc() override; + +signals: + void error(const QString &message); + void requestResponse(const QVariant &response); + void subscribed(); + void newEvent(const QVariant &event); + +public slots: + void subscribe(); + void sendRequest(const QVariant &request); + void sendAction(const QVariant &action); + +private slots: + void onRequestConnected(); + void onRequestReadyRead(); + void onRequestError(QLocalSocket::LocalSocketError socketError); + void onRequestDisconnected(); + + void onEventConnected(); + void onEventReadyRead(); + void onEventError(QLocalSocket::LocalSocketError socketError); + void onEventDisconnected(); + +private: + void writeJson(QLocalSocket* socket, const QVariant &data); + QVariant parseLine(QByteArray &buffer); + + QString m_socketPath; + QLocalSocket *m_requestSocket; + QLocalSocket *m_eventSocket; + + QByteArray m_eventBuffer; + QByteArray m_requestBuffer; + QQueue m_pendingRequests; +}; + +#endif // IPC_H \ No newline at end of file diff --git a/src/share/sleex/plugins/src/Sleex/fhtc/plugin.cpp b/src/share/sleex/plugins/src/Sleex/fhtc/plugin.cpp new file mode 100644 index 0000000..aff6826 --- /dev/null +++ b/src/share/sleex/plugins/src/Sleex/fhtc/plugin.cpp @@ -0,0 +1,14 @@ +#include + +class SleexFhtcPlugin : public QQmlExtensionPlugin { + Q_OBJECT + Q_PLUGIN_METADATA(IID QQmlExtensionInterface_iid) + +public: + void registerTypes(const char *uri) override { + // QML_ELEMENT and QML_SINGLETON macros handle registration automatically + Q_UNUSED(uri) + } +}; + +#include "plugin.moc" \ No newline at end of file diff --git a/src/share/sleex/plugins/src/Sleex/fhtc/workspaces.cpp b/src/share/sleex/plugins/src/Sleex/fhtc/workspaces.cpp new file mode 100644 index 0000000..977ddf4 --- /dev/null +++ b/src/share/sleex/plugins/src/Sleex/fhtc/workspaces.cpp @@ -0,0 +1,124 @@ +#include "workspaces.hpp" +#include +#include + +Workspaces::Workspaces(QObject *parent) + : QObject(parent), m_ipc(nullptr), m_activeWorkspaceId(-1) +{ +} + +void Workspaces::classBegin() +{ + // this "hook" is called by the QML engine after instantiation. + // It's the best moment to get the Ipc singleton instance. + QQmlEngine *engine = qmlEngine(this); + if (!engine) { + qWarning() << "Workspaces: Unable to find the QML engine."; + return; + } + + // Qt 6.2+ + m_ipc = engine->singletonInstance("Sleex.Fhtc", "Ipc"); + + if (!m_ipc) { + qWarning() << "Workspaces: Cannot find the 'Ipc' singleton."; + qWarning() << "Make sure 'Ipc' is imported and used in QML (e.g., Ipc.subscribe())"; + return; + } + + connect(m_ipc, &Ipc::newEvent, this, &Workspaces::handleNewEvent); + connect(m_ipc, &Ipc::requestResponse, this, &Workspaces::handleRequestResponse); + connect(m_ipc, &Ipc::subscribed, this, &Workspaces::requestInitialState); + + // If Ipc is already 'subscribed', request the state + requestInitialState(); +} + + +QVariantMap Workspaces::workspaces() const { return m_workspaces; } +QVariantMap Workspaces::space() const { return m_space; } +int Workspaces::activeWorkspaceId() const { return m_activeWorkspaceId; } + +QVariant Workspaces::activeWorkspace() const +{ + return m_workspaces.value(QString::number(m_activeWorkspaceId)); +} + + +void Workspaces::requestInitialState() +{ + if (!m_ipc) return; + + m_ipc->sendRequest(QVariantMap{{"workspaces", QVariant()}}); + m_ipc->sendRequest(QVariantMap{{"space", QVariant()}}); + m_ipc->sendRequest(QVariantMap{{"focused-workspace", QVariant()}}); +} + +void Workspaces::handleNewEvent(const QVariant &event) +{ + QVariantMap eventMap = event.toMap(); + QString type = eventMap.value("event").toString(); + if (type.isEmpty()) return; + + QVariant data = eventMap.value("data"); + bool changed = false; + + if (type == "workspaces") { + m_workspaces = data.toMap(); + emit workspacesChanged(); + changed = true; + } else if (type == "workspace-changed") { + QVariantMap wsMap = data.toMap(); + QString id = wsMap.value("id").toString(); + m_workspaces.insert(id, wsMap); + emit workspacesChanged(); + changed = true; + } else if (type == "workspace-removed") { + QString id = data.toMap().value("id").toString(); + if (m_workspaces.remove(id)) { + emit workspacesChanged(); + changed = true; + } + } else if (type == "active-workspace-changed") { + int id = data.toMap().value("id").toInt(); + if (m_activeWorkspaceId != id) { + m_activeWorkspaceId = id; + emit activeWorkspaceChanged(); + } + } else if (type == "space") { + m_space = data.toMap(); + emit spaceChanged(); + } + + if (changed) { + emit activeWorkspaceChanged(); + } +} + +void Workspaces::handleRequestResponse(const QVariant &response) +{ + QVariantMap resMap = response.toMap(); + + if (resMap.contains("workspaces")) { + m_workspaces = resMap.value("workspaces").toMap(); + emit workspacesChanged(); + emit activeWorkspaceChanged(); + } + + if (resMap.contains("space")) { + m_space = resMap.value("space").toMap(); + emit spaceChanged(); + } + + if (resMap.contains("workspace")) { + QVariant ws = resMap.value("workspace"); + int newId = -1; + if (ws.isValid() && !ws.isNull()) { + newId = ws.toMap().value("id").toInt(); + } + if (m_activeWorkspaceId != newId) { + m_activeWorkspaceId = newId; + emit activeWorkspaceChanged(); + } + } +} \ No newline at end of file diff --git a/src/share/sleex/plugins/src/Sleex/fhtc/workspaces.hpp b/src/share/sleex/plugins/src/Sleex/fhtc/workspaces.hpp new file mode 100644 index 0000000..b3c280b --- /dev/null +++ b/src/share/sleex/plugins/src/Sleex/fhtc/workspaces.hpp @@ -0,0 +1,51 @@ +#ifndef WORKSPACES_H +#define WORKSPACES_H + +#include +#include +#include +#include +#include "ipc.hpp" + +class Workspaces : public QObject, public QQmlParserStatus +{ + Q_OBJECT + Q_INTERFACES(QQmlParserStatus) + + QML_NAMED_ELEMENT(Workspaces) + QML_SINGLETON + + Q_PROPERTY(QVariantMap workspaces READ workspaces NOTIFY workspacesChanged) + Q_PROPERTY(QVariantMap space READ space NOTIFY spaceChanged) + Q_PROPERTY(int activeWorkspaceId READ activeWorkspaceId NOTIFY activeWorkspaceChanged) + Q_PROPERTY(QVariant activeWorkspace READ activeWorkspace NOTIFY activeWorkspaceChanged) + +public: + explicit Workspaces(QObject *parent = nullptr); + + void classBegin() override; + void componentComplete() override; + + QVariantMap workspaces() const; + QVariantMap space() const; + int activeWorkspaceId() const; + QVariant activeWorkspace() const; + +signals: + void workspacesChanged(); + void spaceChanged(); + void activeWorkspaceChanged(); + +private slots: + void handleNewEvent(const QVariant &event); + void handleRequestResponse(const QVariant &response); + void requestInitialState(); + +private: + Ipc *m_ipc; + QVariantMap m_workspaces; + QVariantMap m_space; + int m_activeWorkspaceId; +}; + +#endif // WORKSPACES_H \ No newline at end of file From c35a9e0e799e2b5ef02b3afb049fd5abe9581cce Mon Sep 17 00:00:00 2001 From: Ardox Date: Tue, 9 Dec 2025 14:39:31 +0100 Subject: [PATCH 3/5] FHTC: bar workspace widget handle --- src/share/sleex/modules/bar/Workspaces.qml | 97 ++++++++------ src/share/sleex/services/Fhtc.qml | 148 +++++++++++++++++++++ 2 files changed, 208 insertions(+), 37 deletions(-) create mode 100644 src/share/sleex/services/Fhtc.qml diff --git a/src/share/sleex/modules/bar/Workspaces.qml b/src/share/sleex/modules/bar/Workspaces.qml index 5583bf9..50392dd 100644 --- a/src/share/sleex/modules/bar/Workspaces.qml +++ b/src/share/sleex/modules/bar/Workspaces.qml @@ -8,7 +8,6 @@ import QtQuick.Controls import QtQuick.Layouts import Quickshell import Quickshell.Wayland -import Quickshell.Hyprland import Quickshell.Io import Quickshell.Widgets import Qt5Compat.GraphicalEffects @@ -16,10 +15,27 @@ import Qt5Compat.GraphicalEffects Item { required property var bar property bool borderless: Config.options.bar.borderless - readonly property HyprlandMonitor monitor: Hyprland.monitorFor(bar.screen) - readonly property Toplevel activeWindow: ToplevelManager.activeToplevel + readonly property var activeWindow: Fhtc.focusedWindow + readonly property string screenName: bar.screen?.name ?? "" + + // Get workspaces for this screen only, sorted by ID + readonly property var screenWorkspaces: { + return Object.values(Fhtc.workspaces) + .filter(ws => ws.output === screenName) + .sort((a, b) => a.id - b.id); + } + + // Active workspace index within this screen (0-based) + readonly property int activeWorkspaceIndex: { + if (!Fhtc.activeWorkspace) return -1; + if (Fhtc.activeWorkspace.output !== screenName) return -1; + // Find the index of the active workspace in our sorted screen workspaces + const idx = screenWorkspaces.findIndex(ws => ws.id === Fhtc.activeWorkspace.id); + // Return -1 if the workspace is beyond the shown limit + if (idx >= Config.options.bar.workspaces.shown) return -1; + return idx; + } - readonly property int workspaceGroup: Math.floor((monitor.activeWorkspace?.id - 1) / Config.options.bar.workspaces.shown) property list workspaceOccupied: [] property int widgetPadding: 0 property int horizontalPadding: 5 @@ -28,23 +44,31 @@ Item { property real workspaceIconSizeShrinked: workspaceButtonWidth * 0.55 property real workspaceIconOpacityShrinked: 1 property real workspaceIconMarginShrinked: -4 - property int workspaceIndexInGroup: (monitor.activeWorkspace?.id - 1) % Config.options.bar.workspaces.shown - // Function to update workspaceOccupied function updateWorkspaceOccupied() { workspaceOccupied = Array.from({ length: Config.options.bar.workspaces.shown }, (_, i) => { - return Hyprland.workspaces.values.some(ws => ws.id === workspaceGroup * Config.options.bar.workspaces.shown + i + 1); + // Get the workspace at this index for this screen + const ws = screenWorkspaces[i]; + if (!ws) return false; + // Check if the workspace has any windows + return ws.windows && ws.windows.length > 0; }) } // Initialize workspaceOccupied when the component is created Component.onCompleted: updateWorkspaceOccupied() - // Listen for changes in Hyprland.workspaces.values + // Listen for changes in Fhtc.workspaces and windows Connections { - target: Hyprland.workspaces - function onValuesChanged() { + target: Fhtc + function onWorkspacesChanged() { + updateWorkspaceOccupied(); + } + function onWindowsChanged() { + updateWorkspaceOccupied(); + } + function onActiveWorkspaceChanged() { updateWorkspaceOccupied(); } } @@ -57,23 +81,13 @@ Item { WheelHandler { onWheel: (event) => { if (event.angleDelta.y < 0) - Hyprland.dispatch(`workspace +1`); + Fhtc.dispatch("focus-workspace", ["+1"]); else if (event.angleDelta.y > 0) - Hyprland.dispatch(`workspace -1`); + Fhtc.dispatch("focus-workspace", ["-1"]); } acceptedDevices: PointerDevice.Mouse | PointerDevice.TouchPad } - MouseArea { - anchors.fill: parent - acceptedButtons: Qt.BackButton - onPressed: (event) => { - if (event.button === Qt.BackButton) { - Hyprland.dispatch(`togglespecialworkspace`); - } - } - } - Item { anchors.fill: parent anchors.leftMargin: horizontalPadding @@ -96,8 +110,8 @@ Item { implicitWidth: workspaceButtonWidth implicitHeight: workspaceButtonWidth radius: Appearance.rounding.full - property var leftOccupied: (workspaceOccupied[index-1] && !(!activeWindow?.activated && monitor.activeWorkspace?.id === index)) - property var rightOccupied: (workspaceOccupied[index+1] && !(!activeWindow?.activated && monitor.activeWorkspace?.id === index+2)) + property var leftOccupied: (workspaceOccupied[index-1]) + property var rightOccupied: (workspaceOccupied[index+1]) property var radiusLeft: leftOccupied ? 0 : Appearance.rounding.full property var radiusRight: rightOccupied ? 0 : Appearance.rounding.full @@ -107,7 +121,7 @@ Item { bottomRightRadius: radiusRight color: ColorUtils.transparentize(Appearance.m3colors.m3secondaryContainer, 0.4) - opacity: (workspaceOccupied[index] && !(!activeWindow?.activated && monitor.activeWorkspace?.id === index+1)) ? 1 : 0 + opacity: (workspaceOccupied[index]) ? 1 : 0 Behavior on opacity { animation: Appearance.animation.elementMove.numberAnimation.createObject(this) @@ -129,6 +143,7 @@ Item { // Active workspace Rectangle { z: 2 + visible: activeWorkspaceIndex >= 0 // Make active ws indicator, which has a brighter color, smaller to look like it is of the same size as ws occupied highlight property real activeWorkspaceMargin: 2 implicitHeight: workspaceButtonWidth - activeWorkspaceMargin * 2 @@ -136,8 +151,8 @@ Item { color: Appearance.colors.colPrimary anchors.verticalCenter: parent.verticalCenter - property real idx1: workspaceIndexInGroup - property real idx2: workspaceIndexInGroup + property real idx1: activeWorkspaceIndex >= 0 ? activeWorkspaceIndex : 0 + property real idx2: activeWorkspaceIndex >= 0 ? activeWorkspaceIndex : 0 x: Math.min(idx1, idx2) * workspaceButtonWidth + activeWorkspaceMargin implicitWidth: Math.abs(idx1 - idx2) * workspaceButtonWidth + workspaceButtonWidth - activeWorkspaceMargin * 2 @@ -172,24 +187,32 @@ Item { Button { id: button - property int workspaceValue: workspaceGroup * Config.options.bar.workspaces.shown + index + 1 + property var workspace: screenWorkspaces[index] ?? null + property int workspaceId: workspace?.id ?? -1 Layout.fillHeight: true - onPressed: Hyprland.dispatch(`workspace ${workspaceValue}`) + onPressed: { + // Use index + 1 for focus-workspace command (1-based) + Fhtc.dispatch("focus-workspace", [(index + 1).toString()]); + } width: workspaceButtonWidth background: Item { id: workspaceButtonBackground implicitWidth: workspaceButtonWidth implicitHeight: workspaceButtonWidth + + // Get the biggest window from the workspace's window list property var biggestWindow: { - const windowsInThisWorkspace = HyprlandData.windowList.filter(w => w.workspace.id == button.workspaceValue) + if (!button.workspace || !button.workspace.windows || button.workspace.windows.length === 0) return null; + const windowIds = button.workspace.windows; + const windowsInThisWorkspace = windowIds.map(id => Fhtc.windows[id]).filter(w => w != null); return windowsInThisWorkspace.reduce((maxWin, win) => { const maxArea = (maxWin?.size?.[0] ?? 0) * (maxWin?.size?.[1] ?? 0) const winArea = (win?.size?.[0] ?? 0) * (win?.size?.[1] ?? 0) return winArea > maxArea ? win : maxWin }, null) } - property var mainAppIconSource: Quickshell.iconPath(AppSearch.guessIcon(biggestWindow?.class), "image-missing") + property var mainAppIconSource: Quickshell.iconPath(AppSearch.guessIcon(biggestWindow?.["app-id"]), "image-missing") StyledText { // Workspace number text opacity: GlobalStates.workspaceShowNumbers @@ -202,9 +225,9 @@ Item { horizontalAlignment: Text.AlignHCenter verticalAlignment: Text.AlignVCenter font.pixelSize: Appearance.font.pixelSize.small - ((text.length - 1) * (text !== "10") * 2) - text: `${button.workspaceValue}` + text: `${index + 1}` elide: Text.ElideRight - color: (monitor.activeWorkspace?.id == button.workspaceValue) ? + color: (activeWorkspaceIndex == index) ? Appearance.m3colors.m3onPrimary : (workspaceOccupied[index] ? Appearance.m3colors.m3onSecondaryContainer : Appearance.colors.colOnLayer1Inactive) @@ -224,9 +247,9 @@ Item { width: workspaceButtonWidth * 0.18 height: width radius: width / 2 - color: (monitor.activeWorkspace?.id == button.workspaceValue) ? - Appearance.m3colors.m3onPrimary : - (workspaceOccupied[index] ? Appearance.m3colors.m3onSecondaryContainer : + color: (activeWorkspaceIndex == index) ? + Appearance.m3colors.m3onPrimary : + (workspaceOccupied[index] ? Appearance.m3colors.m3onSecondaryContainer : Appearance.colors.colOnLayer1Inactive) Behavior on opacity { @@ -240,7 +263,7 @@ Item { opacity: !Config.options?.bar.workspaces.showAppIcons ? 0 : (workspaceButtonBackground.biggestWindow && !GlobalStates.workspaceShowNumbers && Config.options?.bar.workspaces.showAppIcons) ? 1 : workspaceButtonBackground.biggestWindow ? workspaceIconOpacityShrinked : 0 - visible: opacity > 0 + visible: opacity > 0 IconImage { id: mainAppIcon anchors.bottom: parent.bottom diff --git a/src/share/sleex/services/Fhtc.qml b/src/share/sleex/services/Fhtc.qml new file mode 100644 index 0000000..81bfe06 --- /dev/null +++ b/src/share/sleex/services/Fhtc.qml @@ -0,0 +1,148 @@ +pragma Singleton +pragma ComponentBehavior: Bound + +import Quickshell +import Quickshell.Io + +Singleton { + id: root + + // The socket we connect to. This is the bridge between us and fht-compositor + readonly property string socketPath: Quickshell.env("FHTC_SOCKET_PATH") + + // How things work here is that we store workspace and window data into separate maps, + // and actual data structures (like Monitors, or even Workspaces) store IDs. The stored + // maps are ID->Window/Workspace maps + // + // FIXME: Add typing to these. + property var workspaces: ({}) + property var windows: ({}) + property var space: ({}) + + // Focused window is the one that is currently receiving keyboard input. There can be multiple + // active windows at once, but only one at max focused window can exist at a time. + property int focusedWindowId: -1 + property var focusedWindow: null + // The active workspace is the workspace that currently has the pointer on it. This usually means + // the displayed workspace on the focused monitor. + property int activeWorkspaceId: -1 + property var activeWorkspace: null + + Socket { + id: subscribeSocket + path: root.socketPath + connected: true + + onConnectionStateChanged: { + if (connected) + // When we connect, start turn this socket immediatly into a subcribe + // socket. Allowing us to actually use it. + subscribeSocket.write('"subscribe"\n'); + } + + parser: SplitParser { + onRead: line => { + try { + root.handleEvent(JSON.parse(line)); + } catch (err) { + console.warn("FhtCompositor: failed to parse event: ", line, err); + } + } + } + } + + // The main event handler. The passed in `event` parameter is a fht-compositor-ipc::Event + // https://github.com/nferhat/fht-compositor/blob/ff3d9f3b6549b38e99755d022f5343fda3d6a971/fht-compositor-ipc/src/lib.rs#L131 + function handleEvent(event) { + switch (event.event) { + case "windows": + // Update the window list. The passed in data is a HashMap + root.windows = event.data; + root.windowsChanged(); + break; + case "focused-window-changed": + var newId = event.data.id; + if (newId == null) { + root.focusedWindowId = -1; + root.focusedWindow = null; + } else { + // FIXME: Maybe this event could be sent before a WindowChanged event, in this + // case this could lead to invalid state. + root.focusedWindowId = newId; + root.focusedWindow = root.windows[newId]; + } + + root.focusedWindowChanged(); + root.focusedWindowIdChanged(); + + break; + case "window-closed": + // NOTE: the compositor will sent us a focused-window-changed event, so we don't + // have to update the focusedWindow here. + var id = event.data.id; + delete root.windows[id]; + + root.windowsChanged(); + + break; + case "window-changed": + // This event could either be an existing window changing, or a new window opening + const win = event.data; + root.windows[win.id] = win; + + root.windowsChanged(); + + break; + case "workspaces": + // Update the workspace list. The passed in data is a HashMap + root.workspaces = event.data; + root.workspacesChanged(); + break; + case "active-workspace-changed": + var newId = event.data.id; + if (newId == null) { + root.activeWorkspaceId = -1; + root.activeWorkspace = null; + } else { + // FIXME: Maybe this event could be sent before a WorkspaceChanged event, in this + // case this could lead to invalid state. + root.activeWorkspaceId = newId; + root.activeWorkspace = root.workspaces[newId]; + } + + root.activeWorkspaceChanged(); + root.activeWorkspaceIdChanged(); + + break; + case "workspace-changed": + const ws = event.data; + root.workspaces[ws.id] = ws; + root.workspacesChanged(); + + break; + case "workspace-removed": + var id = event.data.id; + delete root.workspaces[id]; + + root.workspacesChanged(); + + break; + case "space": + root.space = event.data; + root.spaceChanged(); + break; + default: + // console.warn("Unhandled fht-compositor event: ", event.event); + break; + } + } + + function dispatch(command, args) { + var cmd = { + command: command, + args: args + }; + + subscribeSocket.write(JSON.stringify(cmd) + "\n"); + } +} From a60ffd66dc78f09ef341e85838592d408136814e Mon Sep 17 00:00:00 2001 From: Ardox Date: Tue, 9 Dec 2025 15:00:03 +0100 Subject: [PATCH 4/5] FHTC: bar workspace action handle --- src/share/sleex/modules/bar/Workspaces.qml | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/share/sleex/modules/bar/Workspaces.qml b/src/share/sleex/modules/bar/Workspaces.qml index 50392dd..f7d89c0 100644 --- a/src/share/sleex/modules/bar/Workspaces.qml +++ b/src/share/sleex/modules/bar/Workspaces.qml @@ -81,9 +81,9 @@ Item { WheelHandler { onWheel: (event) => { if (event.angleDelta.y < 0) - Fhtc.dispatch("focus-workspace", ["+1"]); + Quickshell.execDetached(["fht-compositor", "ipc", "action", "focus-next-workspace"]); else if (event.angleDelta.y > 0) - Fhtc.dispatch("focus-workspace", ["-1"]); + Quickshell.execDetached(["fht-compositor", "ipc", "action", "focus-previous-workspace"]); } acceptedDevices: PointerDevice.Mouse | PointerDevice.TouchPad } @@ -191,8 +191,9 @@ Item { property int workspaceId: workspace?.id ?? -1 Layout.fillHeight: true onPressed: { - // Use index + 1 for focus-workspace command (1-based) - Fhtc.dispatch("focus-workspace", [(index + 1).toString()]); + if (button.workspaceId >= 0) { + Quickshell.execDetached(["fht-compositor", "ipc", "action", "focus-workspace", `${button.workspaceId}`]); + } } width: workspaceButtonWidth From 42bff99683a33556d71490b6ffb4b37fa76fc849 Mon Sep 17 00:00:00 2001 From: Ardox Date: Tue, 9 Dec 2025 15:00:23 +0100 Subject: [PATCH 5/5] FHTC: dock visibility handle --- src/share/sleex/modules/dock/Dock.qml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/share/sleex/modules/dock/Dock.qml b/src/share/sleex/modules/dock/Dock.qml index d96b82e..78a4f23 100644 --- a/src/share/sleex/modules/dock/Dock.qml +++ b/src/share/sleex/modules/dock/Dock.qml @@ -10,7 +10,6 @@ import Quickshell.Io import Quickshell import Quickshell.Widgets import Quickshell.Wayland -import Quickshell.Hyprland Scope { // Scope id: root @@ -27,7 +26,7 @@ Scope { // Scope property bool reveal: root.pinned || (Config.options?.dock.hoverToReveal && dockMouseArea.containsMouse) || dockApps.requestDockShow - || (!ToplevelManager.activeToplevel?.activated) + || (ToplevelManager.toplevels?.length === 0) anchors { bottom: true