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:
@@ -8,6 +8,7 @@ A Linux desktop environment built around a custom compositor, shell, and service
|
||||
|------|---------|
|
||||
| `core/nebula-core` | Core runtime and platform APIs (growing) |
|
||||
| `core/applications` | Installed `.desktop` discovery and launching |
|
||||
| `core/windows` | Window and workspace management (`WindowService`) |
|
||||
| `compositor/` | Compositor configuration (Sway is temporary) |
|
||||
| `shells/` | Desktop and future Bigscreen shells |
|
||||
| `packages/` | Shared UI (`Nebula.UI`), icons, API, and design tokens |
|
||||
@@ -19,9 +20,9 @@ A Linux desktop environment built around a custom compositor, shell, and service
|
||||
|
||||
## Shells
|
||||
|
||||
**Nebula Desktop 0.1** is a C++20 Qt Quick/QML Wayland shell using LayerShellQt. It can discover installed applications and launch them (including Foot) from the GUI. See `shells/desktop/`.
|
||||
**Nebula Desktop 0.2** is a C++20 Qt Quick/QML Wayland shell using LayerShellQt. It is floating-first: ordinary applications open as independently movable windows. See `shells/desktop/`.
|
||||
|
||||
**Nebula Bigscreen** is planned as a separate Qt Quick/QML shell with a controller-first interaction model, sharing `Nebula.UI` and `Nebula.Applications`.
|
||||
**Nebula Bigscreen** is planned as a separate Qt Quick/QML shell with a controller-first interaction model, sharing `Nebula.UI`, `Nebula.Applications`, and `Nebula.Windows`.
|
||||
|
||||
Standalone Nebula applications may still use React/Tauri where that is a better fit. The operating-system shell does not.
|
||||
|
||||
@@ -34,13 +35,30 @@ Ubuntu
|
||||
↓
|
||||
Wayland
|
||||
↓
|
||||
Sway temporary compositor
|
||||
Sway temporary compositor / window manager
|
||||
↓
|
||||
Nebula Shell Qt Quick/QML
|
||||
Nebula Shell Qt Quick/QML via LayerShellQt
|
||||
```
|
||||
|
||||
Window management is abstracted:
|
||||
|
||||
```text
|
||||
Nebula QML
|
||||
↓
|
||||
WindowService
|
||||
↓
|
||||
Sway IPC backend now
|
||||
Nebula compositor later
|
||||
↓
|
||||
Sway
|
||||
/ \
|
||||
Wayland app XWayland app
|
||||
```
|
||||
|
||||
Applications and games render on their own Wayland/XWayland paths. They are not composited inside QML.
|
||||
|
||||
Sway currently provides temporary window decorations (title bar and border) so windows can be identified, dragged, and resized. Custom Nebula window chrome is later work.
|
||||
|
||||
## Getting started (development VM)
|
||||
|
||||
```bash
|
||||
|
||||
+29
-6
@@ -5,14 +5,15 @@ Sway is the **temporary** Wayland compositor while Nebula Desktop is developed.
|
||||
Sway currently provides:
|
||||
|
||||
- Wayland compositor
|
||||
- application window management
|
||||
- floating-first application window management
|
||||
- temporary title bars and borders on normal application windows
|
||||
- XWayland
|
||||
- input and the pointer cursor
|
||||
- output handling
|
||||
|
||||
Nebula provides the visible shell (Qt Quick/QML via LayerShellQt). The Sway config must not enable a Sway bar.
|
||||
Nebula provides the visible shell (Qt Quick/QML via LayerShellQt) and logical window management through `WindowService` (`core/windows`). The Sway config must not enable a Sway bar.
|
||||
|
||||
A custom Nebula compositor is a later milestone. Do not treat this Sway setup as the final architecture.
|
||||
A custom Nebula compositor is a later milestone. Do not treat this Sway setup as the final architecture. QML must not call `swaymsg`; the Sway IPC backend is the only Sway-specific code, and it is isolated so it can be replaced.
|
||||
|
||||
```text
|
||||
Ubuntu/Linux
|
||||
@@ -26,6 +27,19 @@ Nebula Shell
|
||||
Qt Quick/QML
|
||||
```
|
||||
|
||||
## Floating-first session
|
||||
|
||||
Ordinary application windows open as floating windows, sized to about **70% of the current workspace** (the usable output area below the 40px Nebula top bar) and centred. Sway workspace geometry already excludes the top-bar exclusive zone, so placement stays on-screen at any resolution.
|
||||
|
||||
Rules match only normal application views:
|
||||
|
||||
- Wayland: `shell="xdg_shell"` (`app_id`)
|
||||
- XWayland: `shell="xwayland"` (`class`)
|
||||
|
||||
Layer-shell surfaces (Nebula desktop, top bar, launcher, overlays) are a different shell and are not matched. They stay on BACKGROUND / TOP / OVERLAY.
|
||||
|
||||
Sway draws a restrained title bar and border on those application windows so they can be identified, dragged, and resized with the mouse. This is **temporary**. Custom Nebula decorations, CSD, and wrapping Wayland clients inside QML are out of scope.
|
||||
|
||||
## Development session
|
||||
|
||||
From the NebulaOS repository root:
|
||||
@@ -50,7 +64,7 @@ NEBULA_SHELL_AUTOSTART=0 sh compositor/sway/dev/run-sway-vm.sh
|
||||
|
||||
### Output / resolution
|
||||
|
||||
The development config uses scale `1` and does not pin a resolution. Sway uses the output's preferred mode, which follows the current display size (including VMware Autofit Guest).
|
||||
The development config uses scale `1` and does not pin a resolution. Sway uses the output's preferred mode, which follows the current display size (including VMware Autofit Guest). Window sizes use workspace-relative percentages, not hard-coded pixel dimensions.
|
||||
|
||||
```bash
|
||||
swaymsg -t get_outputs
|
||||
@@ -69,15 +83,24 @@ Requires `adwaita-icon-theme`. A Nebula-branded cursor theme is a later task.
|
||||
|
||||
`WLR_NO_HARDWARE_CURSORS=1` is VMware-specific and is set by `run-sway-vm.sh`, not by production session files.
|
||||
|
||||
### Emergency shortcuts
|
||||
### Emergency / development shortcuts
|
||||
|
||||
Normal launching, focusing, and closing is done from the Nebula GUI. These remain compositor fallbacks:
|
||||
|
||||
| Shortcut | Action |
|
||||
|----------|--------|
|
||||
| Super+Enter | open Foot |
|
||||
| Super+Left Mouse | move the floating window under the pointer |
|
||||
| Super+Right Mouse | resize the floating window under the pointer |
|
||||
| Super+Left | snap to left half of the usable area |
|
||||
| Super+Right | snap to right half of the usable area |
|
||||
| Super+Up | maximise to the usable area (below the top bar) |
|
||||
| Super+Down | restore to the default ~70% centred size |
|
||||
| Super+Shift+Q | close the focused window |
|
||||
| Super+Shift+E | exit Sway |
|
||||
| Super+Shift+C | reload Sway config |
|
||||
|
||||
Normal application launching is done from the Nebula launcher, not these shortcuts.
|
||||
Title-bar drag and border resize should be the normal mouse path. Super+mouse is an emergency convenience.
|
||||
|
||||
### Rendering
|
||||
|
||||
|
||||
@@ -5,6 +5,11 @@ set $term foot
|
||||
#
|
||||
# Sway is invisible infrastructure. Nebula Desktop is the visible shell.
|
||||
# Do NOT add a bar block; Nebula provides its own top bar.
|
||||
#
|
||||
# Nebula Desktop 0.2 is floating-first: ordinary application windows open
|
||||
# as independently movable/resizable floating windows, not tiled to fill
|
||||
# the workspace. Sway remains the temporary compositor/window manager.
|
||||
# Custom Nebula window decoration is later work.
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Output / resolution
|
||||
@@ -28,6 +33,76 @@ seat * xcursor_theme Adwaita 24
|
||||
# WLR_NO_HARDWARE_CURSORS=1 sway -c compositor/sway/nebula-sway.conf
|
||||
# compositor/sway/dev/run-sway-vm.sh sets that automatically.
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Focus / pointer
|
||||
# -----------------------------------------------------------------------------
|
||||
# Do not warp the pointer when focusing windows; this is a mouse-first desktop.
|
||||
mouse_warping none
|
||||
focus_follows_mouse yes
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Temporary window decorations
|
||||
# -----------------------------------------------------------------------------
|
||||
# Sway draws a restrained title bar and border on normal application windows
|
||||
# so they can be identified, dragged, and resized with the mouse.
|
||||
#
|
||||
# This is temporary infrastructure. Nebula does not yet own client/server-side
|
||||
# decorations. Do not treat these colours or metrics as the final design.
|
||||
#
|
||||
# Layer-shell surfaces (desktop, top bar, launcher) are not xdg_shell/XWayland
|
||||
# views and are not decorated by these settings.
|
||||
font pango:Ubuntu 10
|
||||
titlebar_padding 7 3
|
||||
titlebar_border_thickness 1
|
||||
default_border normal 1
|
||||
default_floating_border normal 1
|
||||
hide_edge_borders none
|
||||
|
||||
# class border backgr. text indicator child_border
|
||||
client.focused #3d4d62 #1a222e #e8ecf2 #7ea0c4 #3d4d62
|
||||
client.focused_inactive #2a3342 #141a24 #8e99ab #2a3342 #2a3342
|
||||
client.unfocused #1a2029 #10151c #6b7587 #1a2029 #1a2029
|
||||
client.urgent #c98888 #1a222e #e8ecf2 #c98888 #c98888
|
||||
client.placeholder #1a2029 #0b0f16 #6b7587 #1a2029 #1a2029
|
||||
client.background #0b0f16
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Floating-first application windows
|
||||
# -----------------------------------------------------------------------------
|
||||
# Match only ordinary application views:
|
||||
# Wayland → shell="xdg_shell" (app_id)
|
||||
# XWayland → shell="xwayland" (class)
|
||||
#
|
||||
# Layer-shell clients (Nebula desktop, top bar, launcher, overlays) are a
|
||||
# different shell and are not matched. They stay on BACKGROUND / TOP / OVERLAY.
|
||||
#
|
||||
# Size is a percentage of the *workspace* (ppt). Sway workspace geometry
|
||||
# already excludes the 40px Nebula top-bar exclusive zone, so 70% × centred
|
||||
# stays in the usable work area on any resolution. Do not hard-code pixels.
|
||||
|
||||
for_window [shell="xdg_shell"] floating enable, border normal, resize set width 70 ppt height 70 ppt, move position center
|
||||
for_window [shell="xwayland"] floating enable, border normal, resize set width 70 ppt height 70 ppt, move position center
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Mouse move and resize
|
||||
# -----------------------------------------------------------------------------
|
||||
# Title bar: drag. Window edges: resize.
|
||||
# Development fallback (does not replace title-bar interaction):
|
||||
# Super + Left Mouse → move
|
||||
# Super + Right Mouse → resize
|
||||
floating_modifier $mod normal
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Development snap shortcuts
|
||||
# -----------------------------------------------------------------------------
|
||||
# Preparation for later Snap Layouts. Keyboard is not the only way to arrange
|
||||
# windows; the mouse still moves and resizes floating windows directly.
|
||||
# Percentages are workspace-relative (usable area below the top bar).
|
||||
bindsym $mod+Left resize set width 50 ppt height 100 ppt, move position 0 ppt 0 ppt
|
||||
bindsym $mod+Right resize set width 50 ppt height 100 ppt, move position 50 ppt 0 ppt
|
||||
bindsym $mod+Up resize set width 100 ppt height 100 ppt, move position 0 ppt 0 ppt
|
||||
bindsym $mod+Down resize set width 70 ppt height 70 ppt, move position center
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Nebula shell autostart
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -46,12 +121,6 @@ exec /bin/sh scripts/run-nebula-vm.sh
|
||||
# Emergency development shortcuts (not the normal UX)
|
||||
# -----------------------------------------------------------------------------
|
||||
bindsym $mod+Return exec $term
|
||||
bindsym $mod+Shift+q kill
|
||||
bindsym $mod+Shift+e exec swaymsg exit
|
||||
bindsym $mod+Shift+c reload
|
||||
|
||||
# Remove Sway window decorations
|
||||
default_border pixel 0
|
||||
default_floating_border pixel 0
|
||||
|
||||
# Basic floating controls for development
|
||||
floating_modifier $mod normal
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -13,6 +13,7 @@ struct DesktopEntry
|
||||
QString icon;
|
||||
QString exec;
|
||||
QString tryExec;
|
||||
QString startupWmClass;
|
||||
QString workingDirectory;
|
||||
QStringList categories;
|
||||
bool terminal = false;
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
+70
-13
@@ -1,8 +1,8 @@
|
||||
# Nebula Desktop
|
||||
|
||||
Production shell for the mouse-and-keyboard NebulaOS desktop. This is Nebula Desktop **0.1**: a usable session foundation, not only a visual prototype.
|
||||
Production shell for the mouse-and-keyboard NebulaOS desktop. This is Nebula Desktop **0.2**: a usable floating-first session with compositor-backed window management.
|
||||
|
||||
The user can enter the development session, use the full output, open the Nebula launcher, and start real installed applications (including Foot) without Sway keyboard shortcuts.
|
||||
The user can enter the development session, use the full output, open the Nebula launcher, start real installed applications (including Foot), move and resize them as ordinary windows, see the focused application in the top bar, and close windows from the GUI.
|
||||
|
||||
## Architecture
|
||||
|
||||
@@ -47,7 +47,24 @@ Wayland / XWayland
|
||||
Sway
|
||||
```
|
||||
|
||||
Games, browsers, terminals, and other apps never pass through Qt Quick.
|
||||
Window management is a separate path. QML never talks to Sway:
|
||||
|
||||
```text
|
||||
Nebula QML
|
||||
↓
|
||||
WindowService core/windows
|
||||
↓
|
||||
generic backend
|
||||
↓
|
||||
Sway IPC now
|
||||
Nebula compositor later
|
||||
↓
|
||||
Sway
|
||||
/ \
|
||||
Wayland app XWayland app
|
||||
```
|
||||
|
||||
Games, browsers, terminals, and other apps never pass through Qt Quick. Do not draw third-party application windows inside QML, and do not wrap Wayland clients in custom Nebula QML windows.
|
||||
|
||||
## Production stack
|
||||
|
||||
@@ -57,6 +74,7 @@ Games, browsers, terminals, and other apps never pass through Qt Quick.
|
||||
| UI | Qt 6, Qt Quick, QML |
|
||||
| Layer surfaces | LayerShellQt |
|
||||
| Installed apps | `core/applications` (`import Nebula.Applications`) |
|
||||
| Windows / workspaces | `core/windows` (`import Nebula.Windows`) |
|
||||
| Build | CMake |
|
||||
| Shared design system | `packages/nebula-ui` (`import Nebula.UI`) |
|
||||
|
||||
@@ -92,6 +110,30 @@ BACKGROUND
|
||||
|
||||
Surfaces follow the live Wayland output geometry from Qt (`OutputTracker`). They do not assume 1920×1080 or other fixed desktop sizes. If VMware resizes the display, the shell updates.
|
||||
|
||||
## Window management
|
||||
|
||||
Nebula Desktop 0.2 is **floating-first**. Sway is still the compositor, but ordinary applications no longer tile to fill the workspace.
|
||||
|
||||
- New Wayland (`app_id`) and XWayland (`class`) windows open floating at about 70% of the **workspace** (usable area below the exclusive top bar), centred, with a simple cascade offset for subsequent windows.
|
||||
- Sway provides a temporary restrained title bar and border so windows can be identified, dragged, and resized. Custom Nebula decorations are later work.
|
||||
- Super+Left/Right mouse remains a development move/resize fallback. Super+Left/Right/Up/Down snap or maximise using workspace-relative sizes.
|
||||
|
||||
`WindowService` owns the logical model:
|
||||
|
||||
- `WindowModel` roles: `windowId`, `title`, `appId`, `windowClass`, `workspace`, `focused`, `floating`, `fullscreen`, `x`, `y`, `width`, `height`, plus `displayName`, `iconName`, `minimized`, `maximized`, `output`
|
||||
- `WorkspaceModel` roles: `workspaceId`, `name`, `number`, `focused`, `visible`, `output`, `windowCount`
|
||||
- Live updates from Sway IPC `window` / `workspace` / `output` events (no polling, no `swaymsg` from QML)
|
||||
- Focused **normal application** only — desktop, top bar, launcher, and overlays are not the active app
|
||||
- Operations: `focusWindow`, `closeWindow` (compositor close request, not process kill), `maximizeWindow`, `restoreWindow`, `setWindowFloating`, `moveWindow`, `resizeWindow`, `snapWindow`, `switchWorkspace`
|
||||
- Maximise fills the workspace usable area (below the top bar). Restore returns the previous floating geometry when WindowService saved it
|
||||
- Minimise exists on the service (Sway scratchpad internally). The UI does not mention scratchpad
|
||||
|
||||
If `SWAYSOCK` is unset or IPC fails, the shell keeps running. WindowService logs a warning and stays empty.
|
||||
|
||||
The top-bar context shows the focused application's user-facing name (via `ApplicationService` metadata when possible, otherwise a humanized `app_id` / class). With no normal window focused it shows **Desktop**.
|
||||
|
||||
The launcher has a temporary **Open Windows** list: click to focus, × to close. This is development-quality proof of the service, not Mission Control / Overview.
|
||||
|
||||
## Applications
|
||||
|
||||
`core/applications` discovers freedesktop `.desktop` entries from XDG data directories:
|
||||
@@ -99,7 +141,7 @@ Surfaces follow the live Wayland output geometry from Qt (`OutputTracker`). They
|
||||
1. `$XDG_DATA_HOME/applications` (default `~/.local/share/applications`)
|
||||
2. `$XDG_DATA_DIRS/applications` (default `/usr/local/share:/usr/share`)
|
||||
|
||||
Launcher-visible apps respect `Hidden=true`, `NoDisplay=true`, `OnlyShowIn` / `NotShowIn`, and `TryExec` when present. Localized `Name` values are used when available.
|
||||
Launcher-visible apps respect `Hidden=true`, `NoDisplay=true`, `OnlyShowIn` / `NotShowIn`, and `TryExec` when present. Localized `Name` values are used when available. `StartupWMClass` is stored so `WindowService` can map a compositor `app_id` / class to a friendly name.
|
||||
|
||||
Launching uses `QProcess::startDetached` after `QProcess::splitCommand`. Exec field codes (`%f`, `%F`, `%u`, `%U`, `%i`, `%c`, `%k`, `%%`, …) are handled for launches without file arguments. Desktop-entry strings are never passed to a shell.
|
||||
|
||||
@@ -107,8 +149,6 @@ Search matches name, generic name, comment, and categories. Searching `term` sho
|
||||
|
||||
Application icons use the system icon theme through Qt (`QIcon::fromTheme`). Unresolved icons use a Nebula fallback glyph, not a broken-image placeholder.
|
||||
|
||||
The top-bar context text stays **Desktop** in this milestone. Launching an app is not treated as focus; real window tracking comes next.
|
||||
|
||||
Mock launcher data remains only as a fallback when no installed applications are discovered.
|
||||
|
||||
## Layout
|
||||
@@ -123,7 +163,7 @@ shells/desktop/
|
||||
└── prototype-react/ archived design reference only
|
||||
```
|
||||
|
||||
Shared visual language lives in `packages/nebula-ui`. Application discovery lives in `core/applications`.
|
||||
Shared visual language lives in `packages/nebula-ui`. Application discovery lives in `core/applications`. Window management lives in `core/windows`.
|
||||
|
||||
## Linux build
|
||||
|
||||
@@ -157,6 +197,8 @@ cmake --build build -j$(nproc)
|
||||
|
||||
The production binary is `shells/desktop/shell/build/nebula-shell`. It does **not** force `QT_QUICK_BACKEND=software`. GPU-accelerated Qt Quick is the production default.
|
||||
|
||||
`WindowService` and the Sway IPC backend are linked as the `NebulaWindows` target (`core/windows`). Do not compile those `.cpp` files into the shell through relative paths.
|
||||
|
||||
### QML lint
|
||||
|
||||
If the installed Qt provides `qmllint`:
|
||||
@@ -192,7 +234,8 @@ That should:
|
||||
2. autostart `nebula-shell` through `scripts/run-nebula-vm.sh`
|
||||
3. fill the current output
|
||||
4. show a normal Adwaita 24px cursor
|
||||
5. let the Nebula launcher start Foot and other installed apps
|
||||
5. open launched apps as floating windows below the top bar
|
||||
6. let the Nebula launcher start, focus, and close Foot and other installed apps
|
||||
|
||||
Skip autostart while debugging the compositor:
|
||||
|
||||
@@ -218,7 +261,7 @@ QT_QUICK_BACKEND=software ./build/nebula-shell
|
||||
|
||||
### Output / resolution
|
||||
|
||||
The development Sway config uses scale `1` and does not pin a resolution. Sway uses the output's preferred mode so the session follows the current display size.
|
||||
The development Sway config uses scale `1` and does not pin a resolution. Sway uses the output's preferred mode so the session follows the current display size. Window placement uses compositor workspace geometry, not 1920×1080.
|
||||
|
||||
Inspect outputs and modes:
|
||||
|
||||
@@ -249,11 +292,16 @@ Install `adwaita-icon-theme` if the theme is missing. A branded Nebula cursor is
|
||||
|
||||
### Emergency shortcuts
|
||||
|
||||
These remain for development fallback. They are not the normal UX once the GUI launcher works.
|
||||
These remain for development fallback. They are not the normal UX once the GUI launcher and window list work.
|
||||
|
||||
| Shortcut | Action |
|
||||
|----------|--------|
|
||||
| Super+Enter | open Foot |
|
||||
| Super+Left Mouse | move window |
|
||||
| Super+Right Mouse | resize window |
|
||||
| Super+Left / Right / Up | snap / maximise in the usable area |
|
||||
| Super+Down | restore default floating size |
|
||||
| Super+Shift+Q | close focused window |
|
||||
| Super+Shift+E | exit Sway |
|
||||
| Super+Shift+C | reload Sway config |
|
||||
|
||||
@@ -261,12 +309,21 @@ These remain for development fallback. They are not the normal UX once the GUI l
|
||||
|
||||
`prototype-react/` is an archived React/Vite UI. It is **not** the production shell. Keep it until the Qt shell reaches visual and behavioural parity, then remove it in a later cleanup.
|
||||
|
||||
## Current limitations
|
||||
|
||||
- Sway is still the compositor. The future Nebula compositor is not started.
|
||||
- Window decorations are Sway's temporary title bar/border, not Nebula chrome.
|
||||
- The Open Windows section is a temporary launcher list, not Overview / Mission Control.
|
||||
- Clicking the desktop background may not clear application focus; Sway still considers the last client focused. The top bar follows compositor focus.
|
||||
- Minimise is available on `WindowService` but is not a first-class desktop UI yet.
|
||||
- Keyboard snap shortcuts are development conveniences, not a Snap Layouts UI.
|
||||
|
||||
## Out of scope
|
||||
|
||||
Not implemented in this milestone:
|
||||
|
||||
- Sway IPC / focused-window tracking
|
||||
- workspaces, window overview, dock
|
||||
- Mission Control / Overview, dock, live window thumbnails
|
||||
- custom Nebula titlebar renderer / CSD protocol
|
||||
- PipeWire, NetworkManager, BlueZ, UPower
|
||||
- notifications, XDG portals, Polkit
|
||||
- login/lock screen, greetd
|
||||
@@ -274,4 +331,4 @@ Not implemented in this milestone:
|
||||
- Nebula Bigscreen
|
||||
- Gamescope
|
||||
|
||||
Those come later. Bigscreen is expected to reuse `Nebula.UI` and `Nebula.Applications`.
|
||||
Those come later. Bigscreen is expected to reuse `Nebula.UI`, `Nebula.Applications`, and `Nebula.Windows`.
|
||||
|
||||
@@ -13,7 +13,7 @@ set(CMAKE_CXX_STANDARD 20)
|
||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
set(CMAKE_CXX_EXTENSIONS OFF)
|
||||
|
||||
find_package(Qt6 6.4 REQUIRED COMPONENTS Core Gui Qml Quick)
|
||||
find_package(Qt6 6.4 REQUIRED COMPONENTS Core Gui Qml Quick Network)
|
||||
find_package(LayerShellQt REQUIRED)
|
||||
|
||||
qt_standard_project_setup(REQUIRES 6.4)
|
||||
@@ -30,6 +30,11 @@ add_subdirectory(
|
||||
"${CMAKE_CURRENT_BINARY_DIR}/nebula-applications"
|
||||
)
|
||||
|
||||
add_subdirectory(
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/../../../core/windows"
|
||||
"${CMAKE_CURRENT_BINARY_DIR}/nebula-windows"
|
||||
)
|
||||
|
||||
qt_add_executable(nebula-shell
|
||||
src/main.cpp
|
||||
src/DesktopIconProvider.cpp
|
||||
@@ -48,6 +53,7 @@ set(NEBULA_SHELL_QML_FILES
|
||||
qml/components/SystemArea.qml
|
||||
qml/components/Launcher.qml
|
||||
qml/components/AppItem.qml
|
||||
qml/components/OpenWindowItem.qml
|
||||
qml/mock/MockApps.qml
|
||||
)
|
||||
|
||||
@@ -79,6 +85,7 @@ qt_add_qml_module(nebula-shell
|
||||
QtQuick
|
||||
Nebula.UI
|
||||
Nebula.Applications
|
||||
Nebula.Windows
|
||||
)
|
||||
|
||||
target_link_libraries(nebula-shell
|
||||
@@ -90,6 +97,7 @@ target_link_libraries(nebula-shell
|
||||
LayerShellQt::Interface
|
||||
NebulaUI
|
||||
NebulaApplications
|
||||
NebulaWindows
|
||||
)
|
||||
|
||||
if(TARGET NebulaUIplugin)
|
||||
@@ -98,6 +106,9 @@ endif()
|
||||
if(TARGET NebulaApplicationsplugin)
|
||||
target_link_libraries(nebula-shell PRIVATE NebulaApplicationsplugin)
|
||||
endif()
|
||||
if(TARGET NebulaWindowsplugin)
|
||||
target_link_libraries(nebula-shell PRIVATE NebulaWindowsplugin)
|
||||
endif()
|
||||
|
||||
# Generated nebula-shell_qmltyperegistrations.cpp includes "OutputTracker.hpp"
|
||||
# via __has_include. Keep src/ on the include path so that succeeds.
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
pragma Singleton
|
||||
|
||||
import QtQuick
|
||||
import Nebula.Windows
|
||||
|
||||
QtObject {
|
||||
property bool launcherOpen: false
|
||||
|
||||
// Focused-window tracking is a later compositor milestone. Launching an
|
||||
// application does not mean it has focus, so this stays "Desktop" for now.
|
||||
property string activeApplication: "Desktop"
|
||||
readonly property string activeApplication: WindowService.focusedApplicationName.length > 0
|
||||
? WindowService.focusedApplicationName
|
||||
: qsTr("Desktop")
|
||||
|
||||
function openLauncher() {
|
||||
launcherOpen = true
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import QtQuick
|
||||
import Nebula.UI
|
||||
import Nebula.Applications
|
||||
import Nebula.Windows
|
||||
|
||||
Item {
|
||||
id: root
|
||||
@@ -187,6 +188,33 @@ Item {
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
width: parent.width
|
||||
spacing: 8
|
||||
visible: !root.searching && WindowService.windowCount > 0
|
||||
|
||||
Text {
|
||||
text: qsTr("Open Windows")
|
||||
color: Theme.textSecondary
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
font.weight: Font.DemiBold
|
||||
font.letterSpacing: 0.4
|
||||
}
|
||||
|
||||
Column {
|
||||
width: parent.width
|
||||
spacing: 2
|
||||
|
||||
Repeater {
|
||||
model: WindowService.model
|
||||
delegate: OpenWindowItem {
|
||||
width: parent.width
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
width: parent.width
|
||||
spacing: 8
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import QtQuick
|
||||
import Nebula.UI
|
||||
import Nebula.Windows
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
required property string windowId
|
||||
required property string displayName
|
||||
required property string iconName
|
||||
required property bool focused
|
||||
required property bool minimized
|
||||
|
||||
implicitWidth: parent ? parent.width : 200
|
||||
implicitHeight: 36
|
||||
Accessible.role: Accessible.ListItem
|
||||
Accessible.name: displayName
|
||||
Accessible.onPressAction: root.activate()
|
||||
|
||||
readonly property url iconSource: iconName.length > 0
|
||||
? ("image://desktopicon/" + encodeURIComponent(iconName))
|
||||
: ""
|
||||
|
||||
function activate() {
|
||||
WindowService.focusWindow(windowId)
|
||||
ShellState.closeLauncher()
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
radius: Theme.radiusSmall
|
||||
color: {
|
||||
if (hover.containsMouse)
|
||||
return Theme.tileHover
|
||||
if (root.focused)
|
||||
return Theme.accentSoft
|
||||
return "transparent"
|
||||
}
|
||||
|
||||
Behavior on color {
|
||||
ColorAnimation {
|
||||
duration: Theme.animationFast
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Row {
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: 8
|
||||
anchors.rightMargin: 4
|
||||
spacing: 8
|
||||
|
||||
Rectangle {
|
||||
width: 22
|
||||
height: 22
|
||||
radius: 6
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
color: Theme.iconWell
|
||||
|
||||
Image {
|
||||
id: icon
|
||||
anchors.centerIn: parent
|
||||
width: 14
|
||||
height: 14
|
||||
fillMode: Image.PreserveAspectFit
|
||||
asynchronous: true
|
||||
smooth: true
|
||||
cache: true
|
||||
visible: status === Image.Ready
|
||||
source: root.iconSource
|
||||
}
|
||||
|
||||
NebulaIcon {
|
||||
anchors.centerIn: parent
|
||||
visible: icon.status !== Image.Ready
|
||||
name: "application"
|
||||
size: 12
|
||||
color: Theme.accent
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: parent.width - 22 - 28 - 16
|
||||
text: root.displayName
|
||||
color: {
|
||||
if (root.minimized)
|
||||
return Theme.textTertiary
|
||||
if (root.focused)
|
||||
return Theme.textPrimary
|
||||
return Theme.textSecondary
|
||||
}
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeApp
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
|
||||
Item {
|
||||
width: 28
|
||||
height: parent.height
|
||||
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
text: "×"
|
||||
color: closeHover.containsMouse ? Theme.danger : Theme.textTertiary
|
||||
font.pixelSize: 14
|
||||
font.family: Theme.fontFamily
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: closeHover
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: WindowService.closeWindow(root.windowId)
|
||||
}
|
||||
|
||||
Accessible.role: Accessible.Button
|
||||
Accessible.name: qsTr("Close %1").arg(root.displayName)
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: hover
|
||||
anchors.fill: parent
|
||||
anchors.rightMargin: 28
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: root.activate()
|
||||
}
|
||||
|
||||
FocusFrame {
|
||||
radius: Theme.radiusSmall
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user