Improves focused-window geometry so chrome placement stays stable across workspaces and floating containers. WindowService now exposes focused workspace X/Y, resolves workspace by name, and publishes those offsets with focused geometry. The Sway backend now tracks the active floating frame through recursion and consistently derives frame/deco/content rects from that frame. QML chrome placement was updated to use workspace-relative coordinates and simpler direct margins (removing interpolation behaviors), with safer title-bar height handling for vertical centering.
580 lines
20 KiB
C++
580 lines
20 KiB
C++
#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();
|
|
}
|
|
|
|
void applyFrameGeometry(WindowInfo &window, const QJsonObject &node, const QJsonObject &frame)
|
|
{
|
|
const bool useFrame = !frame.isEmpty()
|
|
&& (frame.value(QStringLiteral("type")).toString() == QLatin1String("floating_con")
|
|
|| nodeIsFloating(frame));
|
|
const QJsonObject &source = useFrame ? frame : node;
|
|
|
|
const QJsonObject rect = source.value(QStringLiteral("rect")).toObject();
|
|
const QJsonObject deco = source.value(QStringLiteral("deco_rect")).toObject();
|
|
const QJsonObject content = source.value(QStringLiteral("window_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();
|
|
window.decoX = deco.value(QStringLiteral("x")).toInt();
|
|
window.decoY = deco.value(QStringLiteral("y")).toInt();
|
|
window.decoWidth = deco.value(QStringLiteral("width")).toInt();
|
|
window.decoHeight = deco.value(QStringLiteral("height")).toInt();
|
|
window.contentX = content.value(QStringLiteral("x")).toInt();
|
|
window.contentY = content.value(QStringLiteral("y")).toInt();
|
|
}
|
|
|
|
} // 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)));
|
|
}
|
|
|
|
bool SwayWindowBackend::updateFocusedGeometry()
|
|
{
|
|
if (!m_ipc->isConnected() || m_focusedWindowId == 0)
|
|
return false;
|
|
|
|
const QJsonDocument tree = m_ipc->request(SwayIpcClient::GetTree);
|
|
if (!tree.isObject())
|
|
return false;
|
|
|
|
QVector<WindowInfo> windows;
|
|
QVector<WorkspaceInfo> workspaces;
|
|
collect(tree.object(), {}, {}, {}, false, &windows, &workspaces);
|
|
|
|
for (WindowInfo &window : windows) {
|
|
if (window.id != m_focusedWindowId)
|
|
continue;
|
|
for (WindowInfo &cached : m_windows) {
|
|
if (cached.id != window.id)
|
|
continue;
|
|
if (cached.x == window.x
|
|
&& cached.y == window.y
|
|
&& cached.width == window.width
|
|
&& cached.height == window.height
|
|
&& cached.decoX == window.decoX
|
|
&& cached.decoY == window.decoY
|
|
&& cached.decoWidth == window.decoWidth
|
|
&& cached.decoHeight == window.decoHeight
|
|
&& cached.contentX == window.contentX
|
|
&& cached.contentY == window.contentY
|
|
&& cached.fullscreen == window.fullscreen)
|
|
return false;
|
|
cached.x = window.x;
|
|
cached.y = window.y;
|
|
cached.width = window.width;
|
|
cached.height = window.height;
|
|
cached.decoX = window.decoX;
|
|
cached.decoY = window.decoY;
|
|
cached.decoWidth = window.decoWidth;
|
|
cached.decoHeight = window.decoHeight;
|
|
cached.contentX = window.contentX;
|
|
cached.contentY = window.contentY;
|
|
cached.fullscreen = window.fullscreen;
|
|
return true;
|
|
}
|
|
break;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
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 focusDidChange = (focused != m_focusedWindowId);
|
|
m_windows = std::move(windows);
|
|
m_workspaces = std::move(workspaces);
|
|
m_focusedWindowId = focused;
|
|
|
|
emit windowsChanged();
|
|
emit workspacesChanged();
|
|
if (focusDidChange)
|
|
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")
|
|
|| name == QLatin1String("Nebula Window Chrome")) {
|
|
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 QJsonObject &frame,
|
|
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);
|
|
}
|
|
}
|
|
|
|
QJsonObject nextFrame = frame;
|
|
if (type == QLatin1String("floating_con"))
|
|
nextFrame = node;
|
|
|
|
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) || nodeIsFloating(nextFrame);
|
|
window.fullscreen = node.value(QStringLiteral("fullscreen_mode")).toInt() > 0;
|
|
window.minimized = nextScratchpad;
|
|
applyFrameGeometry(window, node, nextFrame);
|
|
windows->append(window);
|
|
}
|
|
|
|
const QJsonArray nodes = node.value(QStringLiteral("nodes")).toArray();
|
|
for (const QJsonValue &child : nodes)
|
|
collect(child.toObject(), nextFrame, nextWorkspace, nextOutput, nextScratchpad, windows, workspaces);
|
|
|
|
const QJsonArray floating = node.value(QStringLiteral("floating_nodes")).toArray();
|
|
for (const QJsonValue &child : floating)
|
|
collect(child.toObject(), nextFrame, nextWorkspace, nextOutput, nextScratchpad, windows, workspaces);
|
|
}
|