Add Nebula.Windows with Sway IPC backend

Introduces a new `core/windows` module (`Nebula.Windows`) with `WindowService`, window/workspace models, and a Sway IPC backend for live window tracking and control (focus, close, move/resize, snap, maximize/restore, minimize, workspace switch). The desktop shell now links this module, uses compositor focus for top-bar active app text, and adds an Open Windows section in the launcher with focus/close actions.

Also updates application metadata handling to parse `StartupWMClass` and map window `app_id`/class to friendly names and icons, and revises Sway config/docs for the new floating-first Desktop 0.2 behavior with temporary compositor-provided decorations.
This commit is contained in:
2026-08-26 18:20:56 +12:00
parent 8c07c10050
commit 2f59d3c115
29 changed files with 2177 additions and 35 deletions
+70 -13
View File
@@ -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`.
+12 -1
View File
@@ -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.
+4 -3
View File
@@ -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
}
}