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:
2026-08-26 18:20:56 +12:00
parent 8c07c10050
commit 2f59d3c115
29 changed files with 2177 additions and 35 deletions
+46
View File
@@ -91,3 +91,49 @@ const DesktopEntry *ApplicationModel::entryById(const QString &desktopId) const
}
return nullptr;
}
const DesktopEntry *ApplicationModel::entryMatchingWindow(const QString &appId, const QString &windowClass) const
{
auto stem = [](QString value) {
if (value.endsWith(QLatin1String(".desktop"), Qt::CaseInsensitive))
value.chop(8);
return value.toLower();
};
const QString app = stem(appId);
const QString cls = stem(windowClass);
const QString appLast = app.contains(QLatin1Char('.')) ? app.section(QLatin1Char('.'), -1) : app;
const QString classLast = cls.contains(QLatin1Char('.')) ? cls.section(QLatin1Char('.'), -1) : cls;
const DesktopEntry *wmClassMatch = nullptr;
const DesktopEntry *lastComponentMatch = nullptr;
for (const DesktopEntry &entry : m_entries) {
const QString id = stem(entry.desktopId);
if (!app.isEmpty() && id == app)
return &entry;
if (!cls.isEmpty() && id == cls)
return &entry;
if (!entry.startupWmClass.isEmpty()) {
const QString wm = stem(entry.startupWmClass);
if ((!app.isEmpty() && wm == app) || (!cls.isEmpty() && wm == cls)
|| (!appLast.isEmpty() && wm == appLast) || (!classLast.isEmpty() && wm == classLast)) {
if (!wmClassMatch)
wmClassMatch = &entry;
}
}
if (!lastComponentMatch) {
const QString idLast = id.contains(QLatin1Char('.')) ? id.section(QLatin1Char('.'), -1) : id;
if (appLast.size() >= 3 && (id == appLast || idLast == appLast))
lastComponentMatch = &entry;
else if (classLast.size() >= 3 && (id == classLast || idLast == classLast))
lastComponentMatch = &entry;
}
}
if (wmClassMatch)
return wmClassMatch;
return lastComponentMatch;
}
+1
View File
@@ -35,6 +35,7 @@ public:
void setEntries(QVector<DesktopEntry> entries);
const DesktopEntry *entryAt(int row) const;
const DesktopEntry *entryById(const QString &desktopId) const;
const DesktopEntry *entryMatchingWindow(const QString &appId, const QString &windowClass) const;
signals:
void countChanged();
+35
View File
@@ -7,13 +7,21 @@
#include <QFileInfo>
#include <QSet>
#include <QVector>
#include <QtGlobal>
#include <utility>
namespace {
ApplicationService *s_instance = nullptr;
}
ApplicationService::ApplicationService(QObject *parent)
: QObject(parent)
, m_model(new ApplicationModel(this))
, m_filtered(new ApplicationFilterModel(this))
{
if (!s_instance)
s_instance = this;
m_filtered->setSourceModel(m_model);
m_filtered->setSortRole(ApplicationModel::NameRole);
m_filtered->sort(0, Qt::AscendingOrder);
@@ -24,6 +32,17 @@ ApplicationService::ApplicationService(QObject *parent)
refresh();
}
ApplicationService::~ApplicationService()
{
if (s_instance == this)
s_instance = nullptr;
}
ApplicationService *ApplicationService::instance()
{
return s_instance;
}
ApplicationService *ApplicationService::create(QQmlEngine *engine, QJSEngine *)
{
auto *service = new ApplicationService(engine);
@@ -66,6 +85,22 @@ bool ApplicationService::launch(const QString &desktopId) const
return entry->launch();
}
QString ApplicationService::displayNameForWindow(const QString &appId, const QString &windowClass) const
{
const DesktopEntry *entry = m_model->entryMatchingWindow(appId, windowClass);
if (entry && !entry->name.isEmpty())
return entry->name;
return {};
}
QString ApplicationService::iconNameForWindow(const QString &appId, const QString &windowClass) const
{
const DesktopEntry *entry = m_model->entryMatchingWindow(appId, windowClass);
if (entry)
return entry->icon;
return {};
}
void ApplicationService::refresh()
{
QVector<DesktopEntry> entries;
+5
View File
@@ -20,7 +20,10 @@ class ApplicationService : public QObject
public:
explicit ApplicationService(QObject *parent = nullptr);
~ApplicationService() override;
static ApplicationService *create(QQmlEngine *engine, QJSEngine *scriptEngine);
static ApplicationService *instance();
ApplicationModel *model() const;
ApplicationFilterModel *filteredModel() const;
@@ -30,6 +33,8 @@ public:
int count() const;
Q_INVOKABLE bool launch(const QString &desktopId) const;
Q_INVOKABLE QString displayNameForWindow(const QString &appId, const QString &windowClass) const;
Q_INVOKABLE QString iconNameForWindow(const QString &appId, const QString &windowClass) const;
Q_INVOKABLE void refresh();
signals:
+2
View File
@@ -353,6 +353,8 @@ DesktopEntry parseDesktopEntryFile(const QString &filePath, const QString &deskt
entry.exec = value;
else if (key == QLatin1String("TryExec"))
entry.tryExec = value;
else if (key == QLatin1String("StartupWMClass"))
entry.startupWmClass = value;
else if (key == QLatin1String("Icon"))
entry.icon = value;
else if (key == QLatin1String("Path"))
+1
View File
@@ -13,6 +13,7 @@ struct DesktopEntry
QString icon;
QString exec;
QString tryExec;
QString startupWmClass;
QString workingDirectory;
QStringList categories;
bool terminal = false;
+2 -1
View File
@@ -2,13 +2,14 @@
Shared C++ service for discovering and launching installed Linux applications from `.desktop` entries.
Desktop and the future Bigscreen shell should both use this library. It has no Desktop-specific QML.
Desktop and the future Bigscreen shell should both use this library. It has no Desktop-specific QML. Window naming helpers (`displayNameForWindow`, `iconNameForWindow`) are intended for `WindowService`.
## Behaviour
- Scans XDG application directories (`XDG_DATA_HOME`, `XDG_DATA_DIRS`, with the usual `~/.local/share` and `/usr/share` defaults).
- Hides entries with `Hidden=true` or `NoDisplay=true`, and respects `OnlyShowIn` / `NotShowIn` / `TryExec`.
- Exposes a `QAbstractListModel` to QML (`desktopId`, `name`, `genericName`, `comment`, `iconName`, `categories`, `terminal`).
- Parses `StartupWMClass` and can map a compositor `app_id` / window class to a friendly `Name` (`displayNameForWindow`, `iconNameForWindow`) for `WindowService`.
- Launches with `QProcess::startDetached` and `QProcess::splitCommand`. Does not use a shell.
- Strips or substitutes common Exec field codes when launching without files/URLs.
+63
View File
@@ -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()
+41
View File
@@ -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.
+47
View File
@@ -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();
};
+114
View File
@@ -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;
}
+54
View File
@@ -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;
};
+257
View File
@@ -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(' '));
}
+82
View File
@@ -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;
};
+40
View File
@@ -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;
};
+84
View File
@@ -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;
}
+44
View File
@@ -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;
};