Add Nebula.Windows with Sway IPC backend
Introduces a new `core/windows` module (`Nebula.Windows`) with `WindowService`, window/workspace models, and a Sway IPC backend for live window tracking and control (focus, close, move/resize, snap, maximize/restore, minimize, workspace switch). The desktop shell now links this module, uses compositor focus for top-bar active app text, and adds an Open Windows section in the launcher with focus/close actions. Also updates application metadata handling to parse `StartupWMClass` and map window `app_id`/class to friendly names and icons, and revises Sway config/docs for the new floating-first Desktop 0.2 behavior with temporary compositor-provided decorations.
This commit is contained in:
@@ -0,0 +1,63 @@
|
||||
cmake_minimum_required(VERSION 3.20)
|
||||
|
||||
if(NOT TARGET Qt6::Qml)
|
||||
project(NebulaWindows LANGUAGES CXX)
|
||||
find_package(Qt6 6.4 REQUIRED COMPONENTS Core Gui Qml Network)
|
||||
qt_standard_project_setup(REQUIRES 6.4)
|
||||
endif()
|
||||
|
||||
if(NOT TARGET Qt6::Network)
|
||||
find_package(Qt6 6.4 REQUIRED COMPONENTS Network)
|
||||
endif()
|
||||
|
||||
if(NOT TARGET NebulaApplications)
|
||||
add_subdirectory(
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/../applications"
|
||||
"${CMAKE_CURRENT_BINARY_DIR}/nebula-applications"
|
||||
)
|
||||
endif()
|
||||
|
||||
qt_add_library(NebulaWindows STATIC)
|
||||
|
||||
qt_add_qml_module(NebulaWindows
|
||||
URI Nebula.Windows
|
||||
VERSION 1.0
|
||||
RESOURCE_PREFIX /qt/qml
|
||||
SOURCES
|
||||
WindowTypes.hpp
|
||||
WindowBackend.hpp
|
||||
WindowModel.hpp
|
||||
WindowModel.cpp
|
||||
WorkspaceModel.hpp
|
||||
WorkspaceModel.cpp
|
||||
WindowService.hpp
|
||||
WindowService.cpp
|
||||
backends/sway/SwayIpcClient.hpp
|
||||
backends/sway/SwayIpcClient.cpp
|
||||
backends/sway/SwayWindowBackend.hpp
|
||||
backends/sway/SwayWindowBackend.cpp
|
||||
)
|
||||
|
||||
target_include_directories(NebulaWindows
|
||||
PUBLIC
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}"
|
||||
PRIVATE
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/backends/sway"
|
||||
)
|
||||
|
||||
target_link_libraries(NebulaWindows
|
||||
PUBLIC
|
||||
Qt6::Core
|
||||
Qt6::Gui
|
||||
Qt6::Qml
|
||||
Qt6::Network
|
||||
NebulaApplications
|
||||
)
|
||||
|
||||
set_target_properties(NebulaWindows PROPERTIES
|
||||
AUTOMOC ON
|
||||
)
|
||||
|
||||
if(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang")
|
||||
target_compile_options(NebulaWindows PRIVATE -Wall -Wextra)
|
||||
endif()
|
||||
@@ -0,0 +1,41 @@
|
||||
# Nebula Windows
|
||||
|
||||
Shared C++ window-management service. Desktop, the future Bigscreen shell, and a future Nebula compositor backend should all talk to this library rather than to a compositor directly.
|
||||
|
||||
```text
|
||||
Nebula QML
|
||||
↓
|
||||
WindowService
|
||||
↓
|
||||
generic WindowBackend
|
||||
↓
|
||||
Sway IPC now
|
||||
Nebula compositor later
|
||||
```
|
||||
|
||||
QML must not run `swaymsg`. Sway-specific protocol, socket handling, and tree parsing stay in `backends/sway/`.
|
||||
|
||||
## Behaviour
|
||||
|
||||
- Connects to the compositor IPC socket from `SWAYSOCK`. The path is never hard-coded.
|
||||
- If IPC is missing or fails, the shell keeps running and WindowService stays empty. A development warning is logged.
|
||||
- Subscribes to `window`, `workspace`, and `output` events instead of polling.
|
||||
- Exposes `WindowModel` (`QAbstractListModel`) and `WorkspaceModel`.
|
||||
- Tracks the focused *normal application* window. Layer-shell chrome (desktop, top bar, launcher) is not treated as the active app.
|
||||
- Resolves a user-facing name via `ApplicationService` when possible, then a small identifier humanizer (`footclient` → Foot, `org.mozilla.firefox` → Firefox).
|
||||
|
||||
QML module URI: `Nebula.Windows`.
|
||||
|
||||
## Operations
|
||||
|
||||
`focusWindow`, `closeWindow`, `maximizeWindow`, `restoreWindow`, `setWindowFloating`, plus `moveWindow` / `resizeWindow` / `snapWindow` / `switchWorkspace`.
|
||||
|
||||
`closeWindow` asks the compositor to close the client. It does not kill the process.
|
||||
|
||||
Maximise fills the current workspace usable area (below the Nebula exclusive zone) using compositor-relative sizing. Restore returns the previous floating geometry when WindowService saved it.
|
||||
|
||||
Minimise is implemented for the Sway backend using scratchpad semantics, but the UI must not mention scratchpad. The user-facing idea is only hide / show.
|
||||
|
||||
Snap edges (`SnapLeft`, `SnapRight`, `SnapMaximize`) are prepared for a later Snap Layouts UI.
|
||||
|
||||
See `shells/desktop/README.md` and `compositor/README.md` for session behaviour.
|
||||
@@ -0,0 +1,47 @@
|
||||
#pragma once
|
||||
|
||||
#include "WindowTypes.hpp"
|
||||
|
||||
#include <QObject>
|
||||
#include <QVector>
|
||||
|
||||
class WindowBackend : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
enum SnapEdge {
|
||||
SnapLeft = 0,
|
||||
SnapRight,
|
||||
SnapMaximize
|
||||
};
|
||||
|
||||
explicit WindowBackend(QObject *parent = nullptr)
|
||||
: QObject(parent)
|
||||
{
|
||||
}
|
||||
|
||||
~WindowBackend() override = default;
|
||||
|
||||
virtual bool isConnected() const = 0;
|
||||
virtual QVector<WindowInfo> windows() const = 0;
|
||||
virtual QVector<WorkspaceInfo> workspaces() const = 0;
|
||||
virtual qint64 focusedWindowId() const = 0;
|
||||
|
||||
virtual bool focusWindow(qint64 windowId) = 0;
|
||||
virtual bool closeWindow(qint64 windowId) = 0;
|
||||
virtual bool maximizeWindow(qint64 windowId) = 0;
|
||||
virtual bool restoreWindow(qint64 windowId) = 0;
|
||||
virtual bool minimizeWindow(qint64 windowId) = 0;
|
||||
virtual bool setWindowFloating(qint64 windowId, bool floating) = 0;
|
||||
virtual bool moveWindow(qint64 windowId, int x, int y) = 0;
|
||||
virtual bool resizeWindow(qint64 windowId, int width, int height) = 0;
|
||||
virtual bool snapWindow(qint64 windowId, SnapEdge edge) = 0;
|
||||
virtual bool switchWorkspace(const QString &workspaceId) = 0;
|
||||
|
||||
signals:
|
||||
void connectedChanged();
|
||||
void windowsChanged();
|
||||
void workspacesChanged();
|
||||
void focusChanged();
|
||||
};
|
||||
@@ -0,0 +1,114 @@
|
||||
#include "WindowModel.hpp"
|
||||
|
||||
#include <utility>
|
||||
|
||||
WindowModel::WindowModel(QObject *parent)
|
||||
: QAbstractListModel(parent)
|
||||
{
|
||||
}
|
||||
|
||||
int WindowModel::rowCount(const QModelIndex &parent) const
|
||||
{
|
||||
if (parent.isValid())
|
||||
return 0;
|
||||
return m_windows.size();
|
||||
}
|
||||
|
||||
int WindowModel::count() const
|
||||
{
|
||||
return m_windows.size();
|
||||
}
|
||||
|
||||
QVariant WindowModel::data(const QModelIndex &index, int role) const
|
||||
{
|
||||
if (!index.isValid() || index.row() < 0 || index.row() >= m_windows.size())
|
||||
return {};
|
||||
|
||||
const WindowInfo &window = m_windows.at(index.row());
|
||||
switch (role) {
|
||||
case WindowIdRole:
|
||||
return QString::number(window.id);
|
||||
case TitleRole:
|
||||
return window.title;
|
||||
case AppIdRole:
|
||||
return window.appId;
|
||||
case WindowClassRole:
|
||||
return window.windowClass;
|
||||
case WorkspaceRole:
|
||||
return window.workspace;
|
||||
case FocusedRole:
|
||||
return window.focused;
|
||||
case FloatingRole:
|
||||
return window.floating;
|
||||
case FullscreenRole:
|
||||
return window.fullscreen;
|
||||
case XRole:
|
||||
return window.x;
|
||||
case YRole:
|
||||
return window.y;
|
||||
case WidthRole:
|
||||
return window.width;
|
||||
case HeightRole:
|
||||
return window.height;
|
||||
case DisplayNameRole:
|
||||
case Qt::DisplayRole:
|
||||
return window.displayName.isEmpty() ? window.title : window.displayName;
|
||||
case IconNameRole:
|
||||
return window.iconName;
|
||||
case MinimizedRole:
|
||||
return window.minimized;
|
||||
case MaximizedRole:
|
||||
return window.maximized;
|
||||
case OutputRole:
|
||||
return window.output;
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
QHash<int, QByteArray> WindowModel::roleNames() const
|
||||
{
|
||||
return {
|
||||
{WindowIdRole, QByteArrayLiteral("windowId")},
|
||||
{TitleRole, QByteArrayLiteral("title")},
|
||||
{AppIdRole, QByteArrayLiteral("appId")},
|
||||
{WindowClassRole, QByteArrayLiteral("windowClass")},
|
||||
{WorkspaceRole, QByteArrayLiteral("workspace")},
|
||||
{FocusedRole, QByteArrayLiteral("focused")},
|
||||
{FloatingRole, QByteArrayLiteral("floating")},
|
||||
{FullscreenRole, QByteArrayLiteral("fullscreen")},
|
||||
{XRole, QByteArrayLiteral("x")},
|
||||
{YRole, QByteArrayLiteral("y")},
|
||||
{WidthRole, QByteArrayLiteral("width")},
|
||||
{HeightRole, QByteArrayLiteral("height")},
|
||||
{DisplayNameRole, QByteArrayLiteral("displayName")},
|
||||
{IconNameRole, QByteArrayLiteral("iconName")},
|
||||
{MinimizedRole, QByteArrayLiteral("minimized")},
|
||||
{MaximizedRole, QByteArrayLiteral("maximized")},
|
||||
{OutputRole, QByteArrayLiteral("output")},
|
||||
};
|
||||
}
|
||||
|
||||
void WindowModel::setWindows(QVector<WindowInfo> windows)
|
||||
{
|
||||
beginResetModel();
|
||||
m_windows = std::move(windows);
|
||||
endResetModel();
|
||||
emit countChanged();
|
||||
}
|
||||
|
||||
const WindowInfo *WindowModel::windowAt(int row) const
|
||||
{
|
||||
if (row < 0 || row >= m_windows.size())
|
||||
return nullptr;
|
||||
return &m_windows.at(row);
|
||||
}
|
||||
|
||||
const WindowInfo *WindowModel::windowById(qint64 windowId) const
|
||||
{
|
||||
for (const WindowInfo &window : m_windows) {
|
||||
if (window.id == windowId)
|
||||
return &window;
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
#pragma once
|
||||
|
||||
#include "WindowTypes.hpp"
|
||||
|
||||
#include <QAbstractListModel>
|
||||
#include <QVector>
|
||||
#include <QtQml/qqmlregistration.h>
|
||||
|
||||
class WindowModel : public QAbstractListModel
|
||||
{
|
||||
Q_OBJECT
|
||||
QML_ELEMENT
|
||||
QML_UNCREATABLE("Obtained from WindowService")
|
||||
Q_PROPERTY(int count READ count NOTIFY countChanged)
|
||||
|
||||
public:
|
||||
enum Roles {
|
||||
WindowIdRole = Qt::UserRole + 1,
|
||||
TitleRole,
|
||||
AppIdRole,
|
||||
WindowClassRole,
|
||||
WorkspaceRole,
|
||||
FocusedRole,
|
||||
FloatingRole,
|
||||
FullscreenRole,
|
||||
XRole,
|
||||
YRole,
|
||||
WidthRole,
|
||||
HeightRole,
|
||||
DisplayNameRole,
|
||||
IconNameRole,
|
||||
MinimizedRole,
|
||||
MaximizedRole,
|
||||
OutputRole
|
||||
};
|
||||
Q_ENUM(Roles)
|
||||
|
||||
explicit WindowModel(QObject *parent = nullptr);
|
||||
|
||||
int rowCount(const QModelIndex &parent = QModelIndex()) const override;
|
||||
QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override;
|
||||
QHash<int, QByteArray> roleNames() const override;
|
||||
|
||||
int count() const;
|
||||
void setWindows(QVector<WindowInfo> windows);
|
||||
const WindowInfo *windowAt(int row) const;
|
||||
const WindowInfo *windowById(qint64 windowId) const;
|
||||
|
||||
signals:
|
||||
void countChanged();
|
||||
|
||||
private:
|
||||
QVector<WindowInfo> m_windows;
|
||||
};
|
||||
@@ -0,0 +1,257 @@
|
||||
#include "WindowService.hpp"
|
||||
|
||||
#include "ApplicationService.hpp"
|
||||
#include "SwayWindowBackend.hpp"
|
||||
|
||||
#include <QHash>
|
||||
#include <QStringList>
|
||||
#include <QTimer>
|
||||
|
||||
namespace {
|
||||
|
||||
bool looksTechnicalTitle(const QString &title)
|
||||
{
|
||||
if (title.isEmpty())
|
||||
return true;
|
||||
if (title.contains(QLatin1Char('@')) || title.contains(QLatin1Char('/')) || title.contains(QLatin1Char('\\')))
|
||||
return true;
|
||||
if (title.contains(QLatin1String("://")))
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
WindowService::WindowService(QObject *parent)
|
||||
: QObject(parent)
|
||||
, m_backend(new SwayWindowBackend(this))
|
||||
, m_model(new WindowModel(this))
|
||||
, m_workspaces(new WorkspaceModel(this))
|
||||
{
|
||||
connect(m_backend, &WindowBackend::connectedChanged, this, &WindowService::connectedChanged);
|
||||
connect(m_backend, &WindowBackend::connectedChanged, this, &WindowService::syncFromBackend);
|
||||
connect(m_backend, &WindowBackend::windowsChanged, this, &WindowService::syncFromBackend);
|
||||
|
||||
QTimer::singleShot(0, this, [this]() {
|
||||
bindApplicationService();
|
||||
if (m_applications)
|
||||
syncFromBackend();
|
||||
});
|
||||
syncFromBackend();
|
||||
}
|
||||
|
||||
WindowService *WindowService::create(QQmlEngine *engine, QJSEngine *)
|
||||
{
|
||||
auto *service = new WindowService(engine);
|
||||
QQmlEngine::setObjectOwnership(service, QQmlEngine::CppOwnership);
|
||||
return service;
|
||||
}
|
||||
|
||||
bool WindowService::connected() const
|
||||
{
|
||||
return m_backend && m_backend->isConnected();
|
||||
}
|
||||
|
||||
WindowModel *WindowService::model() const
|
||||
{
|
||||
return m_model;
|
||||
}
|
||||
|
||||
WorkspaceModel *WindowService::workspaceModel() const
|
||||
{
|
||||
return m_workspaces;
|
||||
}
|
||||
|
||||
int WindowService::windowCount() const
|
||||
{
|
||||
return m_model->count();
|
||||
}
|
||||
|
||||
QString WindowService::focusedWindowId() const
|
||||
{
|
||||
return m_focusedWindowId;
|
||||
}
|
||||
|
||||
QString WindowService::focusedWindowTitle() const
|
||||
{
|
||||
return m_focusedWindowTitle;
|
||||
}
|
||||
|
||||
QString WindowService::focusedApplicationId() const
|
||||
{
|
||||
return m_focusedApplicationId;
|
||||
}
|
||||
|
||||
QString WindowService::focusedApplicationName() const
|
||||
{
|
||||
return m_focusedApplicationName;
|
||||
}
|
||||
|
||||
bool WindowService::focusWindow(const QString &windowId)
|
||||
{
|
||||
return m_backend->focusWindow(parseWindowId(windowId));
|
||||
}
|
||||
|
||||
bool WindowService::closeWindow(const QString &windowId)
|
||||
{
|
||||
return m_backend->closeWindow(parseWindowId(windowId));
|
||||
}
|
||||
|
||||
bool WindowService::maximizeWindow(const QString &windowId)
|
||||
{
|
||||
return m_backend->maximizeWindow(parseWindowId(windowId));
|
||||
}
|
||||
|
||||
bool WindowService::restoreWindow(const QString &windowId)
|
||||
{
|
||||
return m_backend->restoreWindow(parseWindowId(windowId));
|
||||
}
|
||||
|
||||
bool WindowService::minimizeWindow(const QString &windowId)
|
||||
{
|
||||
return m_backend->minimizeWindow(parseWindowId(windowId));
|
||||
}
|
||||
|
||||
bool WindowService::setWindowFloating(const QString &windowId, bool floating)
|
||||
{
|
||||
return m_backend->setWindowFloating(parseWindowId(windowId), floating);
|
||||
}
|
||||
|
||||
bool WindowService::moveWindow(const QString &windowId, int x, int y)
|
||||
{
|
||||
return m_backend->moveWindow(parseWindowId(windowId), x, y);
|
||||
}
|
||||
|
||||
bool WindowService::resizeWindow(const QString &windowId, int width, int height)
|
||||
{
|
||||
return m_backend->resizeWindow(parseWindowId(windowId), width, height);
|
||||
}
|
||||
|
||||
bool WindowService::snapWindow(const QString &windowId, SnapEdge edge)
|
||||
{
|
||||
return m_backend->snapWindow(parseWindowId(windowId), static_cast<WindowBackend::SnapEdge>(edge));
|
||||
}
|
||||
|
||||
bool WindowService::switchWorkspace(const QString &workspaceId)
|
||||
{
|
||||
return m_backend->switchWorkspace(workspaceId);
|
||||
}
|
||||
|
||||
void WindowService::syncFromBackend()
|
||||
{
|
||||
bindApplicationService();
|
||||
|
||||
QVector<WindowInfo> windows = m_backend->windows();
|
||||
resolveNames(windows);
|
||||
const int previousCount = m_model->count();
|
||||
m_model->setWindows(windows);
|
||||
m_workspaces->setWorkspaces(m_backend->workspaces());
|
||||
if (m_model->count() != previousCount)
|
||||
emit windowCountChanged();
|
||||
|
||||
const qint64 focusedId = m_backend->focusedWindowId();
|
||||
QString windowId;
|
||||
QString title;
|
||||
QString appId;
|
||||
QString appName;
|
||||
|
||||
if (const WindowInfo *focused = m_model->windowById(focusedId)) {
|
||||
if (!focused->minimized) {
|
||||
windowId = QString::number(focused->id);
|
||||
title = focused->title;
|
||||
appId = focused->appId.isEmpty() ? focused->windowClass : focused->appId;
|
||||
appName = focused->displayName;
|
||||
}
|
||||
}
|
||||
|
||||
if (m_focusedWindowId != windowId
|
||||
|| m_focusedWindowTitle != title
|
||||
|| m_focusedApplicationId != appId
|
||||
|| m_focusedApplicationName != appName) {
|
||||
m_focusedWindowId = windowId;
|
||||
m_focusedWindowTitle = title;
|
||||
m_focusedApplicationId = appId;
|
||||
m_focusedApplicationName = appName;
|
||||
emit focusChanged();
|
||||
}
|
||||
}
|
||||
|
||||
void WindowService::resolveNames(QVector<WindowInfo> &windows) const
|
||||
{
|
||||
for (WindowInfo &window : windows) {
|
||||
QString name;
|
||||
if (m_applications)
|
||||
name = m_applications->displayNameForWindow(window.appId, window.windowClass);
|
||||
if (name.isEmpty())
|
||||
name = humanizeIdentifier(window.appId, window.windowClass);
|
||||
if (name.isEmpty() && !looksTechnicalTitle(window.title))
|
||||
name = window.title;
|
||||
window.displayName = name;
|
||||
|
||||
if (m_applications)
|
||||
window.iconName = m_applications->iconNameForWindow(window.appId, window.windowClass);
|
||||
}
|
||||
}
|
||||
|
||||
void WindowService::bindApplicationService()
|
||||
{
|
||||
if (m_applications)
|
||||
return;
|
||||
|
||||
m_applications = ApplicationService::instance();
|
||||
if (!m_applications)
|
||||
return;
|
||||
|
||||
connect(m_applications, &ApplicationService::countChanged, this, &WindowService::syncFromBackend);
|
||||
}
|
||||
|
||||
qint64 WindowService::parseWindowId(const QString &windowId)
|
||||
{
|
||||
bool ok = false;
|
||||
const qint64 id = windowId.trimmed().toLongLong(&ok);
|
||||
return ok ? id : 0;
|
||||
}
|
||||
|
||||
QString WindowService::humanizeIdentifier(const QString &appId, const QString &windowClass)
|
||||
{
|
||||
static const QHash<QString, QString> aliases = {
|
||||
{QStringLiteral("foot"), QStringLiteral("Foot")},
|
||||
{QStringLiteral("footclient"), QStringLiteral("Foot")},
|
||||
{QStringLiteral("footserver"), QStringLiteral("Foot")},
|
||||
{QStringLiteral("org.mozilla.firefox"), QStringLiteral("Firefox")},
|
||||
{QStringLiteral("firefox"), QStringLiteral("Firefox")},
|
||||
{QStringLiteral("firefox-esr"), QStringLiteral("Firefox")},
|
||||
};
|
||||
|
||||
const QString raw = appId.isEmpty() ? windowClass : appId;
|
||||
const QString lower = raw.toLower();
|
||||
if (aliases.contains(lower))
|
||||
return aliases.value(lower);
|
||||
|
||||
return titleCaseIdentifier(raw);
|
||||
}
|
||||
|
||||
QString WindowService::titleCaseIdentifier(QString id)
|
||||
{
|
||||
if (id.endsWith(QLatin1String(".desktop"), Qt::CaseInsensitive))
|
||||
id.chop(8);
|
||||
if (id.contains(QLatin1Char('.')))
|
||||
id = id.section(QLatin1Char('.'), -1);
|
||||
if (id.isEmpty())
|
||||
return {};
|
||||
|
||||
id.replace(QLatin1Char('-'), QLatin1Char(' '));
|
||||
id.replace(QLatin1Char('_'), QLatin1Char(' '));
|
||||
|
||||
const QStringList parts = id.split(QLatin1Char(' '), Qt::SkipEmptyParts);
|
||||
QStringList titled;
|
||||
titled.reserve(parts.size());
|
||||
for (QString part : parts) {
|
||||
if (part.size() == 1)
|
||||
part = part.toUpper();
|
||||
else if (!part.isEmpty())
|
||||
part = part.left(1).toUpper() + part.mid(1);
|
||||
titled.append(part);
|
||||
}
|
||||
return titled.join(QLatin1Char(' '));
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
#pragma once
|
||||
|
||||
#include "WindowBackend.hpp"
|
||||
#include "WindowModel.hpp"
|
||||
#include "WorkspaceModel.hpp"
|
||||
|
||||
#include <QJSEngine>
|
||||
#include <QObject>
|
||||
#include <QQmlEngine>
|
||||
#include <QVector>
|
||||
#include <QtQml/qqmlregistration.h>
|
||||
|
||||
class ApplicationService;
|
||||
|
||||
class WindowService : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
QML_ELEMENT
|
||||
QML_SINGLETON
|
||||
Q_PROPERTY(bool connected READ connected NOTIFY connectedChanged)
|
||||
Q_PROPERTY(WindowModel *model READ model CONSTANT)
|
||||
Q_PROPERTY(WorkspaceModel *workspaceModel READ workspaceModel CONSTANT)
|
||||
Q_PROPERTY(int windowCount READ windowCount NOTIFY windowCountChanged)
|
||||
Q_PROPERTY(QString focusedWindowId READ focusedWindowId NOTIFY focusChanged)
|
||||
Q_PROPERTY(QString focusedWindowTitle READ focusedWindowTitle NOTIFY focusChanged)
|
||||
Q_PROPERTY(QString focusedApplicationId READ focusedApplicationId NOTIFY focusChanged)
|
||||
Q_PROPERTY(QString focusedApplicationName READ focusedApplicationName NOTIFY focusChanged)
|
||||
|
||||
public:
|
||||
enum SnapEdge {
|
||||
SnapLeft = WindowBackend::SnapLeft,
|
||||
SnapRight = WindowBackend::SnapRight,
|
||||
SnapMaximize = WindowBackend::SnapMaximize
|
||||
};
|
||||
Q_ENUM(SnapEdge)
|
||||
|
||||
explicit WindowService(QObject *parent = nullptr);
|
||||
static WindowService *create(QQmlEngine *engine, QJSEngine *scriptEngine);
|
||||
|
||||
bool connected() const;
|
||||
WindowModel *model() const;
|
||||
WorkspaceModel *workspaceModel() const;
|
||||
int windowCount() const;
|
||||
|
||||
QString focusedWindowId() const;
|
||||
QString focusedWindowTitle() const;
|
||||
QString focusedApplicationId() const;
|
||||
QString focusedApplicationName() const;
|
||||
|
||||
Q_INVOKABLE bool focusWindow(const QString &windowId);
|
||||
Q_INVOKABLE bool closeWindow(const QString &windowId);
|
||||
Q_INVOKABLE bool maximizeWindow(const QString &windowId);
|
||||
Q_INVOKABLE bool restoreWindow(const QString &windowId);
|
||||
Q_INVOKABLE bool minimizeWindow(const QString &windowId);
|
||||
Q_INVOKABLE bool setWindowFloating(const QString &windowId, bool floating);
|
||||
Q_INVOKABLE bool moveWindow(const QString &windowId, int x, int y);
|
||||
Q_INVOKABLE bool resizeWindow(const QString &windowId, int width, int height);
|
||||
Q_INVOKABLE bool snapWindow(const QString &windowId, SnapEdge edge);
|
||||
Q_INVOKABLE bool switchWorkspace(const QString &workspaceId);
|
||||
|
||||
signals:
|
||||
void connectedChanged();
|
||||
void windowCountChanged();
|
||||
void focusChanged();
|
||||
|
||||
private:
|
||||
void syncFromBackend();
|
||||
void resolveNames(QVector<WindowInfo> &windows) const;
|
||||
void bindApplicationService();
|
||||
static qint64 parseWindowId(const QString &windowId);
|
||||
static QString humanizeIdentifier(const QString &appId, const QString &windowClass);
|
||||
static QString titleCaseIdentifier(QString id);
|
||||
|
||||
WindowBackend *m_backend = nullptr;
|
||||
WindowModel *m_model = nullptr;
|
||||
WorkspaceModel *m_workspaces = nullptr;
|
||||
ApplicationService *m_applications = nullptr;
|
||||
QString m_focusedWindowId;
|
||||
QString m_focusedWindowTitle;
|
||||
QString m_focusedApplicationId;
|
||||
QString m_focusedApplicationName;
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
#pragma once
|
||||
|
||||
#include <QString>
|
||||
#include <QtGlobal>
|
||||
|
||||
struct WindowInfo
|
||||
{
|
||||
qint64 id = 0;
|
||||
QString title;
|
||||
QString appId;
|
||||
QString windowClass;
|
||||
QString workspace;
|
||||
QString output;
|
||||
QString displayName;
|
||||
QString iconName;
|
||||
bool focused = false;
|
||||
bool floating = false;
|
||||
bool fullscreen = false;
|
||||
bool minimized = false;
|
||||
bool maximized = false;
|
||||
int x = 0;
|
||||
int y = 0;
|
||||
int width = 0;
|
||||
int height = 0;
|
||||
};
|
||||
|
||||
struct WorkspaceInfo
|
||||
{
|
||||
QString id;
|
||||
QString name;
|
||||
int number = -1;
|
||||
bool focused = false;
|
||||
bool visible = false;
|
||||
QString output;
|
||||
int windowCount = 0;
|
||||
int x = 0;
|
||||
int y = 0;
|
||||
int width = 0;
|
||||
int height = 0;
|
||||
};
|
||||
@@ -0,0 +1,84 @@
|
||||
#include "WorkspaceModel.hpp"
|
||||
|
||||
#include <utility>
|
||||
|
||||
WorkspaceModel::WorkspaceModel(QObject *parent)
|
||||
: QAbstractListModel(parent)
|
||||
{
|
||||
}
|
||||
|
||||
int WorkspaceModel::rowCount(const QModelIndex &parent) const
|
||||
{
|
||||
if (parent.isValid())
|
||||
return 0;
|
||||
return m_workspaces.size();
|
||||
}
|
||||
|
||||
int WorkspaceModel::count() const
|
||||
{
|
||||
return m_workspaces.size();
|
||||
}
|
||||
|
||||
QVariant WorkspaceModel::data(const QModelIndex &index, int role) const
|
||||
{
|
||||
if (!index.isValid() || index.row() < 0 || index.row() >= m_workspaces.size())
|
||||
return {};
|
||||
|
||||
const WorkspaceInfo &workspace = m_workspaces.at(index.row());
|
||||
switch (role) {
|
||||
case WorkspaceIdRole:
|
||||
return workspace.id;
|
||||
case NameRole:
|
||||
case Qt::DisplayRole:
|
||||
return workspace.name;
|
||||
case NumberRole:
|
||||
return workspace.number;
|
||||
case FocusedRole:
|
||||
return workspace.focused;
|
||||
case VisibleRole:
|
||||
return workspace.visible;
|
||||
case OutputRole:
|
||||
return workspace.output;
|
||||
case WindowCountRole:
|
||||
return workspace.windowCount;
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
QHash<int, QByteArray> WorkspaceModel::roleNames() const
|
||||
{
|
||||
return {
|
||||
{WorkspaceIdRole, QByteArrayLiteral("workspaceId")},
|
||||
{NameRole, QByteArrayLiteral("name")},
|
||||
{NumberRole, QByteArrayLiteral("number")},
|
||||
{FocusedRole, QByteArrayLiteral("focused")},
|
||||
{VisibleRole, QByteArrayLiteral("visible")},
|
||||
{OutputRole, QByteArrayLiteral("output")},
|
||||
{WindowCountRole, QByteArrayLiteral("windowCount")},
|
||||
};
|
||||
}
|
||||
|
||||
void WorkspaceModel::setWorkspaces(QVector<WorkspaceInfo> workspaces)
|
||||
{
|
||||
beginResetModel();
|
||||
m_workspaces = std::move(workspaces);
|
||||
endResetModel();
|
||||
emit countChanged();
|
||||
}
|
||||
|
||||
const WorkspaceInfo *WorkspaceModel::workspaceAt(int row) const
|
||||
{
|
||||
if (row < 0 || row >= m_workspaces.size())
|
||||
return nullptr;
|
||||
return &m_workspaces.at(row);
|
||||
}
|
||||
|
||||
const WorkspaceInfo *WorkspaceModel::focusedWorkspace() const
|
||||
{
|
||||
for (const WorkspaceInfo &workspace : m_workspaces) {
|
||||
if (workspace.focused)
|
||||
return &workspace;
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
#pragma once
|
||||
|
||||
#include "WindowTypes.hpp"
|
||||
|
||||
#include <QAbstractListModel>
|
||||
#include <QVector>
|
||||
#include <QtQml/qqmlregistration.h>
|
||||
|
||||
class WorkspaceModel : public QAbstractListModel
|
||||
{
|
||||
Q_OBJECT
|
||||
QML_ELEMENT
|
||||
QML_UNCREATABLE("Obtained from WindowService")
|
||||
Q_PROPERTY(int count READ count NOTIFY countChanged)
|
||||
|
||||
public:
|
||||
enum Roles {
|
||||
WorkspaceIdRole = Qt::UserRole + 1,
|
||||
NameRole,
|
||||
NumberRole,
|
||||
FocusedRole,
|
||||
VisibleRole,
|
||||
OutputRole,
|
||||
WindowCountRole
|
||||
};
|
||||
Q_ENUM(Roles)
|
||||
|
||||
explicit WorkspaceModel(QObject *parent = nullptr);
|
||||
|
||||
int rowCount(const QModelIndex &parent = QModelIndex()) const override;
|
||||
QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override;
|
||||
QHash<int, QByteArray> roleNames() const override;
|
||||
|
||||
int count() const;
|
||||
void setWorkspaces(QVector<WorkspaceInfo> workspaces);
|
||||
const WorkspaceInfo *workspaceAt(int row) const;
|
||||
const WorkspaceInfo *focusedWorkspace() const;
|
||||
|
||||
signals:
|
||||
void countChanged();
|
||||
|
||||
private:
|
||||
QVector<WorkspaceInfo> m_workspaces;
|
||||
};
|
||||
@@ -0,0 +1,245 @@
|
||||
#include "SwayIpcClient.hpp"
|
||||
|
||||
#include <QJsonArray>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QtGlobal>
|
||||
|
||||
#include <cstring>
|
||||
#include <utility>
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr char kMagic[] = "i3-ipc";
|
||||
constexpr int kMagicSize = 6;
|
||||
constexpr int kHeaderSize = 14;
|
||||
constexpr int kDefaultTimeoutMs = 2000;
|
||||
|
||||
} // namespace
|
||||
|
||||
SwayIpcClient::SwayIpcClient(QObject *parent)
|
||||
: QObject(parent)
|
||||
, m_command(new QLocalSocket(this))
|
||||
, m_event(new QLocalSocket(this))
|
||||
{
|
||||
connect(m_command, &QLocalSocket::disconnected, this, &SwayIpcClient::handleCommandDisconnected);
|
||||
connect(m_event, &QLocalSocket::disconnected, this, &SwayIpcClient::handleEventDisconnected);
|
||||
connect(m_event, &QLocalSocket::readyRead, this, &SwayIpcClient::processEventSocket);
|
||||
}
|
||||
|
||||
void SwayIpcClient::processEventSocket()
|
||||
{
|
||||
appendIncoming(m_event, &m_eventBuffer);
|
||||
Message message;
|
||||
while (takeMessage(&m_eventBuffer, &message)) {
|
||||
if (m_inRequest > 0)
|
||||
m_pendingEvents.append(std::move(message));
|
||||
else
|
||||
emit eventReceived(message.type, QJsonDocument::fromJson(message.payload));
|
||||
}
|
||||
}
|
||||
|
||||
SwayIpcClient::~SwayIpcClient()
|
||||
{
|
||||
disconnectFromSway();
|
||||
}
|
||||
|
||||
QString SwayIpcClient::socketPath()
|
||||
{
|
||||
return QString::fromLocal8Bit(qgetenv("SWAYSOCK"));
|
||||
}
|
||||
|
||||
bool SwayIpcClient::isConnected() const
|
||||
{
|
||||
return m_connected
|
||||
&& m_command->state() == QLocalSocket::ConnectedState
|
||||
&& m_event->state() == QLocalSocket::ConnectedState;
|
||||
}
|
||||
|
||||
void SwayIpcClient::disconnectFromSway()
|
||||
{
|
||||
m_event->blockSignals(true);
|
||||
m_command->blockSignals(true);
|
||||
if (m_event->state() != QLocalSocket::UnconnectedState)
|
||||
m_event->disconnectFromServer();
|
||||
if (m_command->state() != QLocalSocket::UnconnectedState)
|
||||
m_command->disconnectFromServer();
|
||||
m_event->blockSignals(false);
|
||||
m_command->blockSignals(false);
|
||||
m_eventBuffer.clear();
|
||||
m_pendingEvents.clear();
|
||||
|
||||
if (m_connected) {
|
||||
m_connected = false;
|
||||
emit connectedChanged();
|
||||
emit disconnected();
|
||||
}
|
||||
}
|
||||
|
||||
bool SwayIpcClient::connectToSway()
|
||||
{
|
||||
const QString path = socketPath();
|
||||
if (path.isEmpty())
|
||||
return false;
|
||||
|
||||
disconnectFromSway();
|
||||
|
||||
m_command->connectToServer(path);
|
||||
if (!m_command->waitForConnected(kDefaultTimeoutMs)) {
|
||||
qWarning("nebula: Sway IPC command socket failed: %s", qPrintable(m_command->errorString()));
|
||||
return false;
|
||||
}
|
||||
|
||||
m_event->connectToServer(path);
|
||||
if (!m_event->waitForConnected(kDefaultTimeoutMs)) {
|
||||
qWarning("nebula: Sway IPC event socket failed: %s", qPrintable(m_event->errorString()));
|
||||
m_command->disconnectFromServer();
|
||||
return false;
|
||||
}
|
||||
|
||||
m_connected = true;
|
||||
emit connectedChanged();
|
||||
return true;
|
||||
}
|
||||
|
||||
QJsonDocument SwayIpcClient::request(quint32 type, const QByteArray &payload)
|
||||
{
|
||||
if (m_command->state() != QLocalSocket::ConnectedState)
|
||||
return {};
|
||||
|
||||
++m_inRequest;
|
||||
const bool wrote = writeMessage(m_command, type, payload);
|
||||
Message reply;
|
||||
const bool read = wrote && readMessage(m_command, &reply, kDefaultTimeoutMs);
|
||||
--m_inRequest;
|
||||
|
||||
flushPendingEvents();
|
||||
|
||||
if (!read) {
|
||||
qWarning("nebula: Sway IPC request failed (type %u)", type);
|
||||
return {};
|
||||
}
|
||||
|
||||
return QJsonDocument::fromJson(reply.payload);
|
||||
}
|
||||
|
||||
bool SwayIpcClient::subscribe(const QStringList &events)
|
||||
{
|
||||
if (m_event->state() != QLocalSocket::ConnectedState)
|
||||
return false;
|
||||
|
||||
QJsonArray array;
|
||||
for (const QString &event : events)
|
||||
array.append(event);
|
||||
|
||||
// Subscribe on the event connection so unsolicited events never interleave
|
||||
// with command replies on the request socket.
|
||||
m_event->blockSignals(true);
|
||||
const bool wrote = writeMessage(m_event, Subscribe, QJsonDocument(array).toJson(QJsonDocument::Compact));
|
||||
Message reply;
|
||||
const bool read = wrote && readMessage(m_event, &reply, kDefaultTimeoutMs);
|
||||
m_event->blockSignals(false);
|
||||
|
||||
if (m_event->bytesAvailable() > 0)
|
||||
processEventSocket();
|
||||
|
||||
if (!read) {
|
||||
qWarning("nebula: Sway IPC subscribe failed");
|
||||
return false;
|
||||
}
|
||||
|
||||
const QJsonDocument doc = QJsonDocument::fromJson(reply.payload);
|
||||
if (!doc.isObject() || !doc.object().value(QStringLiteral("success")).toBool()) {
|
||||
qWarning("nebula: Sway IPC subscribe was rejected");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool SwayIpcClient::writeMessage(QLocalSocket *socket, quint32 type, const QByteArray &payload)
|
||||
{
|
||||
QByteArray header;
|
||||
header.resize(kHeaderSize);
|
||||
memcpy(header.data(), kMagic, kMagicSize);
|
||||
const quint32 size = static_cast<quint32>(payload.size());
|
||||
memcpy(header.data() + kMagicSize, &size, sizeof(size));
|
||||
memcpy(header.data() + kMagicSize + sizeof(size), &type, sizeof(type));
|
||||
|
||||
if (socket->write(header) != header.size())
|
||||
return false;
|
||||
if (!payload.isEmpty() && socket->write(payload) != payload.size())
|
||||
return false;
|
||||
return socket->waitForBytesWritten(kDefaultTimeoutMs);
|
||||
}
|
||||
|
||||
bool SwayIpcClient::readMessage(QLocalSocket *socket, Message *message, int timeoutMs)
|
||||
{
|
||||
QByteArray buffer;
|
||||
while (!takeMessage(&buffer, message)) {
|
||||
if (socket->bytesAvailable() <= 0 && !socket->waitForReadyRead(timeoutMs))
|
||||
return false;
|
||||
appendIncoming(socket, &buffer);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void SwayIpcClient::appendIncoming(QLocalSocket *socket, QByteArray *buffer)
|
||||
{
|
||||
buffer->append(socket->readAll());
|
||||
}
|
||||
|
||||
bool SwayIpcClient::takeMessage(QByteArray *buffer, Message *message)
|
||||
{
|
||||
if (buffer->size() < kHeaderSize)
|
||||
return false;
|
||||
if (!buffer->startsWith(kMagic)) {
|
||||
qWarning("nebula: Sway IPC header magic mismatch");
|
||||
buffer->clear();
|
||||
return false;
|
||||
}
|
||||
|
||||
quint32 size = 0;
|
||||
quint32 type = 0;
|
||||
memcpy(&size, buffer->constData() + kMagicSize, sizeof(size));
|
||||
memcpy(&type, buffer->constData() + kMagicSize + sizeof(size), sizeof(type));
|
||||
|
||||
const int total = kHeaderSize + static_cast<int>(size);
|
||||
if (buffer->size() < total)
|
||||
return false;
|
||||
|
||||
message->type = type;
|
||||
message->payload = buffer->mid(kHeaderSize, static_cast<int>(size));
|
||||
buffer->remove(0, total);
|
||||
return true;
|
||||
}
|
||||
|
||||
void SwayIpcClient::flushPendingEvents()
|
||||
{
|
||||
if (m_inRequest > 0 || m_pendingEvents.isEmpty())
|
||||
return;
|
||||
|
||||
const QVector<Message> pending = std::move(m_pendingEvents);
|
||||
m_pendingEvents.clear();
|
||||
for (const Message &message : pending)
|
||||
emit eventReceived(message.type, QJsonDocument::fromJson(message.payload));
|
||||
}
|
||||
|
||||
void SwayIpcClient::handleCommandDisconnected()
|
||||
{
|
||||
if (!m_connected)
|
||||
return;
|
||||
qWarning("nebula: Sway IPC command socket disconnected");
|
||||
m_connected = false;
|
||||
emit connectedChanged();
|
||||
emit disconnected();
|
||||
}
|
||||
|
||||
void SwayIpcClient::handleEventDisconnected()
|
||||
{
|
||||
if (!m_connected)
|
||||
return;
|
||||
qWarning("nebula: Sway IPC event socket disconnected");
|
||||
m_connected = false;
|
||||
emit connectedChanged();
|
||||
emit disconnected();
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
#pragma once
|
||||
|
||||
#include <QByteArray>
|
||||
#include <QJsonDocument>
|
||||
#include <QLocalSocket>
|
||||
#include <QObject>
|
||||
#include <QStringList>
|
||||
#include <QVector>
|
||||
|
||||
class SwayIpcClient : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
enum MessageType : quint32 {
|
||||
RunCommand = 0,
|
||||
GetWorkspaces = 1,
|
||||
Subscribe = 2,
|
||||
GetOutputs = 3,
|
||||
GetTree = 4,
|
||||
GetMarks = 5,
|
||||
GetVersion = 7
|
||||
};
|
||||
|
||||
enum EventType : quint32 {
|
||||
WorkspaceEvent = 0x80000000u,
|
||||
OutputEvent = 0x80000001u,
|
||||
WindowEvent = 0x80000003u,
|
||||
BindingEvent = 0x80000005u,
|
||||
ShutdownEvent = 0x80000006u
|
||||
};
|
||||
|
||||
explicit SwayIpcClient(QObject *parent = nullptr);
|
||||
~SwayIpcClient() override;
|
||||
|
||||
bool connectToSway();
|
||||
void disconnectFromSway();
|
||||
bool isConnected() const;
|
||||
|
||||
QJsonDocument request(quint32 type, const QByteArray &payload = {});
|
||||
bool subscribe(const QStringList &events);
|
||||
|
||||
static QString socketPath();
|
||||
|
||||
signals:
|
||||
void connectedChanged();
|
||||
void disconnected();
|
||||
void eventReceived(quint32 type, const QJsonDocument &payload);
|
||||
|
||||
private:
|
||||
struct Message {
|
||||
quint32 type = 0;
|
||||
QByteArray payload;
|
||||
};
|
||||
|
||||
bool writeMessage(QLocalSocket *socket, quint32 type, const QByteArray &payload);
|
||||
bool readMessage(QLocalSocket *socket, Message *message, int timeoutMs);
|
||||
void appendIncoming(QLocalSocket *socket, QByteArray *buffer);
|
||||
bool takeMessage(QByteArray *buffer, Message *message);
|
||||
void processEventSocket();
|
||||
void flushPendingEvents();
|
||||
void handleCommandDisconnected();
|
||||
void handleEventDisconnected();
|
||||
|
||||
QLocalSocket *m_command = nullptr;
|
||||
QLocalSocket *m_event = nullptr;
|
||||
QByteArray m_eventBuffer;
|
||||
QVector<Message> m_pendingEvents;
|
||||
int m_inRequest = 0;
|
||||
bool m_connected = false;
|
||||
};
|
||||
@@ -0,0 +1,504 @@
|
||||
#include "SwayWindowBackend.hpp"
|
||||
|
||||
#include <QJsonArray>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QJsonValue>
|
||||
#include <QVariant>
|
||||
#include <QtGlobal>
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr int kMaxConnectAttempts = 8;
|
||||
|
||||
QString quoteCriteriaValue(const QString &value)
|
||||
{
|
||||
QString escaped = value;
|
||||
escaped.replace(QLatin1Char('\\'), QStringLiteral("\\\\"));
|
||||
escaped.replace(QLatin1Char('"'), QStringLiteral("\\\""));
|
||||
return escaped;
|
||||
}
|
||||
|
||||
bool isScratchpadWorkspace(const QString &name)
|
||||
{
|
||||
return name == QLatin1String("__i3_scratch");
|
||||
}
|
||||
|
||||
bool isPseudoOutput(const QString &name)
|
||||
{
|
||||
return name == QLatin1String("__i3");
|
||||
}
|
||||
|
||||
qint64 jsonId(const QJsonValue &value)
|
||||
{
|
||||
if (value.isDouble())
|
||||
return static_cast<qint64>(value.toDouble());
|
||||
return value.toVariant().toLongLong();
|
||||
}
|
||||
|
||||
QString floatingState(const QJsonObject &node)
|
||||
{
|
||||
return node.value(QStringLiteral("floating")).toString();
|
||||
}
|
||||
|
||||
bool nodeIsFloating(const QJsonObject &node)
|
||||
{
|
||||
const QString state = floatingState(node);
|
||||
if (state == QLatin1String("user_on") || state == QLatin1String("auto_on"))
|
||||
return true;
|
||||
return node.value(QStringLiteral("type")).toString() == QLatin1String("floating_con");
|
||||
}
|
||||
|
||||
bool nodeLooksLikeView(const QJsonObject &node)
|
||||
{
|
||||
const int pid = node.value(QStringLiteral("pid")).toInt();
|
||||
if (pid <= 0)
|
||||
return false;
|
||||
|
||||
const QString appId = node.value(QStringLiteral("app_id")).toString();
|
||||
if (!appId.isEmpty())
|
||||
return true;
|
||||
|
||||
if (node.contains(QStringLiteral("window")) && !node.value(QStringLiteral("window")).isNull())
|
||||
return true;
|
||||
|
||||
const QJsonObject properties = node.value(QStringLiteral("window_properties")).toObject();
|
||||
return !properties.isEmpty();
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
SwayWindowBackend::SwayWindowBackend(QObject *parent)
|
||||
: WindowBackend(parent)
|
||||
, m_ipc(new SwayIpcClient(this))
|
||||
, m_retryTimer(new QTimer(this))
|
||||
{
|
||||
m_retryTimer->setSingleShot(true);
|
||||
connect(m_retryTimer, &QTimer::timeout, this, &SwayWindowBackend::tryConnect);
|
||||
connect(m_ipc, &SwayIpcClient::eventReceived, this, &SwayWindowBackend::handleEvent);
|
||||
connect(m_ipc, &SwayIpcClient::connectedChanged, this, &SwayWindowBackend::connectedChanged);
|
||||
connect(m_ipc, &SwayIpcClient::disconnected, this, [this]() {
|
||||
m_windows.clear();
|
||||
m_workspaces.clear();
|
||||
m_focusedWindowId = 0;
|
||||
emit windowsChanged();
|
||||
emit workspacesChanged();
|
||||
emit focusChanged();
|
||||
});
|
||||
|
||||
tryConnect();
|
||||
}
|
||||
|
||||
bool SwayWindowBackend::isConnected() const
|
||||
{
|
||||
return m_ipc->isConnected();
|
||||
}
|
||||
|
||||
QVector<WindowInfo> SwayWindowBackend::windows() const
|
||||
{
|
||||
return m_windows;
|
||||
}
|
||||
|
||||
QVector<WorkspaceInfo> SwayWindowBackend::workspaces() const
|
||||
{
|
||||
return m_workspaces;
|
||||
}
|
||||
|
||||
qint64 SwayWindowBackend::focusedWindowId() const
|
||||
{
|
||||
return m_focusedWindowId;
|
||||
}
|
||||
|
||||
bool SwayWindowBackend::focusWindow(qint64 windowId)
|
||||
{
|
||||
if (m_minimized.contains(windowId) || (findWindow(windowId) && findWindow(windowId)->minimized)) {
|
||||
if (!run(QStringLiteral("[con_id=%1] scratchpad show").arg(windowId)))
|
||||
return false;
|
||||
m_minimized.remove(windowId);
|
||||
}
|
||||
return run(QStringLiteral("[con_id=%1] focus").arg(windowId));
|
||||
}
|
||||
|
||||
bool SwayWindowBackend::closeWindow(qint64 windowId)
|
||||
{
|
||||
// `kill` asks the client to close; it does not SIGKILL the process.
|
||||
const bool ok = run(QStringLiteral("[con_id=%1] kill").arg(windowId));
|
||||
m_savedGeometry.remove(windowId);
|
||||
m_maximized.remove(windowId);
|
||||
m_minimized.remove(windowId);
|
||||
return ok;
|
||||
}
|
||||
|
||||
bool SwayWindowBackend::maximizeWindow(qint64 windowId)
|
||||
{
|
||||
saveGeometry(windowId);
|
||||
m_maximized.insert(windowId);
|
||||
m_minimized.remove(windowId);
|
||||
return run(QStringLiteral("[con_id=%1] floating enable, fullscreen disable, "
|
||||
"resize set width 100 ppt height 100 ppt, move position 0 ppt 0 ppt")
|
||||
.arg(windowId));
|
||||
}
|
||||
|
||||
bool SwayWindowBackend::restoreWindow(qint64 windowId)
|
||||
{
|
||||
if (m_minimized.contains(windowId) || (findWindow(windowId) && findWindow(windowId)->minimized)) {
|
||||
const bool ok = run(QStringLiteral("[con_id=%1] scratchpad show, floating enable").arg(windowId));
|
||||
m_minimized.remove(windowId);
|
||||
if (!ok)
|
||||
return false;
|
||||
if (!m_maximized.contains(windowId) && m_savedGeometry.contains(windowId)) {
|
||||
const QRect rect = m_savedGeometry.value(windowId);
|
||||
return run(QStringLiteral("[con_id=%1] resize set width %2 px height %3 px, "
|
||||
"move absolute position %4 px %5 px")
|
||||
.arg(windowId)
|
||||
.arg(rect.width())
|
||||
.arg(rect.height())
|
||||
.arg(rect.x())
|
||||
.arg(rect.y()));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (m_savedGeometry.contains(windowId)) {
|
||||
const QRect rect = m_savedGeometry.take(windowId);
|
||||
m_maximized.remove(windowId);
|
||||
return run(QStringLiteral("[con_id=%1] floating enable, fullscreen disable, "
|
||||
"resize set width %2 px height %3 px, "
|
||||
"move absolute position %4 px %5 px")
|
||||
.arg(windowId)
|
||||
.arg(rect.width())
|
||||
.arg(rect.height())
|
||||
.arg(rect.x())
|
||||
.arg(rect.y()));
|
||||
}
|
||||
|
||||
m_maximized.remove(windowId);
|
||||
return run(QStringLiteral("[con_id=%1] floating enable, fullscreen disable, "
|
||||
"resize set width 70 ppt height 70 ppt, move position center")
|
||||
.arg(windowId));
|
||||
}
|
||||
|
||||
bool SwayWindowBackend::minimizeWindow(qint64 windowId)
|
||||
{
|
||||
saveGeometry(windowId);
|
||||
m_minimized.insert(windowId);
|
||||
return run(QStringLiteral("[con_id=%1] move scratchpad").arg(windowId));
|
||||
}
|
||||
|
||||
bool SwayWindowBackend::setWindowFloating(qint64 windowId, bool floating)
|
||||
{
|
||||
return run(QStringLiteral("[con_id=%1] floating %2")
|
||||
.arg(windowId)
|
||||
.arg(floating ? QStringLiteral("enable") : QStringLiteral("disable")));
|
||||
}
|
||||
|
||||
bool SwayWindowBackend::moveWindow(qint64 windowId, int x, int y)
|
||||
{
|
||||
return run(QStringLiteral("[con_id=%1] move absolute position %2 px %3 px")
|
||||
.arg(windowId)
|
||||
.arg(x)
|
||||
.arg(y));
|
||||
}
|
||||
|
||||
bool SwayWindowBackend::resizeWindow(qint64 windowId, int width, int height)
|
||||
{
|
||||
if (width < 1 || height < 1)
|
||||
return false;
|
||||
return run(QStringLiteral("[con_id=%1] resize set width %2 px height %3 px")
|
||||
.arg(windowId)
|
||||
.arg(width)
|
||||
.arg(height));
|
||||
}
|
||||
|
||||
bool SwayWindowBackend::snapWindow(qint64 windowId, SnapEdge edge)
|
||||
{
|
||||
saveGeometry(windowId);
|
||||
m_minimized.remove(windowId);
|
||||
if (edge == SnapMaximize) {
|
||||
m_maximized.insert(windowId);
|
||||
return run(QStringLiteral("[con_id=%1] floating enable, fullscreen disable, "
|
||||
"resize set width 100 ppt height 100 ppt, move position 0 ppt 0 ppt")
|
||||
.arg(windowId));
|
||||
}
|
||||
|
||||
m_maximized.remove(windowId);
|
||||
const QString x = (edge == SnapRight) ? QStringLiteral("50") : QStringLiteral("0");
|
||||
return run(QStringLiteral("[con_id=%1] floating enable, fullscreen disable, "
|
||||
"resize set width 50 ppt height 100 ppt, move position %2 ppt 0 ppt")
|
||||
.arg(windowId)
|
||||
.arg(x));
|
||||
}
|
||||
|
||||
bool SwayWindowBackend::switchWorkspace(const QString &workspaceId)
|
||||
{
|
||||
if (workspaceId.isEmpty())
|
||||
return false;
|
||||
return run(QStringLiteral("workspace \"%1\"").arg(quoteCriteriaValue(workspaceId)));
|
||||
}
|
||||
|
||||
void SwayWindowBackend::tryConnect()
|
||||
{
|
||||
if (m_ipc->isConnected())
|
||||
return;
|
||||
|
||||
const QString path = SwayIpcClient::socketPath();
|
||||
if (path.isEmpty()) {
|
||||
qWarning("nebula: SWAYSOCK is unset; WindowService will continue without compositor IPC");
|
||||
return;
|
||||
}
|
||||
|
||||
++m_connectAttempts;
|
||||
if (!m_ipc->connectToSway()) {
|
||||
if (m_connectAttempts < kMaxConnectAttempts) {
|
||||
const int delay = 150 * m_connectAttempts;
|
||||
qInfo("nebula: Sway IPC not ready, retrying in %d ms (%d/%d)",
|
||||
delay, m_connectAttempts, kMaxConnectAttempts);
|
||||
m_retryTimer->start(delay);
|
||||
return;
|
||||
}
|
||||
qWarning("nebula: could not connect to Sway IPC at %s; window management is unavailable",
|
||||
qPrintable(path));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!m_ipc->subscribe({QStringLiteral("window"), QStringLiteral("workspace"), QStringLiteral("output")})) {
|
||||
qWarning("nebula: Sway IPC subscribe failed; window management is unavailable");
|
||||
m_ipc->disconnectFromSway();
|
||||
return;
|
||||
}
|
||||
|
||||
qInfo("nebula: WindowService connected to Sway IPC");
|
||||
refresh();
|
||||
}
|
||||
|
||||
void SwayWindowBackend::refresh()
|
||||
{
|
||||
if (!m_ipc->isConnected())
|
||||
return;
|
||||
|
||||
const QJsonDocument tree = m_ipc->request(SwayIpcClient::GetTree);
|
||||
if (!tree.isObject())
|
||||
return;
|
||||
|
||||
QVector<WindowInfo> windows;
|
||||
QVector<WorkspaceInfo> workspaces;
|
||||
collect(tree.object(), {}, {}, false, &windows, &workspaces);
|
||||
|
||||
QHash<QString, int> counts;
|
||||
qint64 focused = 0;
|
||||
for (WindowInfo &window : windows) {
|
||||
window.maximized = m_maximized.contains(window.id);
|
||||
if (window.minimized)
|
||||
m_minimized.insert(window.id);
|
||||
else
|
||||
m_minimized.remove(window.id);
|
||||
if (!window.minimized)
|
||||
counts[window.workspace] += 1;
|
||||
if (window.focused && !window.minimized)
|
||||
focused = window.id;
|
||||
}
|
||||
for (WorkspaceInfo &workspace : workspaces) {
|
||||
workspace.windowCount = counts.value(workspace.name, 0);
|
||||
if (workspace.focused)
|
||||
continue;
|
||||
for (const WindowInfo &window : windows) {
|
||||
if (window.focused && !window.minimized && window.workspace == workspace.name) {
|
||||
workspace.focused = true;
|
||||
workspace.visible = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const bool focusChanged = (focused != m_focusedWindowId);
|
||||
m_windows = std::move(windows);
|
||||
m_workspaces = std::move(workspaces);
|
||||
m_focusedWindowId = focused;
|
||||
|
||||
emit windowsChanged();
|
||||
emit workspacesChanged();
|
||||
if (focusChanged)
|
||||
emit focusChanged();
|
||||
}
|
||||
|
||||
void SwayWindowBackend::handleEvent(quint32 type, const QJsonDocument &payload)
|
||||
{
|
||||
if (type == SwayIpcClient::WindowEvent) {
|
||||
const QJsonObject object = payload.object();
|
||||
const QString change = object.value(QStringLiteral("change")).toString();
|
||||
if (change == QLatin1String("new")) {
|
||||
const qint64 id = jsonId(object.value(QStringLiteral("container")).toObject().value(QStringLiteral("id")));
|
||||
QTimer::singleShot(0, this, [this, id]() { handleNewWindow(id); });
|
||||
return;
|
||||
}
|
||||
if (change == QLatin1String("close")) {
|
||||
const qint64 id = jsonId(object.value(QStringLiteral("container")).toObject().value(QStringLiteral("id")));
|
||||
m_savedGeometry.remove(id);
|
||||
m_maximized.remove(id);
|
||||
m_minimized.remove(id);
|
||||
}
|
||||
}
|
||||
|
||||
if (m_refreshQueued)
|
||||
return;
|
||||
m_refreshQueued = true;
|
||||
QTimer::singleShot(0, this, [this]() {
|
||||
m_refreshQueued = false;
|
||||
refresh();
|
||||
});
|
||||
}
|
||||
|
||||
void SwayWindowBackend::handleNewWindow(qint64 windowId)
|
||||
{
|
||||
refresh();
|
||||
const WindowInfo *window = findWindow(windowId);
|
||||
if (!window || window->minimized || window->fullscreen)
|
||||
return;
|
||||
|
||||
const int slot = m_cascadeIndex++ % 8;
|
||||
if (slot == 0)
|
||||
return;
|
||||
|
||||
const int offset = slot * 32;
|
||||
run(QStringLiteral("[con_id=%1] move right %2 px, move down %3 px")
|
||||
.arg(windowId)
|
||||
.arg(offset)
|
||||
.arg(offset));
|
||||
}
|
||||
|
||||
bool SwayWindowBackend::run(const QString &command)
|
||||
{
|
||||
if (!m_ipc->isConnected())
|
||||
return false;
|
||||
|
||||
const QJsonDocument reply = m_ipc->request(SwayIpcClient::RunCommand, command.toUtf8());
|
||||
const QJsonArray results = reply.array();
|
||||
bool ok = !results.isEmpty();
|
||||
for (const QJsonValue &value : results) {
|
||||
const QJsonObject object = value.toObject();
|
||||
if (!object.value(QStringLiteral("success")).toBool(true)) {
|
||||
qWarning("nebula: compositor command failed: %s (%s)",
|
||||
qPrintable(command),
|
||||
qPrintable(object.value(QStringLiteral("error")).toString()));
|
||||
ok = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (ok && !m_refreshQueued) {
|
||||
m_refreshQueued = true;
|
||||
QTimer::singleShot(0, this, [this]() {
|
||||
m_refreshQueued = false;
|
||||
refresh();
|
||||
});
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
bool SwayWindowBackend::saveGeometry(qint64 windowId)
|
||||
{
|
||||
const WindowInfo *window = findWindow(windowId);
|
||||
if (!window)
|
||||
return false;
|
||||
if (!m_savedGeometry.contains(windowId) && !m_maximized.contains(windowId))
|
||||
m_savedGeometry.insert(windowId, QRect(window->x, window->y, window->width, window->height));
|
||||
return true;
|
||||
}
|
||||
|
||||
const WindowInfo *SwayWindowBackend::findWindow(qint64 windowId) const
|
||||
{
|
||||
for (const WindowInfo &window : m_windows) {
|
||||
if (window.id == windowId)
|
||||
return &window;
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
bool SwayWindowBackend::isShellChrome(const QJsonObject &node)
|
||||
{
|
||||
const QString appId = node.value(QStringLiteral("app_id")).toString();
|
||||
if (appId == QLatin1String("org.nebulaos.shell") || appId.startsWith(QLatin1String("nebula-")))
|
||||
return true;
|
||||
|
||||
const QString name = node.value(QStringLiteral("name")).toString();
|
||||
if (name == QLatin1String("Nebula Desktop")
|
||||
|| name == QLatin1String("Nebula Top Bar")
|
||||
|| name == QLatin1String("Nebula Launcher")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const QString shell = node.value(QStringLiteral("shell")).toString();
|
||||
if (shell.contains(QLatin1String("layer"), Qt::CaseInsensitive))
|
||||
return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
void SwayWindowBackend::collect(const QJsonObject &node,
|
||||
const QString &workspace,
|
||||
const QString &output,
|
||||
bool onScratchpad,
|
||||
QVector<WindowInfo> *windows,
|
||||
QVector<WorkspaceInfo> *workspaces)
|
||||
{
|
||||
const QString type = node.value(QStringLiteral("type")).toString();
|
||||
const QString name = node.value(QStringLiteral("name")).toString();
|
||||
|
||||
QString nextWorkspace = workspace;
|
||||
QString nextOutput = output;
|
||||
bool nextScratchpad = onScratchpad;
|
||||
|
||||
if (type == QLatin1String("output")) {
|
||||
nextOutput = name;
|
||||
if (isPseudoOutput(name))
|
||||
nextScratchpad = true;
|
||||
} else if (type == QLatin1String("workspace")) {
|
||||
nextWorkspace = name;
|
||||
nextScratchpad = isScratchpadWorkspace(name);
|
||||
if (!nextScratchpad) {
|
||||
WorkspaceInfo info;
|
||||
info.id = name;
|
||||
info.name = name;
|
||||
info.number = node.value(QStringLiteral("num")).toInt(-1);
|
||||
info.focused = node.value(QStringLiteral("focused")).toBool();
|
||||
info.visible = node.value(QStringLiteral("visible")).toBool();
|
||||
info.output = node.value(QStringLiteral("output")).toString(output);
|
||||
const QJsonObject rect = node.value(QStringLiteral("rect")).toObject();
|
||||
info.x = rect.value(QStringLiteral("x")).toInt();
|
||||
info.y = rect.value(QStringLiteral("y")).toInt();
|
||||
info.width = rect.value(QStringLiteral("width")).toInt();
|
||||
info.height = rect.value(QStringLiteral("height")).toInt();
|
||||
workspaces->append(info);
|
||||
}
|
||||
}
|
||||
|
||||
if (nodeLooksLikeView(node) && !isShellChrome(node)) {
|
||||
WindowInfo window;
|
||||
window.id = jsonId(node.value(QStringLiteral("id")));
|
||||
window.title = name;
|
||||
window.appId = node.value(QStringLiteral("app_id")).toString();
|
||||
const QJsonObject properties = node.value(QStringLiteral("window_properties")).toObject();
|
||||
window.windowClass = properties.value(QStringLiteral("class")).toString();
|
||||
if (window.title.isEmpty())
|
||||
window.title = properties.value(QStringLiteral("title")).toString();
|
||||
window.workspace = nextWorkspace;
|
||||
window.output = nextOutput;
|
||||
window.focused = node.value(QStringLiteral("focused")).toBool();
|
||||
window.floating = nodeIsFloating(node);
|
||||
window.fullscreen = node.value(QStringLiteral("fullscreen_mode")).toInt() > 0;
|
||||
window.minimized = nextScratchpad;
|
||||
const QJsonObject rect = node.value(QStringLiteral("rect")).toObject();
|
||||
window.x = rect.value(QStringLiteral("x")).toInt();
|
||||
window.y = rect.value(QStringLiteral("y")).toInt();
|
||||
window.width = rect.value(QStringLiteral("width")).toInt();
|
||||
window.height = rect.value(QStringLiteral("height")).toInt();
|
||||
windows->append(window);
|
||||
}
|
||||
|
||||
const QJsonArray nodes = node.value(QStringLiteral("nodes")).toArray();
|
||||
for (const QJsonValue &child : nodes)
|
||||
collect(child.toObject(), nextWorkspace, nextOutput, nextScratchpad, windows, workspaces);
|
||||
|
||||
const QJsonArray floating = node.value(QStringLiteral("floating_nodes")).toArray();
|
||||
for (const QJsonValue &child : floating)
|
||||
collect(child.toObject(), nextWorkspace, nextOutput, nextScratchpad, windows, workspaces);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
#pragma once
|
||||
|
||||
#include "SwayIpcClient.hpp"
|
||||
#include "WindowBackend.hpp"
|
||||
|
||||
#include <QHash>
|
||||
#include <QJsonObject>
|
||||
#include <QRect>
|
||||
#include <QSet>
|
||||
#include <QTimer>
|
||||
#include <QVector>
|
||||
|
||||
class SwayWindowBackend : public WindowBackend
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit SwayWindowBackend(QObject *parent = nullptr);
|
||||
|
||||
bool isConnected() const override;
|
||||
QVector<WindowInfo> windows() const override;
|
||||
QVector<WorkspaceInfo> workspaces() const override;
|
||||
qint64 focusedWindowId() const override;
|
||||
|
||||
bool focusWindow(qint64 windowId) override;
|
||||
bool closeWindow(qint64 windowId) override;
|
||||
bool maximizeWindow(qint64 windowId) override;
|
||||
bool restoreWindow(qint64 windowId) override;
|
||||
bool minimizeWindow(qint64 windowId) override;
|
||||
bool setWindowFloating(qint64 windowId, bool floating) override;
|
||||
bool moveWindow(qint64 windowId, int x, int y) override;
|
||||
bool resizeWindow(qint64 windowId, int width, int height) override;
|
||||
bool snapWindow(qint64 windowId, SnapEdge edge) override;
|
||||
bool switchWorkspace(const QString &workspaceId) override;
|
||||
|
||||
private:
|
||||
void tryConnect();
|
||||
void refresh();
|
||||
void handleEvent(quint32 type, const QJsonDocument &payload);
|
||||
void handleNewWindow(qint64 windowId);
|
||||
bool run(const QString &command);
|
||||
bool saveGeometry(qint64 windowId);
|
||||
const WindowInfo *findWindow(qint64 windowId) const;
|
||||
static bool isShellChrome(const QJsonObject &node);
|
||||
static void collect(const QJsonObject &node,
|
||||
const QString &workspace,
|
||||
const QString &output,
|
||||
bool onScratchpad,
|
||||
QVector<WindowInfo> *windows,
|
||||
QVector<WorkspaceInfo> *workspaces);
|
||||
|
||||
SwayIpcClient *m_ipc = nullptr;
|
||||
QTimer *m_retryTimer = nullptr;
|
||||
QVector<WindowInfo> m_windows;
|
||||
QVector<WorkspaceInfo> m_workspaces;
|
||||
QHash<qint64, QRect> m_savedGeometry;
|
||||
QSet<qint64> m_maximized;
|
||||
QSet<qint64> m_minimized;
|
||||
qint64 m_focusedWindowId = 0;
|
||||
int m_connectAttempts = 0;
|
||||
int m_cascadeIndex = 0;
|
||||
bool m_refreshQueued = false;
|
||||
};
|
||||
Reference in New Issue
Block a user