Compare commits

...
20 Commits
Author SHA1 Message Date
andrew 852a5fe82e Update run-sway-vm.sh 2026-08-26 23:16:01 +12:00
andrew ba7945a1d6 Fix window chrome positioning on Sway
Improves focused-window geometry so chrome placement stays stable across workspaces and floating containers. WindowService now exposes focused workspace X/Y, resolves workspace by name, and publishes those offsets with focused geometry. The Sway backend now tracks the active floating frame through recursion and consistently derives frame/deco/content rects from that frame. QML chrome placement was updated to use workspace-relative coordinates and simpler direct margins (removing interpolation behaviors), with safer title-bar height handling for vertical centering.
2026-08-26 23:10:22 +12:00
andrew e4ea19cd09 Fix Sway floating frame geometry handling
Use the floating container geometry whenever a view is inside a `floating_con`, so frame/deco/content coordinates stay stable and window chrome no longer jumps during refreshes. Also remove the custom `title_format` left-padding (and its related comment) from the Sway config so title text is no longer artificially offset.
2026-08-26 23:00:11 +12:00
andrew 75a74a4f01 Update run-nebula-vm.sh 2026-08-26 22:55:24 +12:00
andrew 7741fbcb5c Refine window chrome position and sizing
Updates UI theme metrics for traffic-light controls and chrome inset to slightly larger values. In the desktop window chrome surface, positioning now uses the title bar center directly, rounds rendered margins, and introduces short easing animations for X/Y chrome offsets to reduce jitter during movement.
2026-08-26 22:51:16 +12:00
andrew 4d70014242 Align window chrome with Sway title bars
Adds focused title-bar geometry to the window service and updates the Sway backend to derive frame data more reliably (including parent node fallback and content rect tracking for wrapped/floating views). Window chrome positioning now centers against the actual title bar instead of deco offsets, improving alignment. Also refreshes visual sizing/padding for traffic-light controls and Sway titlebar spacing to match the new chrome metrics.
2026-08-26 22:45:50 +12:00
andrew 4321209e92 Add floating traffic-light window chrome
Introduces a dedicated layer-shell `WindowChromeSurface` that renders macOS-style traffic-light controls over the focused Sway window titlebar, replacing the old top-bar window controls. Adds theme tokens and a reusable `TrafficLightButton` component, updates shell QML/CMake wiring, and adjusts Sway titlebar padding/title format so window titles do not overlap the overlay. Window tracking was extended with focused-window geometry/deco/fullscreen properties plus backend refresh support so the chrome follows window movement and state changes in near real time.
2026-08-26 22:38:33 +12:00
andrew 08e67845a5 Add focused window controls to top bar
Introduces a new `WindowControls` component in the desktop top bar with minimize, maximize/restore, and close actions for the focused window. `WindowService` now exposes focused-window presence/maximized state plus focused-window convenience actions to support this UI.

Also extends Nebula UI with a reusable `NebulaToolTip`, keyboard/pressed-state support in `NebulaIconButton`, new window-control glyphs in `NebulaIcon`, and theme tokens for pressed/danger hover states, with CMake and README updates to register/document the new components.
2026-08-26 22:26:29 +12:00
andrew e96a22eb9e Update SwayWindowBackend.cpp 2026-08-26 18:22:28 +12:00
andrew 2f59d3c115 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.
2026-08-26 18:20:56 +12:00
andrew 8c07c10050 Stop pinning Sway output mode in dev config
Removes the hardcoded `output * mode 1920x1080` from the development Sway config and updates related docs to match. Dev sessions now rely on each output’s preferred mode at scale `1`, which better follows current display size (including VMware autofit behavior), while still documenting how to set a mode manually when needed.
2026-08-26 17:51:46 +12:00
andrew ab869119cc Fix Qt compatibility and VM script docs
Updates desktop shell and application code for cross-Qt compatibility: uses begin/endFilterChange for Qt 6.10+, gates LayerShell usage to Qt < 6.5, and adds include handling for generated QML type registration files so nebula-shell builds reliably. Also fixes qInfo size formatting casts and updates README commands to invoke run-sway-vm.sh via `sh` for more portable execution.
2026-08-26 17:35:00 +12:00
andrew b0c95b9e78 Add desktop app service and VM dev session tooling
Introduces a new `core/applications` QML/C++ module (`Nebula.Applications`) to discover `.desktop` entries, expose filterable application models, and launch installed apps safely from the shell. The desktop launcher now uses real installed apps with themed icon loading and fallback glyphs, adds output-aware surface sizing via `OutputTracker`, and wires in image/icon providers. It also adds VMware-focused Sway/shell wrapper scripts, updates Sway config/session env defaults, and refreshes README docs to document the new development flow and Desktop 0.1 behavior.
2026-08-26 17:19:30 +12:00
andrew a7d204f241 Update Main.qml 2026-08-26 16:50:43 +12:00
andrew 9db047b8ad Update CMakeLists.txt 2026-08-26 16:45:54 +12:00
andrew 13d06e3ca8 Update CMakeLists.txt 2026-08-26 16:40:33 +12:00
andrew 9348605044 Update CMakeLists.txt 2026-08-26 16:38:37 +12:00
andrew befed8ff96 Add Qt/QML desktop shell and Nebula.UI
Introduce a new production desktop shell at `shells/desktop/shell` built with C++20, Qt Quick/QML, and LayerShellQt, including top bar, launcher, desktop surfaces, shell state, and mock app data. Add the shared `packages/nebula-ui` QML module (theme, controls, focus, icons) and wire it into the shell build.

Retire the old GTK/WebKit native host by removing `shells/desktop/native`, move the React shell UI to `shells/desktop/prototype-react` as an archived design reference, and update root/compositor/session documentation to reflect the new Linux-only production architecture and temporary Sway role.
2026-08-26 16:34:40 +12:00
andrew 5504d3cfb8 Update nebula-sway.conf 2026-08-26 16:02:05 +12:00
andrew 2dadead399 Add native Wayland shell host scaffolding
Introduce a Linux-only C++20 `nebula-shell` host built with CMake, GTK4, gtk4-layer-shell, and WebKitGTK 6.0. Add a shared `Surface` abstraction and concrete Desktop/TopBar/Launcher surfaces with layer placement, sizing, transparency, and per-surface `?surface=` URL loading.

Add `ShellApplication` startup flow that validates `NEBULA_SHELL_DEV_URL`, checks layer-shell support, creates/presents desktop and top bar, and keeps launcher created but hidden for future bridge-driven toggling. Update the native README to document architecture, development setup, dependencies, and current milestone scope.
2026-08-26 15:44:44 +12:00
105 changed files with 6727 additions and 154 deletions
+71 -6
View File
@@ -6,15 +6,80 @@ A Linux desktop environment built around a custom compositor, shell, and service
| Path | Purpose | | Path | Purpose |
|------|---------| |------|---------|
| `core/nebula-core` | Core runtime and platform APIs | | `core/nebula-core` | Core runtime and platform APIs (growing) |
| `compositor/` | Compositor configuration (Sway) | | `core/applications` | Installed `.desktop` discovery and launching |
| `shells/` | Desktop and bigscreen shell implementations | | `core/windows` | Window and workspace management (`WindowService`) |
| `packages/` | Shared UI libraries, icons, API, and design tokens | | `compositor/` | Compositor configuration (Sway is temporary) |
| `shells/` | Desktop and future Bigscreen shells |
| `packages/` | Shared UI (`Nebula.UI`), icons, API, and design tokens |
| `services/` | System services (audio, network, bluetooth, power, accounts) | | `services/` | System services (audio, network, bluetooth, power, accounts) |
| `session/` | Session manager and desktop entry | | `session/` | Session manager and desktop entry |
| `packaging/` | Distribution packaging (Ubuntu) | | `packaging/` | Distribution packaging (Ubuntu) |
| `branding/` | Boot assets, wallpapers, and sounds | | `branding/` | Boot assets, wallpapers, and sounds |
| `scripts/` | Development helpers (including the VMware shell wrapper) |
## Getting started ## Shells
Documentation and build instructions will be added as components land. **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`, `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.
An archived React desktop prototype lives in `shells/desktop/prototype-react/` as a design reference only.
## Current stack
```text
Ubuntu
Wayland
Sway temporary compositor / window manager
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
cd shells/desktop/shell
rm -rf build
cmake -S . -B build
cmake --build build -j$(nproc)
```
Then, from the repository root:
```bash
sh compositor/sway/dev/run-sway-vm.sh
```
or:
```bash
sway -c compositor/sway/nebula-sway.conf
```
The VMware wrapper sets `QT_QUICK_BACKEND=software`. Production `nebula-shell` does not. Details: `shells/desktop/README.md` and `compositor/README.md`.
The Qt shell must be compiled on Linux (the Ubuntu NebulaOS VM). It will not build on Windows.
+105 -1
View File
@@ -1,3 +1,107 @@
# Compositor # Compositor
NebulaOS uses Sway as its Wayland compositor. Configuration lives in `sway/nebula-sway.conf`. Sway is the **temporary** Wayland compositor while Nebula Desktop is developed. Configuration lives in `sway/nebula-sway.conf`.
Sway currently provides:
- Wayland compositor
- 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) 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. 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
Wayland
Sway now
Nebula compositor later
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:
```bash
sway -c compositor/sway/nebula-sway.conf
```
Recommended on the Ubuntu VMware VM (repo-root `cd`, cursor environment, software cursors):
```bash
sh compositor/sway/dev/run-sway-vm.sh
```
Sway then autostarts Nebula Desktop through `scripts/run-nebula-vm.sh`.
Skip shell autostart:
```bash
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). Window sizes use workspace-relative percentages, not hard-coded pixel dimensions.
```bash
swaymsg -t get_outputs
swaymsg output <name> scale 1
```
### Cursor
```text
seat * xcursor_theme Adwaita 24
XCURSOR_THEME=Adwaita
XCURSOR_SIZE=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 / 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 |
Title-bar drag and border resize should be the normal mouse path. Super+mouse is an emergency convenience.
### Rendering
`scripts/run-nebula-vm.sh` sets `QT_QUICK_BACKEND=software` for the VMware guest GPU. Production `nebula-shell` must not set that variable.
+5
View File
@@ -0,0 +1,5 @@
#!/bin/sh
# Convenience wrapper; the canonical VMware nebula-shell launcher is
# scripts/run-nebula-vm.sh
script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
exec /bin/sh "${script_dir}/../../../scripts/run-nebula-vm.sh" "$@"
+62
View File
@@ -0,0 +1,62 @@
#!/bin/sh
# Recommended VMware development session entry point.
#
# Sets compositor-side development environment (cursor theme, software
# cursors if needed) and starts Sway with the Nebula development config.
# Sway then autostarts nebula-shell via scripts/run-nebula-vm.sh.
#
# Usage:
# ./compositor/sway/dev/run-sway-vm.sh
#
# Equivalent:
# export XCURSOR_THEME=Adwaita XCURSOR_SIZE=24
# sway -c compositor/sway/nebula-sway.conf
#
# Skip shell autostart:
# NEBULA_SHELL_AUTOSTART=0 ./compositor/sway/dev/run-sway-vm.sh
set -eu
script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
repo_root=$(CDPATH= cd -- "${script_dir}/../../.." && pwd)
config="${repo_root}/compositor/sway/nebula-sway.conf"
if command -v realpath >/dev/null 2>&1; then
repo_root=$(realpath "${repo_root}")
config=$(realpath "${config}")
fi
export XDG_CURRENT_DESKTOP="${XDG_CURRENT_DESKTOP:-NebulaOS}"
export XDG_SESSION_DESKTOP="${XDG_SESSION_DESKTOP:-nebula}"
export XCURSOR_THEME="${XCURSOR_THEME:-Adwaita}"
export XCURSOR_SIZE="${XCURSOR_SIZE:-24}"
# VMware often needs software cursors. This is development-specific.
export WLR_NO_HARDWARE_CURSORS="${WLR_NO_HARDWARE_CURSORS:-1}"
if [ ! -f "${config}" ]; then
echo "nebula: missing Sway config at ${config}" >&2
exit 1
fi
if grep -q '^[[:space:]]*title_format\>' "${config}"; then
echo "nebula: refusing to start; ${config} still contains title_format" >&2
echo "nebula: this Sway build does not support that command." >&2
exit 1
fi
echo "nebula: repo ${repo_root}"
echo "nebula: config ${config}"
echo "nebula: sway $(command -v sway 2>/dev/null || echo missing)"
if [ -n "${SWAYSOCK:-}" ]; then
echo "nebula: SWAYSOCK is set to ${SWAYSOCK}" >&2
echo "nebula: you are already inside a compositor session." >&2
echo "nebula: log out of that session or switch to a TTY (Ctrl+Alt+F3)," >&2
echo "nebula: then start Sway from ~/Nebula-OS so this config is used." >&2
exit 1
fi
cd "${repo_root}"
echo "nebula: starting Sway with ${config}"
exec sway -c "${config}" "$@"
+121 -11
View File
@@ -1,17 +1,127 @@
# NebulaOS Sway configuration
include /etc/sway/config.d/*
set $mod Mod4 set $mod Mod4
set $term foot
input type:keyboard { # NebulaOS temporary development compositor configuration
xkb_layout us #
} # 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 * bg #1a1a2e solid_color # -----------------------------------------------------------------------------
# Output / resolution
# -----------------------------------------------------------------------------
# Development default: 1:1 scale, fill the current display.
# No mode is pinned, so Sway uses the output's preferred mode (typically the
# native or current size). VMware Autofit Guest can then follow the host window.
#
# Inspect modes: swaymsg -t get_outputs
# Force another mode (example): swaymsg output <name> mode 1600x900
output * scale 1
output * bg #000000 solid_color
# -----------------------------------------------------------------------------
# Cursor
# -----------------------------------------------------------------------------
# The compositor owns the pointer cursor, not QML.
# A branded Nebula cursor theme is a later task.
seat * xcursor_theme Adwaita 24
# If the cursor is invisible in VMware, start Sway with:
# 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.
#
# Nebula overlays traffic-light controls on the focused window title bar.
# Menu-style chrome (application name, future File/Edit) stays in the top bar.
#
# This is temporary infrastructure until Nebula owns server-side decorations.
# Layer-shell surfaces (desktop, top bar, launcher, window chrome) are not
# xdg_shell/XWayland views and are not decorated by these settings.
font pango:Ubuntu 10
titlebar_padding 14 10
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
# -----------------------------------------------------------------------------
# Starts nebula-shell through the VMware development wrapper, which sets
# QT_QUICK_BACKEND=software. Production sessions must start nebula-shell
# without that variable.
#
# Disable autostart for debugging:
# NEBULA_SHELL_AUTOSTART=0 sway -c compositor/sway/nebula-sway.conf
#
# Run from the repository root so this relative path resolves, or use
# compositor/sway/dev/run-sway-vm.sh which does that for you.
exec /bin/sh scripts/run-nebula-vm.sh
# -----------------------------------------------------------------------------
# Emergency development shortcuts (not the normal UX)
# -----------------------------------------------------------------------------
bindsym $mod+Return exec $term bindsym $mod+Return exec $term
bindsym $mod+Shift+q kill bindsym $mod+Shift+q kill
bindsym $mod+d exec $menu bindsym $mod+Shift+e exec swaymsg exit
bindsym $mod+Shift+c reload
exec_always nebula-session
@@ -0,0 +1,71 @@
#include "ApplicationFilterModel.hpp"
#include "ApplicationModel.hpp"
#include <QtGlobal>
ApplicationFilterModel::ApplicationFilterModel(QObject *parent)
: QSortFilterProxyModel(parent)
{
setDynamicSortFilter(true);
setFilterCaseSensitivity(Qt::CaseInsensitive);
connect(this, &QAbstractItemModel::modelReset, this, &ApplicationFilterModel::countChanged);
connect(this, &QAbstractItemModel::rowsInserted, this, &ApplicationFilterModel::countChanged);
connect(this, &QAbstractItemModel::rowsRemoved, this, &ApplicationFilterModel::countChanged);
connect(this, &QAbstractItemModel::layoutChanged, this, &ApplicationFilterModel::countChanged);
}
QString ApplicationFilterModel::filter() const
{
return m_filter;
}
void ApplicationFilterModel::setFilter(const QString &filter)
{
const QString trimmed = filter.trimmed();
if (m_filter == trimmed)
return;
m_filter = trimmed;
#if QT_VERSION >= QT_VERSION_CHECK(6, 10, 0)
beginFilterChange();
endFilterChange();
#else
invalidateFilter();
#endif
emit filterChanged();
emit countChanged();
}
int ApplicationFilterModel::count() const
{
return rowCount();
}
bool ApplicationFilterModel::filterAcceptsRow(int sourceRow, const QModelIndex &sourceParent) const
{
if (m_filter.isEmpty())
return true;
const QModelIndex index = sourceModel()->index(sourceRow, 0, sourceParent);
if (!index.isValid())
return false;
const auto matches = [this, &index](int role) {
return index.data(role).toString().contains(m_filter, Qt::CaseInsensitive);
};
if (matches(ApplicationModel::NameRole)
|| matches(ApplicationModel::GenericNameRole)
|| matches(ApplicationModel::CommentRole)) {
return true;
}
const QStringList categories = index.data(ApplicationModel::CategoriesRole).toStringList();
for (const QString &category : categories) {
if (category.contains(m_filter, Qt::CaseInsensitive))
return true;
}
return false;
}
@@ -0,0 +1,30 @@
#pragma once
#include <QSortFilterProxyModel>
#include <QtQml/qqmlregistration.h>
class ApplicationFilterModel : public QSortFilterProxyModel
{
Q_OBJECT
QML_ELEMENT
QML_UNCREATABLE("Obtained from ApplicationService")
Q_PROPERTY(QString filter READ filter WRITE setFilter NOTIFY filterChanged)
Q_PROPERTY(int count READ count NOTIFY countChanged)
public:
explicit ApplicationFilterModel(QObject *parent = nullptr);
QString filter() const;
void setFilter(const QString &filter);
int count() const;
signals:
void filterChanged();
void countChanged();
protected:
bool filterAcceptsRow(int sourceRow, const QModelIndex &sourceParent) const override;
private:
QString m_filter;
};
+139
View File
@@ -0,0 +1,139 @@
#include "ApplicationModel.hpp"
#include <algorithm>
#include <utility>
ApplicationModel::ApplicationModel(QObject *parent)
: QAbstractListModel(parent)
{
}
int ApplicationModel::rowCount(const QModelIndex &parent) const
{
if (parent.isValid())
return 0;
return m_entries.size();
}
int ApplicationModel::count() const
{
return m_entries.size();
}
QVariant ApplicationModel::data(const QModelIndex &index, int role) const
{
if (!index.isValid() || index.row() < 0 || index.row() >= m_entries.size())
return {};
const DesktopEntry &entry = m_entries.at(index.row());
switch (role) {
case DesktopIdRole:
case Qt::UserRole:
return entry.desktopId;
case NameRole:
case Qt::DisplayRole:
return entry.name;
case GenericNameRole:
return entry.genericName;
case CommentRole:
return entry.comment;
case IconNameRole:
return entry.icon;
case CategoriesRole:
return entry.categories;
case TerminalRole:
return entry.terminal;
default:
return {};
}
}
QHash<int, QByteArray> ApplicationModel::roleNames() const
{
return {
{DesktopIdRole, QByteArrayLiteral("desktopId")},
{NameRole, QByteArrayLiteral("name")},
{GenericNameRole, QByteArrayLiteral("genericName")},
{CommentRole, QByteArrayLiteral("comment")},
{IconNameRole, QByteArrayLiteral("iconName")},
{CategoriesRole, QByteArrayLiteral("categories")},
{TerminalRole, QByteArrayLiteral("terminal")},
};
}
void ApplicationModel::setEntries(QVector<DesktopEntry> entries)
{
std::sort(entries.begin(), entries.end(), [](const DesktopEntry &left, const DesktopEntry &right) {
const int order = QString::localeAwareCompare(left.name, right.name);
if (order != 0)
return order < 0;
return left.desktopId < right.desktopId;
});
beginResetModel();
m_entries = std::move(entries);
endResetModel();
emit countChanged();
}
const DesktopEntry *ApplicationModel::entryAt(int row) const
{
if (row < 0 || row >= m_entries.size())
return nullptr;
return &m_entries.at(row);
}
const DesktopEntry *ApplicationModel::entryById(const QString &desktopId) const
{
for (const DesktopEntry &entry : m_entries) {
if (entry.desktopId == desktopId)
return &entry;
}
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;
}
+45
View File
@@ -0,0 +1,45 @@
#pragma once
#include "DesktopEntry.hpp"
#include <QAbstractListModel>
#include <QVector>
#include <QtQml/qqmlregistration.h>
class ApplicationModel : public QAbstractListModel
{
Q_OBJECT
QML_ELEMENT
QML_UNCREATABLE("Obtained from ApplicationService")
Q_PROPERTY(int count READ count NOTIFY countChanged)
public:
enum Roles {
DesktopIdRole = Qt::UserRole + 1,
NameRole,
GenericNameRole,
CommentRole,
IconNameRole,
CategoriesRole,
TerminalRole
};
Q_ENUM(Roles)
explicit ApplicationModel(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 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();
private:
QVector<DesktopEntry> m_entries;
};
+144
View File
@@ -0,0 +1,144 @@
#include "ApplicationService.hpp"
#include "DesktopEntry.hpp"
#include <QDir>
#include <QDirIterator>
#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);
connect(m_model, &ApplicationModel::countChanged, this, &ApplicationService::countChanged);
connect(m_filtered, &ApplicationFilterModel::filterChanged, this, &ApplicationService::filterChanged);
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);
QQmlEngine::setObjectOwnership(service, QQmlEngine::CppOwnership);
return service;
}
ApplicationModel *ApplicationService::model() const
{
return m_model;
}
ApplicationFilterModel *ApplicationService::filteredModel() const
{
return m_filtered;
}
QString ApplicationService::filter() const
{
return m_filtered->filter();
}
void ApplicationService::setFilter(const QString &filter)
{
m_filtered->setFilter(filter);
}
int ApplicationService::count() const
{
return m_model->count();
}
bool ApplicationService::launch(const QString &desktopId) const
{
const DesktopEntry *entry = m_model->entryById(desktopId);
if (!entry) {
qWarning("nebula: unknown application %s", qPrintable(desktopId));
return false;
}
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;
QSet<QString> seenIds;
const QStringList directories = applicationDirectories();
qInfo("nebula: scanning %d application director%s",
int(directories.size()),
directories.size() == 1 ? "y" : "ies");
for (const QString &directory : directories) {
QDir dir(directory);
if (!dir.exists())
continue;
QDirIterator iterator(directory, {QStringLiteral("*.desktop")}, QDir::Files, QDirIterator::Subdirectories);
while (iterator.hasNext()) {
const QString filePath = iterator.next();
QString relative = QDir(directory).relativeFilePath(filePath);
relative.replace(QLatin1Char('/'), QLatin1Char('-'));
const QString desktopId = relative;
if (seenIds.contains(desktopId))
continue;
seenIds.insert(desktopId);
DesktopEntry entry = parseDesktopEntryFile(filePath, desktopId);
if (entry.name.isEmpty())
entry.name = QFileInfo(filePath).completeBaseName();
if (!entry.isLauncherVisible())
continue;
entries.append(std::move(entry));
}
}
qInfo("nebula: discovered %d launcher application%s",
int(entries.size()),
entries.size() == 1 ? "" : "s");
m_model->setEntries(std::move(entries));
}
+47
View File
@@ -0,0 +1,47 @@
#pragma once
#include "ApplicationFilterModel.hpp"
#include "ApplicationModel.hpp"
#include <QJSEngine>
#include <QObject>
#include <QQmlEngine>
#include <QtQml/qqmlregistration.h>
class ApplicationService : public QObject
{
Q_OBJECT
QML_ELEMENT
QML_SINGLETON
Q_PROPERTY(ApplicationModel *model READ model CONSTANT)
Q_PROPERTY(ApplicationFilterModel *filteredModel READ filteredModel CONSTANT)
Q_PROPERTY(QString filter READ filter WRITE setFilter NOTIFY filterChanged)
Q_PROPERTY(int count READ count NOTIFY countChanged)
public:
explicit ApplicationService(QObject *parent = nullptr);
~ApplicationService() override;
static ApplicationService *create(QQmlEngine *engine, QJSEngine *scriptEngine);
static ApplicationService *instance();
ApplicationModel *model() const;
ApplicationFilterModel *filteredModel() const;
QString filter() const;
void setFilter(const QString &filter);
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:
void filterChanged();
void countChanged();
private:
ApplicationModel *m_model = nullptr;
ApplicationFilterModel *m_filtered = nullptr;
};
+44
View File
@@ -0,0 +1,44 @@
cmake_minimum_required(VERSION 3.20)
if(NOT TARGET Qt6::Qml)
project(NebulaApplications LANGUAGES CXX)
find_package(Qt6 6.4 REQUIRED COMPONENTS Core Gui Qml)
qt_standard_project_setup(REQUIRES 6.4)
endif()
qt_add_library(NebulaApplications STATIC)
qt_add_qml_module(NebulaApplications
URI Nebula.Applications
VERSION 1.0
RESOURCE_PREFIX /qt/qml
SOURCES
DesktopEntry.hpp
DesktopEntry.cpp
ApplicationModel.hpp
ApplicationModel.cpp
ApplicationFilterModel.hpp
ApplicationFilterModel.cpp
ApplicationService.hpp
ApplicationService.cpp
)
target_include_directories(NebulaApplications
PUBLIC
"${CMAKE_CURRENT_SOURCE_DIR}"
)
target_link_libraries(NebulaApplications
PUBLIC
Qt6::Core
Qt6::Gui
Qt6::Qml
)
set_target_properties(NebulaApplications PROPERTIES
AUTOMOC ON
)
if(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang")
target_compile_options(NebulaApplications PRIVATE -Wall -Wextra)
endif()
+415
View File
@@ -0,0 +1,415 @@
#include "DesktopEntry.hpp"
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QLocale>
#include <QMap>
#include <QProcess>
#include <QStandardPaths>
#include <QtGlobal>
#include <utility>
namespace {
QString unquote(QString value)
{
value = value.trimmed();
if (value.size() >= 2) {
const QChar first = value.front();
const QChar last = value.back();
if ((first == QLatin1Char('"') && last == QLatin1Char('"'))
|| (first == QLatin1Char('\'') && last == QLatin1Char('\''))) {
value = value.mid(1, value.size() - 2);
}
}
return value;
}
bool parseBool(const QString &value)
{
const QString lowered = value.trimmed().toLower();
return lowered == QLatin1String("true") || lowered == QLatin1String("1");
}
struct LocalizedValue
{
QString fallback;
QMap<QString, QString> locales;
};
void storeLocalized(LocalizedValue *target, const QString &key, const QString &prefix, const QString &value)
{
if (key == prefix) {
target->fallback = value;
return;
}
const QString start = prefix + QLatin1Char('[');
if (!key.startsWith(start) || !key.endsWith(QLatin1Char(']')))
return;
const QString locale = key.mid(start.size(), key.size() - start.size() - 1);
if (!locale.isEmpty())
target->locales.insert(locale, value);
}
QString resolveLocalized(const LocalizedValue &value, const QLocale &locale)
{
const QString name = locale.name(); // e.g. en_US
const QString language = name.section(QLatin1Char('_'), 0, 0);
auto match = [&value](const QString &tag) -> QString {
if (tag.isEmpty())
return {};
return value.locales.value(tag);
};
if (QString found = match(name); !found.isEmpty())
return found;
if (QString found = match(language); !found.isEmpty())
return found;
const int underscore = name.indexOf(QLatin1Char('_'));
if (underscore > 0) {
if (QString found = match(name.left(underscore)); !found.isEmpty())
return found;
}
return value.fallback;
}
QString findTerminalEmulator()
{
const QStringList candidates = {
QStringLiteral("foot"),
QStringLiteral("x-terminal-emulator"),
QStringLiteral("kitty"),
QStringLiteral("alacritty"),
QStringLiteral("kgx"),
QStringLiteral("gnome-terminal"),
QStringLiteral("konsole"),
QStringLiteral("xterm"),
};
for (const QString &name : candidates) {
const QString path = QStandardPaths::findExecutable(name);
if (!path.isEmpty())
return path;
}
return {};
}
bool executableAvailable(const QString &command)
{
if (command.isEmpty())
return false;
const QFileInfo info(command);
if (info.isAbsolute())
return info.isExecutable();
return !QStandardPaths::findExecutable(command).isEmpty();
}
} // namespace
QStringList splitDesktopList(const QString &value)
{
const QStringList raw = value.split(QLatin1Char(';'), Qt::SkipEmptyParts);
QStringList cleaned;
cleaned.reserve(raw.size());
for (const QString &item : raw) {
const QString trimmed = item.trimmed();
if (!trimmed.isEmpty())
cleaned.append(trimmed);
}
return cleaned;
}
QStringList currentDesktopIdentifiers()
{
const QByteArray raw = qgetenv("XDG_CURRENT_DESKTOP");
if (raw.isEmpty())
return {QStringLiteral("NebulaOS")};
QStringList ids;
for (const QByteArray &part : raw.split(':')) {
const QString id = QString::fromLocal8Bit(part).trimmed();
if (!id.isEmpty())
ids.append(id);
}
if (ids.isEmpty())
ids.append(QStringLiteral("NebulaOS"));
return ids;
}
QStringList applicationDirectories()
{
QStringList dirs;
QString dataHome = QString::fromLocal8Bit(qgetenv("XDG_DATA_HOME"));
if (dataHome.isEmpty())
dataHome = QDir::homePath() + QStringLiteral("/.local/share");
dirs.append(QDir::cleanPath(dataHome + QStringLiteral("/applications")));
QString dataDirs = QString::fromLocal8Bit(qgetenv("XDG_DATA_DIRS"));
if (dataDirs.isEmpty())
dataDirs = QStringLiteral("/usr/local/share:/usr/share");
for (const QString &root : dataDirs.split(QLatin1Char(':'), Qt::SkipEmptyParts)) {
const QString trimmed = root.trimmed();
if (trimmed.isEmpty())
continue;
dirs.append(QDir::cleanPath(trimmed + QStringLiteral("/applications")));
}
dirs.removeDuplicates();
return dirs;
}
QString resolveIconName(const QString &icon)
{
if (icon.isEmpty())
return {};
const QFileInfo info(icon);
if (info.isAbsolute())
return icon;
QString name = icon;
if (name.endsWith(QLatin1String(".png"), Qt::CaseInsensitive)
|| name.endsWith(QLatin1String(".svg"), Qt::CaseInsensitive)
|| name.endsWith(QLatin1String(".xpm"), Qt::CaseInsensitive)) {
name = QFileInfo(name).completeBaseName();
}
return name;
}
QStringList expandExec(const QString &exec, const DesktopEntry &entry)
{
QString expanded;
expanded.reserve(exec.size());
for (int i = 0; i < exec.size(); ++i) {
const QChar ch = exec.at(i);
if (ch != QLatin1Char('%') || i + 1 >= exec.size()) {
expanded.append(ch);
continue;
}
const QChar code = exec.at(++i);
auto trimTrailingSpace = [&expanded]() {
if (expanded.endsWith(QLatin1Char(' ')))
expanded.chop(1);
};
switch (code.toLatin1()) {
case '%':
expanded.append(QLatin1Char('%'));
break;
case 'f':
case 'F':
case 'u':
case 'U':
case 'd':
case 'D':
case 'n':
case 'N':
case 'v':
case 'm':
// File/URL/deprecated codes are omitted when launching without arguments.
trimTrailingSpace();
break;
case 'c':
expanded.append(QLatin1Char('"'));
expanded.append(entry.name);
expanded.append(QLatin1Char('"'));
break;
case 'k':
expanded.append(QLatin1Char('"'));
expanded.append(entry.filePath);
expanded.append(QLatin1Char('"'));
break;
case 'i':
if (!entry.icon.isEmpty()) {
expanded.append(QStringLiteral("--icon \""));
expanded.append(entry.icon);
expanded.append(QLatin1Char('"'));
} else {
trimTrailingSpace();
}
break;
default:
trimTrailingSpace();
break;
}
}
const QStringList parts = QProcess::splitCommand(expanded.trimmed());
return parts;
}
bool DesktopEntry::isLauncherVisible() const
{
if (hidden || noDisplay)
return false;
if (exec.trimmed().isEmpty())
return false;
if (!tryExec.trimmed().isEmpty() && !executableAvailable(tryExec.trimmed()))
return false;
return true;
}
bool DesktopEntry::launch() const
{
QStringList args = expandExec(exec, *this);
if (args.isEmpty()) {
qWarning("nebula: cannot launch %s: empty Exec", qPrintable(desktopId));
return false;
}
QString program = args.takeFirst();
if (program.isEmpty()) {
qWarning("nebula: cannot launch %s: missing program", qPrintable(desktopId));
return false;
}
if (terminal) {
const QString term = findTerminalEmulator();
if (term.isEmpty()) {
qWarning("nebula: cannot launch %s: Terminal=true but no terminal emulator was found",
qPrintable(desktopId));
return false;
}
QStringList wrapped;
wrapped.append(QStringLiteral("-e"));
wrapped.append(program);
wrapped.append(args);
args = std::move(wrapped);
program = term;
}
const QString cwd = workingDirectory.isEmpty() ? QDir::homePath() : workingDirectory;
qint64 pid = 0;
if (!QProcess::startDetached(program, args, cwd, &pid)) {
qWarning("nebula: failed to launch %s (%s)", qPrintable(desktopId), qPrintable(program));
return false;
}
qInfo("nebula: launched %s (%s) pid %lld", qPrintable(desktopId), qPrintable(program), static_cast<long long>(pid));
return true;
}
DesktopEntry parseDesktopEntryFile(const QString &filePath, const QString &desktopId)
{
DesktopEntry entry;
entry.filePath = filePath;
entry.desktopId = desktopId;
QFile file(filePath);
if (!file.open(QIODevice::ReadOnly | QIODevice::Text))
return entry;
LocalizedValue names;
LocalizedValue genericNames;
LocalizedValue comments;
QString type = QStringLiteral("Application");
QStringList onlyShowIn;
QStringList notShowIn;
bool inDesktopEntry = false;
while (!file.atEnd()) {
QString line = QString::fromUtf8(file.readLine());
if (!line.isEmpty() && line.back() == QLatin1Char('\n'))
line.chop(1);
if (!line.isEmpty() && line.back() == QLatin1Char('\r'))
line.chop(1);
const QString trimmed = line.trimmed();
if (trimmed.isEmpty() || trimmed.startsWith(QLatin1Char('#')))
continue;
if (trimmed.startsWith(QLatin1Char('[')) && trimmed.endsWith(QLatin1Char(']'))) {
inDesktopEntry = (trimmed == QLatin1String("[Desktop Entry]"));
continue;
}
if (!inDesktopEntry)
continue;
const int equals = trimmed.indexOf(QLatin1Char('='));
if (equals <= 0)
continue;
const QString key = trimmed.left(equals).trimmed();
const QString value = unquote(trimmed.mid(equals + 1));
if (key == QLatin1String("Type"))
type = value;
else if (key == QLatin1String("Exec"))
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"))
entry.workingDirectory = value;
else if (key == QLatin1String("Categories"))
entry.categories = splitDesktopList(value);
else if (key == QLatin1String("Terminal"))
entry.terminal = parseBool(value);
else if (key == QLatin1String("Hidden"))
entry.hidden = parseBool(value);
else if (key == QLatin1String("NoDisplay"))
entry.noDisplay = parseBool(value);
else if (key == QLatin1String("OnlyShowIn"))
onlyShowIn = splitDesktopList(value);
else if (key == QLatin1String("NotShowIn"))
notShowIn = splitDesktopList(value);
else {
storeLocalized(&names, key, QStringLiteral("Name"), value);
storeLocalized(&genericNames, key, QStringLiteral("GenericName"), value);
storeLocalized(&comments, key, QStringLiteral("Comment"), value);
}
}
const QLocale locale = QLocale::system();
entry.name = resolveLocalized(names, locale);
entry.genericName = resolveLocalized(genericNames, locale);
entry.comment = resolveLocalized(comments, locale);
entry.icon = resolveIconName(entry.icon);
if (entry.desktopId.isEmpty())
entry.desktopId = QFileInfo(filePath).fileName();
if (type != QLatin1String("Application")) {
entry.hidden = true;
return entry;
}
const QStringList desktops = currentDesktopIdentifiers();
if (!onlyShowIn.isEmpty()) {
bool matched = false;
for (const QString &desktop : desktops) {
if (onlyShowIn.contains(desktop)) {
matched = true;
break;
}
}
if (!matched)
entry.noDisplay = true;
}
for (const QString &desktop : desktops) {
if (notShowIn.contains(desktop)) {
entry.noDisplay = true;
break;
}
}
return entry;
}
+32
View File
@@ -0,0 +1,32 @@
#pragma once
#include <QString>
#include <QStringList>
struct DesktopEntry
{
QString desktopId;
QString filePath;
QString name;
QString genericName;
QString comment;
QString icon;
QString exec;
QString tryExec;
QString startupWmClass;
QString workingDirectory;
QStringList categories;
bool terminal = false;
bool hidden = false;
bool noDisplay = false;
bool isLauncherVisible() const;
bool launch() const;
};
DesktopEntry parseDesktopEntryFile(const QString &filePath, const QString &desktopId);
QStringList applicationDirectories();
QStringList currentDesktopIdentifiers();
QStringList splitDesktopList(const QString &value);
QStringList expandExec(const QString &exec, const DesktopEntry &entry);
QString resolveIconName(const QString &icon);
+19
View File
@@ -0,0 +1,19 @@
# Nebula Applications
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. 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.
QML module URI: `Nebula.Applications`.
See `shells/desktop/README.md` for launcher behaviour and session integration.
+63
View File
@@ -0,0 +1,63 @@
cmake_minimum_required(VERSION 3.20)
if(NOT TARGET Qt6::Qml)
project(NebulaWindows LANGUAGES CXX)
find_package(Qt6 6.4 REQUIRED COMPONENTS Core Gui Qml Network)
qt_standard_project_setup(REQUIRES 6.4)
endif()
if(NOT TARGET Qt6::Network)
find_package(Qt6 6.4 REQUIRED COMPONENTS Network)
endif()
if(NOT TARGET NebulaApplications)
add_subdirectory(
"${CMAKE_CURRENT_SOURCE_DIR}/../applications"
"${CMAKE_CURRENT_BINARY_DIR}/nebula-applications"
)
endif()
qt_add_library(NebulaWindows STATIC)
qt_add_qml_module(NebulaWindows
URI Nebula.Windows
VERSION 1.0
RESOURCE_PREFIX /qt/qml
SOURCES
WindowTypes.hpp
WindowBackend.hpp
WindowModel.hpp
WindowModel.cpp
WorkspaceModel.hpp
WorkspaceModel.cpp
WindowService.hpp
WindowService.cpp
backends/sway/SwayIpcClient.hpp
backends/sway/SwayIpcClient.cpp
backends/sway/SwayWindowBackend.hpp
backends/sway/SwayWindowBackend.cpp
)
target_include_directories(NebulaWindows
PUBLIC
"${CMAKE_CURRENT_SOURCE_DIR}"
PRIVATE
"${CMAKE_CURRENT_SOURCE_DIR}/backends/sway"
)
target_link_libraries(NebulaWindows
PUBLIC
Qt6::Core
Qt6::Gui
Qt6::Qml
Qt6::Network
NebulaApplications
)
set_target_properties(NebulaWindows PROPERTIES
AUTOMOC ON
)
if(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang")
target_compile_options(NebulaWindows PRIVATE -Wall -Wextra)
endif()
+41
View File
@@ -0,0 +1,41 @@
# Nebula Windows
Shared C++ window-management service. Desktop, the future Bigscreen shell, and a future Nebula compositor backend should all talk to this library rather than to a compositor directly.
```text
Nebula QML
WindowService
generic WindowBackend
Sway IPC now
Nebula compositor later
```
QML must not run `swaymsg`. Sway-specific protocol, socket handling, and tree parsing stay in `backends/sway/`.
## Behaviour
- Connects to the compositor IPC socket from `SWAYSOCK`. The path is never hard-coded.
- If IPC is missing or fails, the shell keeps running and WindowService stays empty. A development warning is logged.
- Subscribes to `window`, `workspace`, and `output` events instead of polling.
- Exposes `WindowModel` (`QAbstractListModel`) and `WorkspaceModel`.
- Tracks the focused *normal application* window. Layer-shell chrome (desktop, top bar, launcher) is not treated as the active app.
- Resolves a user-facing name via `ApplicationService` when possible, then a small identifier humanizer (`footclient` → Foot, `org.mozilla.firefox` → Firefox).
QML module URI: `Nebula.Windows`.
## Operations
`focusWindow`, `closeWindow`, `maximizeWindow`, `restoreWindow`, `setWindowFloating`, plus `moveWindow` / `resizeWindow` / `snapWindow` / `switchWorkspace`.
`closeWindow` asks the compositor to close the client. It does not kill the process.
Maximise fills the current workspace usable area (below the Nebula exclusive zone) using compositor-relative sizing. Restore returns the previous floating geometry when WindowService saved it.
Minimise is implemented for the Sway backend using scratchpad semantics, but the UI must not mention scratchpad. The user-facing idea is only hide / show.
Snap edges (`SnapLeft`, `SnapRight`, `SnapMaximize`) are prepared for a later Snap Layouts UI.
See `shells/desktop/README.md` and `compositor/README.md` for session behaviour.
+48
View File
@@ -0,0 +1,48 @@
#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;
virtual bool updateFocusedGeometry() { return false; }
signals:
void connectedChanged();
void windowsChanged();
void workspacesChanged();
void focusChanged();
};
+114
View File
@@ -0,0 +1,114 @@
#include "WindowModel.hpp"
#include <utility>
WindowModel::WindowModel(QObject *parent)
: QAbstractListModel(parent)
{
}
int WindowModel::rowCount(const QModelIndex &parent) const
{
if (parent.isValid())
return 0;
return m_windows.size();
}
int WindowModel::count() const
{
return m_windows.size();
}
QVariant WindowModel::data(const QModelIndex &index, int role) const
{
if (!index.isValid() || index.row() < 0 || index.row() >= m_windows.size())
return {};
const WindowInfo &window = m_windows.at(index.row());
switch (role) {
case WindowIdRole:
return QString::number(window.id);
case TitleRole:
return window.title;
case AppIdRole:
return window.appId;
case WindowClassRole:
return window.windowClass;
case WorkspaceRole:
return window.workspace;
case FocusedRole:
return window.focused;
case FloatingRole:
return window.floating;
case FullscreenRole:
return window.fullscreen;
case XRole:
return window.x;
case YRole:
return window.y;
case WidthRole:
return window.width;
case HeightRole:
return window.height;
case DisplayNameRole:
case Qt::DisplayRole:
return window.displayName.isEmpty() ? window.title : window.displayName;
case IconNameRole:
return window.iconName;
case MinimizedRole:
return window.minimized;
case MaximizedRole:
return window.maximized;
case OutputRole:
return window.output;
default:
return {};
}
}
QHash<int, QByteArray> WindowModel::roleNames() const
{
return {
{WindowIdRole, QByteArrayLiteral("windowId")},
{TitleRole, QByteArrayLiteral("title")},
{AppIdRole, QByteArrayLiteral("appId")},
{WindowClassRole, QByteArrayLiteral("windowClass")},
{WorkspaceRole, QByteArrayLiteral("workspace")},
{FocusedRole, QByteArrayLiteral("focused")},
{FloatingRole, QByteArrayLiteral("floating")},
{FullscreenRole, QByteArrayLiteral("fullscreen")},
{XRole, QByteArrayLiteral("x")},
{YRole, QByteArrayLiteral("y")},
{WidthRole, QByteArrayLiteral("width")},
{HeightRole, QByteArrayLiteral("height")},
{DisplayNameRole, QByteArrayLiteral("displayName")},
{IconNameRole, QByteArrayLiteral("iconName")},
{MinimizedRole, QByteArrayLiteral("minimized")},
{MaximizedRole, QByteArrayLiteral("maximized")},
{OutputRole, QByteArrayLiteral("output")},
};
}
void WindowModel::setWindows(QVector<WindowInfo> windows)
{
beginResetModel();
m_windows = std::move(windows);
endResetModel();
emit countChanged();
}
const WindowInfo *WindowModel::windowAt(int row) const
{
if (row < 0 || row >= m_windows.size())
return nullptr;
return &m_windows.at(row);
}
const WindowInfo *WindowModel::windowById(qint64 windowId) const
{
for (const WindowInfo &window : m_windows) {
if (window.id == windowId)
return &window;
}
return nullptr;
}
+54
View File
@@ -0,0 +1,54 @@
#pragma once
#include "WindowTypes.hpp"
#include <QAbstractListModel>
#include <QVector>
#include <QtQml/qqmlregistration.h>
class WindowModel : public QAbstractListModel
{
Q_OBJECT
QML_ELEMENT
QML_UNCREATABLE("Obtained from WindowService")
Q_PROPERTY(int count READ count NOTIFY countChanged)
public:
enum Roles {
WindowIdRole = Qt::UserRole + 1,
TitleRole,
AppIdRole,
WindowClassRole,
WorkspaceRole,
FocusedRole,
FloatingRole,
FullscreenRole,
XRole,
YRole,
WidthRole,
HeightRole,
DisplayNameRole,
IconNameRole,
MinimizedRole,
MaximizedRole,
OutputRole
};
Q_ENUM(Roles)
explicit WindowModel(QObject *parent = nullptr);
int rowCount(const QModelIndex &parent = QModelIndex()) const override;
QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override;
QHash<int, QByteArray> roleNames() const override;
int count() const;
void setWindows(QVector<WindowInfo> windows);
const WindowInfo *windowAt(int row) const;
const WindowInfo *windowById(qint64 windowId) const;
signals:
void countChanged();
private:
QVector<WindowInfo> m_windows;
};
+490
View File
@@ -0,0 +1,490 @@
#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))
, m_geometryTimer(new QTimer(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);
m_geometryTimer->setInterval(33);
connect(m_geometryTimer, &QTimer::timeout, this, &WindowService::syncFocusedGeometry);
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();
}
bool WindowService::hasFocusedWindow() const
{
return !m_focusedWindowId.isEmpty();
}
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::focusedWindowMaximized() const
{
return m_focusedWindowMaximized;
}
bool WindowService::focusedWindowFullscreen() const
{
return m_focusedWindowFullscreen;
}
int WindowService::focusedWindowX() const
{
return m_focusedWindowX;
}
int WindowService::focusedWindowY() const
{
return m_focusedWindowY;
}
int WindowService::focusedWindowWidth() const
{
return m_focusedWindowWidth;
}
int WindowService::focusedWindowHeight() const
{
return m_focusedWindowHeight;
}
int WindowService::focusedWindowDecoX() const
{
return m_focusedWindowDecoX;
}
int WindowService::focusedWindowDecoY() const
{
return m_focusedWindowDecoY;
}
int WindowService::focusedWindowDecoWidth() const
{
return m_focusedWindowDecoWidth;
}
int WindowService::focusedWindowDecoHeight() const
{
return m_focusedWindowDecoHeight;
}
int WindowService::focusedWindowTitleBarY() const
{
return m_focusedWindowTitleBarY;
}
int WindowService::focusedWindowTitleBarHeight() const
{
return m_focusedWindowTitleBarHeight;
}
int WindowService::focusedWorkspaceX() const
{
return m_focusedWorkspaceX;
}
int WindowService::focusedWorkspaceY() const
{
return m_focusedWorkspaceY;
}
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::minimizeFocusedWindow()
{
if (m_focusedWindowId.isEmpty())
return false;
return minimizeWindow(m_focusedWindowId);
}
bool WindowService::toggleMaximizeFocusedWindow()
{
if (m_focusedWindowId.isEmpty())
return false;
const bool restore = m_focusedWindowMaximized;
const bool ok = restore ? restoreWindow(m_focusedWindowId)
: maximizeWindow(m_focusedWindowId);
if (!ok)
return false;
if (m_focusedWindowMaximized == restore) {
m_focusedWindowMaximized = !restore;
emit focusChanged();
}
return true;
}
bool WindowService::closeFocusedWindow()
{
if (m_focusedWindowId.isEmpty())
return false;
return closeWindow(m_focusedWindowId);
}
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;
bool maximized = false;
const WindowInfo *focusedInfo = nullptr;
if (const WindowInfo *focused = m_model->windowById(focusedId)) {
if (!focused->minimized)
focusedInfo = focused;
}
if (!focusedInfo && !m_focusedWindowId.isEmpty()) {
const WindowInfo *previous = m_model->windowById(parseWindowId(m_focusedWindowId));
if (previous && !previous->minimized)
focusedInfo = previous;
}
if (focusedInfo) {
windowId = QString::number(focusedInfo->id);
title = focusedInfo->title;
appId = focusedInfo->appId.isEmpty() ? focusedInfo->windowClass : focusedInfo->appId;
appName = focusedInfo->displayName;
maximized = focusedInfo->maximized;
}
if (m_focusedWindowId != windowId
|| m_focusedWindowTitle != title
|| m_focusedApplicationId != appId
|| m_focusedApplicationName != appName
|| m_focusedWindowMaximized != maximized) {
m_focusedWindowId = windowId;
m_focusedWindowTitle = title;
m_focusedApplicationId = appId;
m_focusedApplicationName = appName;
m_focusedWindowMaximized = maximized;
emit focusChanged();
}
publishFocusedGeometry(focusedInfo);
if (hasFocusedWindow()) {
if (!m_geometryTimer->isActive())
m_geometryTimer->start();
} else if (m_geometryTimer->isActive()) {
m_geometryTimer->stop();
}
}
void WindowService::syncFocusedGeometry()
{
if (m_focusedWindowId.isEmpty()) {
m_geometryTimer->stop();
publishFocusedGeometry(nullptr);
return;
}
m_backend->updateFocusedGeometry();
const QVector<WindowInfo> windows = m_backend->windows();
const qint64 id = parseWindowId(m_focusedWindowId);
const WindowInfo *focused = nullptr;
for (const WindowInfo &window : windows) {
if (window.id == id) {
focused = &window;
break;
}
}
publishFocusedGeometry(focused);
}
void WindowService::publishFocusedGeometry(const WindowInfo *focused)
{
int x = 0;
int y = 0;
int width = 0;
int height = 0;
int decoX = 0;
int decoY = 0;
int decoWidth = 0;
int decoHeight = 0;
int titleBarY = 0;
int titleBarHeight = 0;
int workspaceX = 0;
int workspaceY = 0;
bool fullscreen = false;
if (focused && !focused->minimized) {
x = focused->x;
y = focused->y;
width = focused->width;
height = focused->height;
decoX = focused->decoX;
decoY = focused->decoY;
decoWidth = focused->decoWidth;
decoHeight = focused->decoHeight;
fullscreen = focused->fullscreen;
titleBarHeight = focused->decoHeight;
if (titleBarHeight <= 0)
titleBarHeight = focused->contentY;
if (titleBarHeight < 16 || titleBarHeight > 48)
titleBarHeight = 32;
// Frame rect from the floating container starts at the title bar.
titleBarY = focused->y + focused->decoY;
const WorkspaceInfo *workspace = m_workspaces->workspaceByName(focused->workspace);
if (!workspace)
workspace = m_workspaces->focusedWorkspace();
if (workspace) {
workspaceX = workspace->x;
workspaceY = workspace->y;
}
}
if (m_focusedWindowFullscreen != fullscreen) {
m_focusedWindowFullscreen = fullscreen;
emit focusChanged();
}
if (m_focusedWindowX != x
|| m_focusedWindowY != y
|| m_focusedWindowWidth != width
|| m_focusedWindowHeight != height
|| m_focusedWindowDecoX != decoX
|| m_focusedWindowDecoY != decoY
|| m_focusedWindowDecoWidth != decoWidth
|| m_focusedWindowDecoHeight != decoHeight
|| m_focusedWindowTitleBarY != titleBarY
|| m_focusedWindowTitleBarHeight != titleBarHeight
|| m_focusedWorkspaceX != workspaceX
|| m_focusedWorkspaceY != workspaceY) {
m_focusedWindowX = x;
m_focusedWindowY = y;
m_focusedWindowWidth = width;
m_focusedWindowHeight = height;
m_focusedWindowDecoX = decoX;
m_focusedWindowDecoY = decoY;
m_focusedWindowDecoWidth = decoWidth;
m_focusedWindowDecoHeight = decoHeight;
m_focusedWindowTitleBarY = titleBarY;
m_focusedWindowTitleBarHeight = titleBarHeight;
m_focusedWorkspaceX = workspaceX;
m_focusedWorkspaceY = workspaceY;
emit focusedWindowGeometryChanged();
}
}
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(' '));
}
+134
View File
@@ -0,0 +1,134 @@
#pragma once
#include "WindowBackend.hpp"
#include "WindowModel.hpp"
#include "WorkspaceModel.hpp"
#include <QJSEngine>
#include <QObject>
#include <QQmlEngine>
#include <QTimer>
#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(bool hasFocusedWindow READ hasFocusedWindow NOTIFY focusChanged)
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)
Q_PROPERTY(bool focusedWindowMaximized READ focusedWindowMaximized NOTIFY focusChanged)
Q_PROPERTY(bool focusedWindowFullscreen READ focusedWindowFullscreen NOTIFY focusChanged)
Q_PROPERTY(int focusedWindowX READ focusedWindowX NOTIFY focusedWindowGeometryChanged)
Q_PROPERTY(int focusedWindowY READ focusedWindowY NOTIFY focusedWindowGeometryChanged)
Q_PROPERTY(int focusedWindowWidth READ focusedWindowWidth NOTIFY focusedWindowGeometryChanged)
Q_PROPERTY(int focusedWindowHeight READ focusedWindowHeight NOTIFY focusedWindowGeometryChanged)
Q_PROPERTY(int focusedWindowDecoX READ focusedWindowDecoX NOTIFY focusedWindowGeometryChanged)
Q_PROPERTY(int focusedWindowDecoY READ focusedWindowDecoY NOTIFY focusedWindowGeometryChanged)
Q_PROPERTY(int focusedWindowDecoWidth READ focusedWindowDecoWidth NOTIFY focusedWindowGeometryChanged)
Q_PROPERTY(int focusedWindowDecoHeight READ focusedWindowDecoHeight NOTIFY focusedWindowGeometryChanged)
Q_PROPERTY(int focusedWindowTitleBarY READ focusedWindowTitleBarY NOTIFY focusedWindowGeometryChanged)
Q_PROPERTY(int focusedWindowTitleBarHeight READ focusedWindowTitleBarHeight NOTIFY focusedWindowGeometryChanged)
Q_PROPERTY(int focusedWorkspaceX READ focusedWorkspaceX NOTIFY focusedWindowGeometryChanged)
Q_PROPERTY(int focusedWorkspaceY READ focusedWorkspaceY NOTIFY focusedWindowGeometryChanged)
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;
bool hasFocusedWindow() const;
QString focusedWindowId() const;
QString focusedWindowTitle() const;
QString focusedApplicationId() const;
QString focusedApplicationName() const;
bool focusedWindowMaximized() const;
bool focusedWindowFullscreen() const;
int focusedWindowX() const;
int focusedWindowY() const;
int focusedWindowWidth() const;
int focusedWindowHeight() const;
int focusedWindowDecoX() const;
int focusedWindowDecoY() const;
int focusedWindowDecoWidth() const;
int focusedWindowDecoHeight() const;
int focusedWindowTitleBarY() const;
int focusedWindowTitleBarHeight() const;
int focusedWorkspaceX() const;
int focusedWorkspaceY() 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 minimizeFocusedWindow();
Q_INVOKABLE bool toggleMaximizeFocusedWindow();
Q_INVOKABLE bool closeFocusedWindow();
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();
void focusedWindowGeometryChanged();
private:
void syncFromBackend();
void syncFocusedGeometry();
void publishFocusedGeometry(const WindowInfo *focused);
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;
bool m_focusedWindowMaximized = false;
bool m_focusedWindowFullscreen = false;
int m_focusedWindowX = 0;
int m_focusedWindowY = 0;
int m_focusedWindowWidth = 0;
int m_focusedWindowHeight = 0;
int m_focusedWindowDecoX = 0;
int m_focusedWindowDecoY = 0;
int m_focusedWindowDecoWidth = 0;
int m_focusedWindowDecoHeight = 0;
int m_focusedWindowTitleBarY = 0;
int m_focusedWindowTitleBarHeight = 0;
int m_focusedWorkspaceX = 0;
int m_focusedWorkspaceY = 0;
QTimer *m_geometryTimer = nullptr;
};
+46
View File
@@ -0,0 +1,46 @@
#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;
int decoX = 0;
int decoY = 0;
int decoWidth = 0;
int decoHeight = 0;
int contentX = 0;
int contentY = 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;
};
+95
View File
@@ -0,0 +1,95 @@
#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;
}
const WorkspaceInfo *WorkspaceModel::workspaceByName(const QString &name) const
{
if (name.isEmpty())
return nullptr;
for (const WorkspaceInfo &workspace : m_workspaces) {
if (workspace.name == name)
return &workspace;
}
return nullptr;
}
+45
View File
@@ -0,0 +1,45 @@
#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;
const WorkspaceInfo *workspaceByName(const QString &name) 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,579 @@
#include "SwayWindowBackend.hpp"
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QJsonValue>
#include <QVariant>
#include <QtGlobal>
namespace {
constexpr int kMaxConnectAttempts = 8;
QString quoteCriteriaValue(const QString &value)
{
QString escaped = value;
escaped.replace(QLatin1Char('\\'), QStringLiteral("\\\\"));
escaped.replace(QLatin1Char('"'), QStringLiteral("\\\""));
return escaped;
}
bool isScratchpadWorkspace(const QString &name)
{
return name == QLatin1String("__i3_scratch");
}
bool isPseudoOutput(const QString &name)
{
return name == QLatin1String("__i3");
}
qint64 jsonId(const QJsonValue &value)
{
if (value.isDouble())
return static_cast<qint64>(value.toDouble());
return value.toVariant().toLongLong();
}
QString floatingState(const QJsonObject &node)
{
return node.value(QStringLiteral("floating")).toString();
}
bool nodeIsFloating(const QJsonObject &node)
{
const QString state = floatingState(node);
if (state == QLatin1String("user_on") || state == QLatin1String("auto_on"))
return true;
return node.value(QStringLiteral("type")).toString() == QLatin1String("floating_con");
}
bool nodeLooksLikeView(const QJsonObject &node)
{
const int pid = node.value(QStringLiteral("pid")).toInt();
if (pid <= 0)
return false;
const QString appId = node.value(QStringLiteral("app_id")).toString();
if (!appId.isEmpty())
return true;
if (node.contains(QStringLiteral("window")) && !node.value(QStringLiteral("window")).isNull())
return true;
const QJsonObject properties = node.value(QStringLiteral("window_properties")).toObject();
return !properties.isEmpty();
}
void applyFrameGeometry(WindowInfo &window, const QJsonObject &node, const QJsonObject &frame)
{
const bool useFrame = !frame.isEmpty()
&& (frame.value(QStringLiteral("type")).toString() == QLatin1String("floating_con")
|| nodeIsFloating(frame));
const QJsonObject &source = useFrame ? frame : node;
const QJsonObject rect = source.value(QStringLiteral("rect")).toObject();
const QJsonObject deco = source.value(QStringLiteral("deco_rect")).toObject();
const QJsonObject content = source.value(QStringLiteral("window_rect")).toObject();
window.x = rect.value(QStringLiteral("x")).toInt();
window.y = rect.value(QStringLiteral("y")).toInt();
window.width = rect.value(QStringLiteral("width")).toInt();
window.height = rect.value(QStringLiteral("height")).toInt();
window.decoX = deco.value(QStringLiteral("x")).toInt();
window.decoY = deco.value(QStringLiteral("y")).toInt();
window.decoWidth = deco.value(QStringLiteral("width")).toInt();
window.decoHeight = deco.value(QStringLiteral("height")).toInt();
window.contentX = content.value(QStringLiteral("x")).toInt();
window.contentY = content.value(QStringLiteral("y")).toInt();
}
} // namespace
SwayWindowBackend::SwayWindowBackend(QObject *parent)
: WindowBackend(parent)
, m_ipc(new SwayIpcClient(this))
, m_retryTimer(new QTimer(this))
{
m_retryTimer->setSingleShot(true);
connect(m_retryTimer, &QTimer::timeout, this, &SwayWindowBackend::tryConnect);
connect(m_ipc, &SwayIpcClient::eventReceived, this, &SwayWindowBackend::handleEvent);
connect(m_ipc, &SwayIpcClient::connectedChanged, this, &SwayWindowBackend::connectedChanged);
connect(m_ipc, &SwayIpcClient::disconnected, this, [this]() {
m_windows.clear();
m_workspaces.clear();
m_focusedWindowId = 0;
emit windowsChanged();
emit workspacesChanged();
emit focusChanged();
});
tryConnect();
}
bool SwayWindowBackend::isConnected() const
{
return m_ipc->isConnected();
}
QVector<WindowInfo> SwayWindowBackend::windows() const
{
return m_windows;
}
QVector<WorkspaceInfo> SwayWindowBackend::workspaces() const
{
return m_workspaces;
}
qint64 SwayWindowBackend::focusedWindowId() const
{
return m_focusedWindowId;
}
bool SwayWindowBackend::focusWindow(qint64 windowId)
{
if (m_minimized.contains(windowId) || (findWindow(windowId) && findWindow(windowId)->minimized)) {
if (!run(QStringLiteral("[con_id=%1] scratchpad show").arg(windowId)))
return false;
m_minimized.remove(windowId);
}
return run(QStringLiteral("[con_id=%1] focus").arg(windowId));
}
bool SwayWindowBackend::closeWindow(qint64 windowId)
{
// `kill` asks the client to close; it does not SIGKILL the process.
const bool ok = run(QStringLiteral("[con_id=%1] kill").arg(windowId));
m_savedGeometry.remove(windowId);
m_maximized.remove(windowId);
m_minimized.remove(windowId);
return ok;
}
bool SwayWindowBackend::maximizeWindow(qint64 windowId)
{
saveGeometry(windowId);
m_maximized.insert(windowId);
m_minimized.remove(windowId);
return run(QStringLiteral("[con_id=%1] floating enable, fullscreen disable, "
"resize set width 100 ppt height 100 ppt, move position 0 ppt 0 ppt")
.arg(windowId));
}
bool SwayWindowBackend::restoreWindow(qint64 windowId)
{
if (m_minimized.contains(windowId) || (findWindow(windowId) && findWindow(windowId)->minimized)) {
const bool ok = run(QStringLiteral("[con_id=%1] scratchpad show, floating enable").arg(windowId));
m_minimized.remove(windowId);
if (!ok)
return false;
if (!m_maximized.contains(windowId) && m_savedGeometry.contains(windowId)) {
const QRect rect = m_savedGeometry.value(windowId);
return run(QStringLiteral("[con_id=%1] resize set width %2 px height %3 px, "
"move absolute position %4 px %5 px")
.arg(windowId)
.arg(rect.width())
.arg(rect.height())
.arg(rect.x())
.arg(rect.y()));
}
return true;
}
if (m_savedGeometry.contains(windowId)) {
const QRect rect = m_savedGeometry.take(windowId);
m_maximized.remove(windowId);
return run(QStringLiteral("[con_id=%1] floating enable, fullscreen disable, "
"resize set width %2 px height %3 px, "
"move absolute position %4 px %5 px")
.arg(windowId)
.arg(rect.width())
.arg(rect.height())
.arg(rect.x())
.arg(rect.y()));
}
m_maximized.remove(windowId);
return run(QStringLiteral("[con_id=%1] floating enable, fullscreen disable, "
"resize set width 70 ppt height 70 ppt, move position center")
.arg(windowId));
}
bool SwayWindowBackend::minimizeWindow(qint64 windowId)
{
saveGeometry(windowId);
m_minimized.insert(windowId);
return run(QStringLiteral("[con_id=%1] move scratchpad").arg(windowId));
}
bool SwayWindowBackend::setWindowFloating(qint64 windowId, bool floating)
{
return run(QStringLiteral("[con_id=%1] floating %2")
.arg(windowId)
.arg(floating ? QStringLiteral("enable") : QStringLiteral("disable")));
}
bool SwayWindowBackend::moveWindow(qint64 windowId, int x, int y)
{
return run(QStringLiteral("[con_id=%1] move absolute position %2 px %3 px")
.arg(windowId)
.arg(x)
.arg(y));
}
bool SwayWindowBackend::resizeWindow(qint64 windowId, int width, int height)
{
if (width < 1 || height < 1)
return false;
return run(QStringLiteral("[con_id=%1] resize set width %2 px height %3 px")
.arg(windowId)
.arg(width)
.arg(height));
}
bool SwayWindowBackend::snapWindow(qint64 windowId, SnapEdge edge)
{
saveGeometry(windowId);
m_minimized.remove(windowId);
if (edge == SnapMaximize) {
m_maximized.insert(windowId);
return run(QStringLiteral("[con_id=%1] floating enable, fullscreen disable, "
"resize set width 100 ppt height 100 ppt, move position 0 ppt 0 ppt")
.arg(windowId));
}
m_maximized.remove(windowId);
const QString x = (edge == SnapRight) ? QStringLiteral("50") : QStringLiteral("0");
return run(QStringLiteral("[con_id=%1] floating enable, fullscreen disable, "
"resize set width 50 ppt height 100 ppt, move position %2 ppt 0 ppt")
.arg(windowId)
.arg(x));
}
bool SwayWindowBackend::switchWorkspace(const QString &workspaceId)
{
if (workspaceId.isEmpty())
return false;
return run(QStringLiteral("workspace \"%1\"").arg(quoteCriteriaValue(workspaceId)));
}
bool SwayWindowBackend::updateFocusedGeometry()
{
if (!m_ipc->isConnected() || m_focusedWindowId == 0)
return false;
const QJsonDocument tree = m_ipc->request(SwayIpcClient::GetTree);
if (!tree.isObject())
return false;
QVector<WindowInfo> windows;
QVector<WorkspaceInfo> workspaces;
collect(tree.object(), {}, {}, {}, false, &windows, &workspaces);
for (WindowInfo &window : windows) {
if (window.id != m_focusedWindowId)
continue;
for (WindowInfo &cached : m_windows) {
if (cached.id != window.id)
continue;
if (cached.x == window.x
&& cached.y == window.y
&& cached.width == window.width
&& cached.height == window.height
&& cached.decoX == window.decoX
&& cached.decoY == window.decoY
&& cached.decoWidth == window.decoWidth
&& cached.decoHeight == window.decoHeight
&& cached.contentX == window.contentX
&& cached.contentY == window.contentY
&& cached.fullscreen == window.fullscreen)
return false;
cached.x = window.x;
cached.y = window.y;
cached.width = window.width;
cached.height = window.height;
cached.decoX = window.decoX;
cached.decoY = window.decoY;
cached.decoWidth = window.decoWidth;
cached.decoHeight = window.decoHeight;
cached.contentX = window.contentX;
cached.contentY = window.contentY;
cached.fullscreen = window.fullscreen;
return true;
}
break;
}
return false;
}
void SwayWindowBackend::tryConnect()
{
if (m_ipc->isConnected())
return;
const QString path = SwayIpcClient::socketPath();
if (path.isEmpty()) {
qWarning("nebula: SWAYSOCK is unset; WindowService will continue without compositor IPC");
return;
}
++m_connectAttempts;
if (!m_ipc->connectToSway()) {
if (m_connectAttempts < kMaxConnectAttempts) {
const int delay = 150 * m_connectAttempts;
qInfo("nebula: Sway IPC not ready, retrying in %d ms (%d/%d)",
delay, m_connectAttempts, kMaxConnectAttempts);
m_retryTimer->start(delay);
return;
}
qWarning("nebula: could not connect to Sway IPC at %s; window management is unavailable",
qPrintable(path));
return;
}
if (!m_ipc->subscribe({QStringLiteral("window"), QStringLiteral("workspace"), QStringLiteral("output")})) {
qWarning("nebula: Sway IPC subscribe failed; window management is unavailable");
m_ipc->disconnectFromSway();
return;
}
qInfo("nebula: WindowService connected to Sway IPC");
refresh();
}
void SwayWindowBackend::refresh()
{
if (!m_ipc->isConnected())
return;
const QJsonDocument tree = m_ipc->request(SwayIpcClient::GetTree);
if (!tree.isObject())
return;
QVector<WindowInfo> windows;
QVector<WorkspaceInfo> workspaces;
collect(tree.object(), {}, {}, {}, false, &windows, &workspaces);
QHash<QString, int> counts;
qint64 focused = 0;
for (WindowInfo &window : windows) {
window.maximized = m_maximized.contains(window.id);
if (window.minimized)
m_minimized.insert(window.id);
else
m_minimized.remove(window.id);
if (!window.minimized)
counts[window.workspace] += 1;
if (window.focused && !window.minimized)
focused = window.id;
}
for (WorkspaceInfo &workspace : workspaces) {
workspace.windowCount = counts.value(workspace.name, 0);
if (workspace.focused)
continue;
for (const WindowInfo &window : windows) {
if (window.focused && !window.minimized && window.workspace == workspace.name) {
workspace.focused = true;
workspace.visible = true;
break;
}
}
}
const bool focusDidChange = (focused != m_focusedWindowId);
m_windows = std::move(windows);
m_workspaces = std::move(workspaces);
m_focusedWindowId = focused;
emit windowsChanged();
emit workspacesChanged();
if (focusDidChange)
emit focusChanged();
}
void SwayWindowBackend::handleEvent(quint32 type, const QJsonDocument &payload)
{
if (type == SwayIpcClient::WindowEvent) {
const QJsonObject object = payload.object();
const QString change = object.value(QStringLiteral("change")).toString();
if (change == QLatin1String("new")) {
const qint64 id = jsonId(object.value(QStringLiteral("container")).toObject().value(QStringLiteral("id")));
QTimer::singleShot(0, this, [this, id]() { handleNewWindow(id); });
return;
}
if (change == QLatin1String("close")) {
const qint64 id = jsonId(object.value(QStringLiteral("container")).toObject().value(QStringLiteral("id")));
m_savedGeometry.remove(id);
m_maximized.remove(id);
m_minimized.remove(id);
}
}
if (m_refreshQueued)
return;
m_refreshQueued = true;
QTimer::singleShot(0, this, [this]() {
m_refreshQueued = false;
refresh();
});
}
void SwayWindowBackend::handleNewWindow(qint64 windowId)
{
refresh();
const WindowInfo *window = findWindow(windowId);
if (!window || window->minimized || window->fullscreen)
return;
const int slot = m_cascadeIndex++ % 8;
if (slot == 0)
return;
const int offset = slot * 32;
run(QStringLiteral("[con_id=%1] move right %2 px, move down %3 px")
.arg(windowId)
.arg(offset)
.arg(offset));
}
bool SwayWindowBackend::run(const QString &command)
{
if (!m_ipc->isConnected())
return false;
const QJsonDocument reply = m_ipc->request(SwayIpcClient::RunCommand, command.toUtf8());
const QJsonArray results = reply.array();
bool ok = !results.isEmpty();
for (const QJsonValue &value : results) {
const QJsonObject object = value.toObject();
if (!object.value(QStringLiteral("success")).toBool(true)) {
qWarning("nebula: compositor command failed: %s (%s)",
qPrintable(command),
qPrintable(object.value(QStringLiteral("error")).toString()));
ok = false;
}
}
if (ok && !m_refreshQueued) {
m_refreshQueued = true;
QTimer::singleShot(0, this, [this]() {
m_refreshQueued = false;
refresh();
});
}
return ok;
}
bool SwayWindowBackend::saveGeometry(qint64 windowId)
{
const WindowInfo *window = findWindow(windowId);
if (!window)
return false;
if (!m_savedGeometry.contains(windowId) && !m_maximized.contains(windowId))
m_savedGeometry.insert(windowId, QRect(window->x, window->y, window->width, window->height));
return true;
}
const WindowInfo *SwayWindowBackend::findWindow(qint64 windowId) const
{
for (const WindowInfo &window : m_windows) {
if (window.id == windowId)
return &window;
}
return nullptr;
}
bool SwayWindowBackend::isShellChrome(const QJsonObject &node)
{
const QString appId = node.value(QStringLiteral("app_id")).toString();
if (appId == QLatin1String("org.nebulaos.shell") || appId.startsWith(QLatin1String("nebula-")))
return true;
const QString name = node.value(QStringLiteral("name")).toString();
if (name == QLatin1String("Nebula Desktop")
|| name == QLatin1String("Nebula Top Bar")
|| name == QLatin1String("Nebula Launcher")
|| name == QLatin1String("Nebula Window Chrome")) {
return true;
}
const QString shell = node.value(QStringLiteral("shell")).toString();
if (shell.contains(QLatin1String("layer"), Qt::CaseInsensitive))
return true;
return false;
}
void SwayWindowBackend::collect(const QJsonObject &node,
const QJsonObject &frame,
const QString &workspace,
const QString &output,
bool onScratchpad,
QVector<WindowInfo> *windows,
QVector<WorkspaceInfo> *workspaces)
{
const QString type = node.value(QStringLiteral("type")).toString();
const QString name = node.value(QStringLiteral("name")).toString();
QString nextWorkspace = workspace;
QString nextOutput = output;
bool nextScratchpad = onScratchpad;
if (type == QLatin1String("output")) {
nextOutput = name;
if (isPseudoOutput(name))
nextScratchpad = true;
} else if (type == QLatin1String("workspace")) {
nextWorkspace = name;
nextScratchpad = isScratchpadWorkspace(name);
if (!nextScratchpad) {
WorkspaceInfo info;
info.id = name;
info.name = name;
info.number = node.value(QStringLiteral("num")).toInt(-1);
info.focused = node.value(QStringLiteral("focused")).toBool();
info.visible = node.value(QStringLiteral("visible")).toBool();
info.output = node.value(QStringLiteral("output")).toString(output);
const QJsonObject rect = node.value(QStringLiteral("rect")).toObject();
info.x = rect.value(QStringLiteral("x")).toInt();
info.y = rect.value(QStringLiteral("y")).toInt();
info.width = rect.value(QStringLiteral("width")).toInt();
info.height = rect.value(QStringLiteral("height")).toInt();
workspaces->append(info);
}
}
QJsonObject nextFrame = frame;
if (type == QLatin1String("floating_con"))
nextFrame = node;
if (nodeLooksLikeView(node) && !isShellChrome(node)) {
WindowInfo window;
window.id = jsonId(node.value(QStringLiteral("id")));
window.title = name;
window.appId = node.value(QStringLiteral("app_id")).toString();
const QJsonObject properties = node.value(QStringLiteral("window_properties")).toObject();
window.windowClass = properties.value(QStringLiteral("class")).toString();
if (window.title.isEmpty())
window.title = properties.value(QStringLiteral("title")).toString();
window.workspace = nextWorkspace;
window.output = nextOutput;
window.focused = node.value(QStringLiteral("focused")).toBool();
window.floating = nodeIsFloating(node) || nodeIsFloating(nextFrame);
window.fullscreen = node.value(QStringLiteral("fullscreen_mode")).toInt() > 0;
window.minimized = nextScratchpad;
applyFrameGeometry(window, node, nextFrame);
windows->append(window);
}
const QJsonArray nodes = node.value(QStringLiteral("nodes")).toArray();
for (const QJsonValue &child : nodes)
collect(child.toObject(), nextFrame, nextWorkspace, nextOutput, nextScratchpad, windows, workspaces);
const QJsonArray floating = node.value(QStringLiteral("floating_nodes")).toArray();
for (const QJsonValue &child : floating)
collect(child.toObject(), nextFrame, nextWorkspace, nextOutput, nextScratchpad, windows, workspaces);
}
@@ -0,0 +1,65 @@
#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;
bool updateFocusedGeometry() 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 QJsonObject &frame,
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;
};
View File
+42
View File
@@ -0,0 +1,42 @@
cmake_minimum_required(VERSION 3.20)
if(NOT TARGET Qt6::Quick)
project(NebulaUI LANGUAGES CXX)
find_package(Qt6 6.4 REQUIRED COMPONENTS Quick)
qt_standard_project_setup(REQUIRES 6.4)
endif()
qt_add_library(NebulaUI STATIC)
set(NEBULA_UI_QML_DIR "${CMAKE_CURRENT_SOURCE_DIR}/qml/Nebula/UI")
set(NEBULA_UI_QML_FILES
Theme.qml
NebulaButton.qml
NebulaCard.qml
NebulaIconButton.qml
NebulaTextField.qml
NebulaToolTip.qml
FocusFrame.qml
NebulaIcon.qml
)
set(NEBULA_UI_QML_PATHS)
foreach(qml_file IN LISTS NEBULA_UI_QML_FILES)
set(qml_path "${NEBULA_UI_QML_DIR}/${qml_file}")
set_source_files_properties("${qml_path}" PROPERTIES
QT_RESOURCE_ALIAS "${qml_file}"
)
list(APPEND NEBULA_UI_QML_PATHS "${qml_path}")
endforeach()
set_source_files_properties("${NEBULA_UI_QML_DIR}/Theme.qml" PROPERTIES
QT_QML_SINGLETON_TYPE TRUE
)
qt_add_qml_module(NebulaUI
URI Nebula.UI
VERSION 1.0
RESOURCE_PREFIX /qt/qml
QML_FILES ${NEBULA_UI_QML_PATHS}
)
+20
View File
@@ -0,0 +1,20 @@
# Nebula.UI
Shared Qt Quick/QML design system for NebulaOS shells.
Desktop and the future Bigscreen shell should both import:
```qml
import Nebula.UI
```
This module contains reusable visual language only:
- colour, spacing, type, and motion tokens (`Theme`)
- generic controls (`NebulaButton`, `NebulaCard`, `NebulaIconButton`, `NebulaTextField`, `NebulaToolTip`)
- focus treatment (`FocusFrame`)
- compact vector icons (`NebulaIcon`)
Do not put Desktop- or Bigscreen-specific chrome here (top bar, launcher, session flow).
Built as part of `shells/desktop/shell`. It is not a standalone application.
@@ -0,0 +1,24 @@
import QtQuick
Item {
id: root
property Item target: parent
property real radius: Theme.radiusSmall
property bool active: target && target.activeFocus
anchors.fill: target
anchors.margins: -2
z: 20
enabled: false
visible: active
Accessible.ignored: true
Rectangle {
anchors.fill: parent
color: "transparent"
border.width: 2
border.color: Theme.focus
radius: root.radius + 2
}
}
@@ -0,0 +1,68 @@
import QtQuick
Item {
id: root
property string text: ""
property bool checked: false
property color textColor: checked ? Theme.accentStrong : Theme.textPrimary
default property alias icon: iconHolder.data
signal clicked()
implicitWidth: row.implicitWidth + 18
implicitHeight: Theme.controlHeight
Accessible.role: Accessible.Button
Accessible.name: root.text
Accessible.checkable: true
Accessible.checked: root.checked
Accessible.onPressAction: root.clicked()
Rectangle {
anchors.fill: parent
radius: Theme.radiusSmall
color: (hover.containsMouse || root.checked) ? Theme.accentSoft : "transparent"
Behavior on color {
ColorAnimation {
duration: Theme.animationFast
}
}
}
Row {
id: row
anchors.centerIn: parent
spacing: 8
Item {
id: iconHolder
width: children.length > 0 ? 18 : 0
height: 18
visible: width > 0
}
Text {
text: root.text
color: root.textColor
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
font.weight: Font.DemiBold
visible: root.text.length > 0
anchors.verticalCenter: parent.verticalCenter
}
}
MouseArea {
id: hover
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.clicked()
}
FocusFrame {
radius: Theme.radiusSmall
}
}
@@ -0,0 +1,21 @@
import QtQuick
Rectangle {
id: root
color: Theme.surface
border.color: Theme.border
border.width: 1
radius: Theme.radiusLarge
Rectangle {
id: shadow
z: -1
anchors.fill: parent
anchors.topMargin: 10
anchors.bottomMargin: -10
radius: root.radius
color: Theme.shadow
opacity: 0.85
}
}
@@ -0,0 +1,436 @@
import QtQuick
Item {
id: root
property string name: "nebula"
property color color: Theme.textPrimary
property int size: 16
property real value: 1
implicitWidth: size
implicitHeight: size
Accessible.ignored: true
Canvas {
id: canvas
anchors.fill: parent
antialiasing: true
onPaint: root.paint(getContext("2d"))
}
onNameChanged: canvas.requestPaint()
onColorChanged: canvas.requestPaint()
onValueChanged: canvas.requestPaint()
onWidthChanged: canvas.requestPaint()
onHeightChanged: canvas.requestPaint()
function paint(ctx) {
ctx.reset()
ctx.strokeStyle = root.color
ctx.fillStyle = root.color
ctx.lineCap = "round"
ctx.lineJoin = "round"
ctx.scale(width / 24, height / 24)
switch (root.name) {
case "nebula":
paintNebula(ctx)
break
case "search":
paintSearch(ctx)
break
case "wifi":
paintWifi(ctx)
break
case "speaker":
paintSpeaker(ctx)
break
case "battery":
paintBattery(ctx)
break
case "power":
paintPower(ctx)
break
case "restart":
paintRestart(ctx)
break
case "folder":
paintFolder(ctx)
break
case "globe":
paintGlobe(ctx)
break
case "terminal":
paintTerminal(ctx)
break
case "settings":
paintSettings(ctx)
break
case "game":
paintGame(ctx)
break
case "photo":
paintPhoto(ctx)
break
case "music":
paintMusic(ctx)
break
case "document":
paintDocument(ctx)
break
case "application":
paintApplication(ctx)
break
case "minimize":
paintMinimize(ctx)
break
case "maximize":
paintMaximize(ctx)
break
case "restore":
paintRestore(ctx)
break
case "close":
paintClose(ctx)
break
default:
paintApplication(ctx)
break
}
}
function paintNebula(ctx) {
ctx.beginPath()
ctx.arc(12, 12, 4.2, 0, Math.PI * 2)
ctx.fill()
ctx.save()
ctx.translate(12, 12)
ctx.rotate(-26 * Math.PI / 180)
ctx.scale(10 / 4.15, 1)
ctx.lineWidth = 1.35 * (4.15 / 10)
ctx.beginPath()
ctx.arc(0, 0, 4.15, 0, Math.PI * 2)
ctx.stroke()
ctx.restore()
}
function paintSearch(ctx) {
ctx.lineWidth = 1.6
ctx.beginPath()
ctx.arc(11, 11, 6.25, 0, Math.PI * 2)
ctx.stroke()
ctx.beginPath()
ctx.moveTo(15.8, 15.8)
ctx.lineTo(20, 20)
ctx.stroke()
}
function paintWifi(ctx) {
const strength = root.value
ctx.lineWidth = 1.6
ctx.globalAlpha = strength > 0 ? 1 : 0.28
ctx.beginPath()
ctx.moveTo(4.8, 15.2)
ctx.bezierCurveTo(8.8, 11.2, 15.2, 11.2, 19.2, 15.2)
ctx.stroke()
ctx.globalAlpha = strength > 1 ? 1 : 0.28
ctx.beginPath()
ctx.moveTo(7.4, 17.4)
ctx.bezierCurveTo(10, 14.9, 14, 14.9, 16.6, 17.4)
ctx.stroke()
ctx.globalAlpha = 1
ctx.beginPath()
ctx.arc(12, 20, 1.15, 0, Math.PI * 2)
ctx.fill()
}
function paintSpeaker(ctx) {
ctx.beginPath()
ctx.moveTo(4.5, 9.4)
ctx.lineTo(7.6, 9.4)
ctx.lineTo(12, 6.2)
ctx.lineTo(12, 17.8)
ctx.lineTo(7.6, 14.6)
ctx.lineTo(4.5, 14.6)
ctx.closePath()
ctx.fill()
ctx.lineWidth = 1.5
ctx.beginPath()
ctx.arc(12.5, 12, 4.2, -0.7, 0.7)
ctx.stroke()
ctx.globalAlpha = 0.7
ctx.beginPath()
ctx.arc(12.5, 12, 7.2, -0.85, 0.85)
ctx.stroke()
ctx.globalAlpha = 1
}
function paintBattery(ctx) {
ctx.lineWidth = 1.5
ctx.beginPath()
roundedRect(ctx, 2.5, 7.25, 16.5, 9.5, 2)
ctx.stroke()
ctx.beginPath()
roundedRect(ctx, 19.5, 10, 2, 4, 0.6)
ctx.fill()
const fillWidth = Math.max(1.5, (14 * Math.min(100, Math.max(0, root.value))) / 100)
ctx.beginPath()
roundedRect(ctx, 4.2, 9, fillWidth, 6, 1)
ctx.fill()
}
function paintPower(ctx) {
ctx.lineWidth = 1.7
ctx.beginPath()
ctx.moveTo(12, 4.5)
ctx.lineTo(12, 11.7)
ctx.stroke()
ctx.beginPath()
ctx.arc(12, 13, 6.2, Math.PI * 1.15, Math.PI * -0.15, false)
ctx.stroke()
}
function paintRestart(ctx) {
ctx.lineWidth = 1.7
ctx.beginPath()
ctx.arc(12, 12, 7, -0.2, Math.PI * 1.55, true)
ctx.stroke()
ctx.beginPath()
ctx.moveTo(19, 5.2)
ctx.lineTo(19, 9.6)
ctx.lineTo(14.6, 9.6)
ctx.stroke()
}
function paintMinimize(ctx) {
ctx.lineWidth = 1.7
ctx.beginPath()
ctx.moveTo(6.4, 16.2)
ctx.lineTo(17.6, 16.2)
ctx.stroke()
}
function paintMaximize(ctx) {
ctx.lineWidth = 1.5
ctx.beginPath()
roundedRect(ctx, 6.2, 6.2, 11.6, 11.6, 2)
ctx.stroke()
}
function paintRestore(ctx) {
ctx.lineWidth = 1.45
ctx.beginPath()
roundedRect(ctx, 8.6, 5.6, 9.4, 9.4, 1.7)
ctx.stroke()
ctx.beginPath()
roundedRect(ctx, 5.6, 8.6, 9.4, 9.4, 1.7)
ctx.stroke()
}
function paintClose(ctx) {
ctx.lineWidth = 1.7
ctx.beginPath()
ctx.moveTo(7.3, 7.3)
ctx.lineTo(16.7, 16.7)
ctx.moveTo(16.7, 7.3)
ctx.lineTo(7.3, 16.7)
ctx.stroke()
}
function paintApplication(ctx) {
ctx.lineWidth = 1.5
ctx.beginPath()
roundedRect(ctx, 4.2, 5.2, 15.6, 13.6, 2.2)
ctx.stroke()
ctx.beginPath()
ctx.moveTo(4.2, 8.4)
ctx.lineTo(19.8, 8.4)
ctx.stroke()
ctx.beginPath()
ctx.arc(7, 6.8, 0.7, 0, Math.PI * 2)
ctx.fill()
ctx.beginPath()
ctx.arc(9.4, 6.8, 0.7, 0, Math.PI * 2)
ctx.fill()
}
function paintFolder(ctx) {
ctx.beginPath()
ctx.moveTo(3.5, 8.2)
ctx.quadraticCurveTo(3.5, 6.4, 5.3, 6.4)
ctx.lineTo(9.4, 6.4)
ctx.lineTo(11, 8.2)
ctx.lineTo(18.7, 8.2)
ctx.quadraticCurveTo(20.5, 8.2, 20.5, 10)
ctx.lineTo(20.5, 16.8)
ctx.quadraticCurveTo(20.5, 18.6, 18.7, 18.6)
ctx.lineTo(5.3, 18.6)
ctx.quadraticCurveTo(3.5, 18.6, 3.5, 16.8)
ctx.closePath()
ctx.fill()
}
function paintGlobe(ctx) {
ctx.lineWidth = 1.6
ctx.beginPath()
ctx.arc(12, 12, 7.25, 0, Math.PI * 2)
ctx.stroke()
ctx.lineWidth = 1.4
ctx.save()
ctx.translate(12, 12)
ctx.scale(3.1 / 7.25, 1)
ctx.beginPath()
ctx.arc(0, 0, 7.25, 0, Math.PI * 2)
ctx.stroke()
ctx.restore()
ctx.beginPath()
ctx.moveTo(5.2, 12)
ctx.lineTo(18.8, 12)
ctx.stroke()
}
function paintTerminal(ctx) {
ctx.lineWidth = 1.7
ctx.beginPath()
ctx.moveTo(7, 9.2)
ctx.lineTo(10.4, 12)
ctx.lineTo(7, 14.8)
ctx.stroke()
ctx.beginPath()
ctx.moveTo(12.2, 15.2)
ctx.lineTo(17, 15.2)
ctx.stroke()
}
function paintSettings(ctx) {
ctx.beginPath()
ctx.arc(12, 12, 2.4, 0, Math.PI * 2)
ctx.fill()
ctx.lineWidth = 1.6
const ticks = [
[12, 5.2, 12, 6.8],
[12, 17.2, 12, 18.8],
[5.2, 12, 6.8, 12],
[17.2, 12, 18.8, 12],
[7.2, 7.2, 8.3, 8.3],
[15.7, 15.7, 16.8, 16.8],
[16.8, 7.2, 15.7, 8.3],
[8.3, 15.7, 7.2, 16.8]
]
for (let i = 0; i < ticks.length; i++) {
const t = ticks[i]
ctx.beginPath()
ctx.moveTo(t[0], t[1])
ctx.lineTo(t[2], t[3])
ctx.stroke()
}
}
function paintGame(ctx) {
ctx.lineWidth = 1.5
ctx.beginPath()
ctx.moveTo(5.2, 10.4)
ctx.lineTo(18.8, 10.4)
ctx.quadraticCurveTo(20.4, 10.4, 20.3, 12.1)
ctx.lineTo(19.8, 15.3)
ctx.quadraticCurveTo(19.6, 16.9, 18.5, 16.9)
ctx.lineTo(16.3, 16.9)
ctx.lineTo(15, 15.1)
ctx.lineTo(10, 15.1)
ctx.lineTo(8.7, 16.9)
ctx.lineTo(6.5, 16.9)
ctx.quadraticCurveTo(5.4, 16.9, 5.2, 15.3)
ctx.lineTo(4.7, 12.1)
ctx.quadraticCurveTo(4.6, 10.4, 5.2, 10.4)
ctx.closePath()
ctx.stroke()
ctx.beginPath()
ctx.moveTo(8, 12.4)
ctx.lineTo(8, 15.6)
ctx.moveTo(6.4, 14)
ctx.lineTo(9.6, 14)
ctx.stroke()
ctx.beginPath()
ctx.arc(15.4, 13.2, 0.9, 0, Math.PI * 2)
ctx.fill()
ctx.beginPath()
ctx.arc(17.2, 15, 0.9, 0, Math.PI * 2)
ctx.fill()
}
function paintPhoto(ctx) {
ctx.lineWidth = 1.5
ctx.beginPath()
roundedRect(ctx, 4, 6.2, 16, 11.6, 2)
ctx.stroke()
ctx.beginPath()
ctx.arc(9, 10.2, 1.3, 0, Math.PI * 2)
ctx.fill()
ctx.beginPath()
ctx.moveTo(7.2, 15.8)
ctx.lineTo(10.6, 12.4)
ctx.lineTo(13.2, 15)
ctx.lineTo(15.4, 12.9)
ctx.lineTo(18.8, 15.8)
ctx.stroke()
}
function paintMusic(ctx) {
ctx.lineWidth = 1.6
ctx.beginPath()
ctx.moveTo(10, 16.6)
ctx.lineTo(10, 7.4)
ctx.lineTo(18, 5.8)
ctx.lineTo(18, 15.2)
ctx.stroke()
ctx.beginPath()
ctx.arc(8.2, 16.6, 2.2, 0, Math.PI * 2)
ctx.fill()
ctx.beginPath()
ctx.arc(16.2, 15.2, 2.2, 0, Math.PI * 2)
ctx.fill()
}
function paintDocument(ctx) {
ctx.lineWidth = 1.5
ctx.beginPath()
ctx.moveTo(7, 5.5)
ctx.lineTo(13.2, 5.5)
ctx.lineTo(17.5, 10)
ctx.lineTo(17.5, 18.5)
ctx.quadraticCurveTo(17.5, 20, 16, 20)
ctx.lineTo(7, 20)
ctx.quadraticCurveTo(5.5, 20, 5.5, 18.5)
ctx.lineTo(5.5, 7)
ctx.quadraticCurveTo(5.5, 5.5, 7, 5.5)
ctx.closePath()
ctx.stroke()
ctx.beginPath()
ctx.moveTo(13.2, 5.5)
ctx.lineTo(13.2, 10)
ctx.lineTo(17.5, 10)
ctx.stroke()
ctx.beginPath()
ctx.moveTo(8.8, 13.4)
ctx.lineTo(15.2, 13.4)
ctx.moveTo(8.8, 16.2)
ctx.lineTo(13.2, 16.2)
ctx.stroke()
}
function roundedRect(ctx, x, y, w, h, r) {
const radius = Math.min(r, w / 2, h / 2)
ctx.moveTo(x + radius, y)
ctx.lineTo(x + w - radius, y)
ctx.quadraticCurveTo(x + w, y, x + w, y + radius)
ctx.lineTo(x + w, y + h - radius)
ctx.quadraticCurveTo(x + w, y + h, x + w - radius, y + h)
ctx.lineTo(x + radius, y + h)
ctx.quadraticCurveTo(x, y + h, x, y + h - radius)
ctx.lineTo(x, y + radius)
ctx.quadraticCurveTo(x, y, x + radius, y)
ctx.closePath()
}
}
@@ -0,0 +1,88 @@
import QtQuick
Item {
id: root
property string label: ""
property bool active: false
property bool danger: false
readonly property bool hovered: hover.containsMouse
readonly property bool pressed: hover.pressed
readonly property color iconColor: {
if (danger && hovered)
return Theme.danger
if (active)
return Theme.accentStrong
if (hovered)
return Theme.textPrimary
return Theme.textSecondary
}
default property alias icon: iconHolder.data
signal clicked()
implicitWidth: Theme.controlHeight
implicitHeight: Theme.controlHeight
activeFocusOnTab: true
Accessible.role: Accessible.Button
Accessible.name: root.label
Accessible.onPressAction: root.clicked()
Keys.onReturnPressed: {
event.accepted = true
root.clicked()
}
Keys.onSpacePressed: {
event.accepted = true
root.clicked()
}
Rectangle {
anchors.fill: parent
radius: Theme.radiusSmall
color: {
if (root.danger && hover.pressed)
return Theme.dangerSoftStrong
if (root.danger && hover.containsMouse)
return Theme.dangerSoft
if (root.active)
return Theme.accentSoft
if (hover.pressed)
return Theme.pressedFill
if (hover.containsMouse)
return Theme.hoverFill
return "transparent"
}
Behavior on color {
ColorAnimation {
duration: Theme.animationFast
}
}
}
Item {
id: iconHolder
anchors.centerIn: parent
width: 16
height: 16
}
MouseArea {
id: hover
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.clicked()
}
FocusFrame {
radius: Theme.radiusSmall
}
NebulaToolTip {
text: root.label
active: hover.containsMouse && !hover.pressed && root.label.length > 0
}
}
@@ -0,0 +1,82 @@
import QtQuick
Item {
id: root
property alias text: input.text
property alias placeholderText: placeholder.text
property alias input: input
signal accepted()
implicitHeight: Theme.searchHeight
implicitWidth: 200
Accessible.role: Accessible.EditableText
Accessible.name: placeholder.text
Accessible.editable: true
Rectangle {
anchors.fill: parent
radius: Theme.radiusMedium
color: Theme.searchFill
border.width: 1
border.color: input.activeFocus ? Theme.borderStrong : Theme.border
Behavior on border.color {
ColorAnimation {
duration: Theme.animationFast
}
}
}
Row {
id: row
anchors.fill: parent
anchors.leftMargin: 12
anchors.rightMargin: 12
spacing: 10
NebulaIcon {
id: searchIcon
anchors.verticalCenter: parent.verticalCenter
name: "search"
size: 16
color: input.activeFocus ? Theme.textPrimary : Theme.textSecondary
}
Item {
width: parent.width - searchIcon.width - row.spacing
height: parent.height
Text {
id: placeholder
anchors.fill: parent
verticalAlignment: Text.AlignVCenter
text: qsTr("Search")
color: Theme.textTertiary
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
visible: input.text.length === 0 && !input.preeditText
}
TextInput {
id: input
anchors.fill: parent
verticalAlignment: Text.AlignVCenter
color: Theme.textPrimary
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
clip: true
selectByMouse: true
activeFocusOnTab: true
onAccepted: root.accepted()
}
}
}
FocusFrame {
target: root
active: input.activeFocus
radius: Theme.radiusMedium
}
}
@@ -0,0 +1,98 @@
import QtQuick
import QtQuick.Window
Item {
id: root
property string text: ""
property bool active: false
property int delay: 550
property int timeout: 4000
width: 0
height: 0
enabled: false
Accessible.ignored: true
Timer {
id: showTimer
interval: root.delay
repeat: false
onTriggered: root.open()
}
Timer {
id: hideTimer
interval: root.timeout
repeat: false
onTriggered: tip.visible = false
}
Window {
id: tip
flags: Qt.Popup | Qt.ToolTip | Qt.FramelessWindowHint | Qt.WindowDoesNotAcceptFocus
color: "transparent"
visible: false
width: Math.ceil(tipText.implicitWidth) + 16
height: Math.ceil(tipText.implicitHeight) + 10
Rectangle {
anchors.fill: parent
radius: Theme.radiusSmall
color: Theme.surfaceRaised
border.color: Theme.borderStrong
border.width: 1
Text {
id: tipText
anchors.centerIn: parent
text: root.text
color: Theme.textPrimary
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
}
}
onActiveChanged: root.sync()
onTextChanged: {
if (tip.visible)
root.place()
else
root.sync()
}
function sync() {
if (root.active && root.text.length > 0) {
showTimer.restart()
return
}
showTimer.stop()
hideTimer.stop()
tip.visible = false
}
function open() {
if (!root.active || root.text.length === 0)
return
const host = root.parent
if (!host)
return
const win = host.Window.window
if (win)
tip.transientParent = win
root.place()
tip.visible = true
hideTimer.restart()
}
function place() {
const host = root.parent
if (!host)
return
const pos = host.mapToGlobal(host.width / 2, host.height + 6)
const screenWidth = Screen.width
tip.x = Math.round(Math.max(8, Math.min(pos.x - tip.width / 2, screenWidth - tip.width - 8)))
tip.y = Math.round(pos.y)
}
}
@@ -0,0 +1,64 @@
pragma Singleton
import QtQuick
QtObject {
// Colours from the Nebula Desktop React prototype (tokens.css).
readonly property color background: "#0b0f16"
readonly property color backgroundDeep: "#080b11"
readonly property color surface: Qt.rgba(20 / 255, 26 / 255, 36 / 255, 0.78)
readonly property color surfaceRaised: Qt.rgba(32 / 255, 40 / 255, 54 / 255, 0.9)
readonly property color topBarBackground: Qt.rgba(10 / 255, 13 / 255, 18 / 255, 0.55)
readonly property color border: Qt.rgba(232 / 255, 236 / 255, 242 / 255, 0.08)
readonly property color borderStrong: Qt.rgba(232 / 255, 236 / 255, 242 / 255, 0.14)
readonly property color textPrimary: "#e8ecf2"
readonly property color textSecondary: "#8e99ab"
readonly property color textTertiary: "#6b7587"
readonly property color accent: "#7ea0c4"
readonly property color accentStrong: "#9bb6d4"
readonly property color accentSoft: Qt.rgba(126 / 255, 160 / 255, 196 / 255, 0.16)
readonly property color danger: "#c98888"
readonly property color focus: Qt.rgba(126 / 255, 160 / 255, 196 / 255, 0.55)
readonly property color hoverFill: Qt.rgba(1, 1, 1, 0.06)
readonly property color pressedFill: Qt.rgba(1, 1, 1, 0.10)
readonly property color dangerSoft: Qt.rgba(201 / 255, 136 / 255, 136 / 255, 0.18)
readonly property color dangerSoftStrong: Qt.rgba(201 / 255, 136 / 255, 136 / 255, 0.28)
readonly property color trafficClose: "#ff5f57"
readonly property color trafficMinimise: "#febc2e"
readonly property color trafficZoom: "#28c840"
readonly property color tileHover: Qt.rgba(1, 1, 1, 0.05)
readonly property color iconWell: Qt.rgba(1, 1, 1, 0.05)
readonly property color iconWellHighlight: Qt.rgba(1, 1, 1, 0.06)
readonly property color searchFill: Qt.rgba(8 / 255, 11 / 255, 17 / 255, 0.45)
readonly property color shadow: Qt.rgba(0, 0, 0, 0.42)
readonly property int radiusSmall: 6
readonly property int radiusMedium: 10
readonly property int radiusLarge: 14
readonly property int spacingSmall: 8
readonly property int spacingMedium: 16
readonly property int spacingLarge: 24
readonly property int animationFast: 120
readonly property int animationNormal: 200
readonly property int topBarHeight: 40
readonly property int controlHeight: 28
readonly property int searchHeight: 36
readonly property int trafficLightSize: 20
readonly property int trafficLightGap: 9
readonly property int windowChromeInset: 14
readonly property int fontSize: 13
readonly property int fontSizeApp: 12
readonly property int fontSizeSmall: 11
readonly property string fontFamily: "Ubuntu"
}
+67
View File
@@ -0,0 +1,67 @@
#!/bin/sh
# VMware / development-only launcher for nebula-shell.
#
# The Ubuntu VMware guest GPU cannot currently run the Qt Quick accelerated
# scene graph correctly. This wrapper sets QT_QUICK_BACKEND=software.
#
# Production sessions must start `nebula-shell` directly and must NOT export
# QT_QUICK_BACKEND. GPU-accelerated Qt Quick is the production default.
#
# Usage (from a running Nebula Sway session, or via nebula-sway.conf exec):
# scripts/run-nebula-vm.sh
#
# Disable autostart:
# NEBULA_SHELL_AUTOSTART=0 sway -c compositor/sway/nebula-sway.conf
set -eu
if [ "${NEBULA_SHELL_AUTOSTART:-1}" = "0" ]; then
echo "nebula: shell autostart disabled (NEBULA_SHELL_AUTOSTART=0)"
exit 0
fi
script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
repo_root=$(CDPATH= cd -- "${script_dir}/.." && pwd)
export XCURSOR_THEME="${XCURSOR_THEME:-Adwaita}"
export XCURSOR_SIZE="${XCURSOR_SIZE:-24}"
# Development-only. Do not copy this into production session files.
export QT_QUICK_BACKEND="${QT_QUICK_BACKEND:-software}"
resolve_shell() {
if [ -n "${NEBULA_SHELL:-}" ] && [ -x "${NEBULA_SHELL}" ]; then
printf '%s\n' "${NEBULA_SHELL}"
return 0
fi
for candidate in \
"${repo_root}/shells/desktop/shell/build/nebula-shell" \
"${repo_root}/build/nebula-shell" \
"${PWD}/shells/desktop/shell/build/nebula-shell" \
"${PWD}/build/nebula-shell"
do
if [ -x "${candidate}" ]; then
printf '%s\n' "${candidate}"
return 0
fi
done
if command -v nebula-shell >/dev/null 2>&1; then
command -v nebula-shell
return 0
fi
return 1
}
if ! shell_bin=$(resolve_shell); then
echo "nebula: nebula-shell not found." >&2
echo "Build it with:" >&2
echo " cd shells/desktop/shell && cmake -S . -B build && cmake --build build -j\$(nproc)" >&2
echo "Or set NEBULA_SHELL to the executable path." >&2
exit 1
fi
echo "nebula: starting ${shell_bin} (QT_QUICK_BACKEND=${QT_QUICK_BACKEND})"
exec "${shell_bin}" "$@"
+13 -3
View File
@@ -1,10 +1,20 @@
#!/bin/sh #!/bin/sh
# NebulaOS session startup # NebulaOS production session startup
#
# Production must start nebula-shell without QT_QUICK_BACKEND=software.
# GPU-accelerated Qt Quick is the production default.
#
# The Ubuntu VMware development VM is different: use
# compositor/sway/dev/run-sway-vm.sh (or sway -c compositor/sway/nebula-sway.conf
# from the repository root) so the development wrapper can set the software
# scene-graph backend.
set -e set -e
export XDG_CURRENT_DESKTOP=NebulaOS export XDG_CURRENT_DESKTOP=NebulaOS
export XDG_SESSION_DESKTOP=nebula export XDG_SESSION_DESKTOP=nebula
export XCURSOR_THEME="${XCURSOR_THEME:-Adwaita}"
export XCURSOR_SIZE="${XCURSOR_SIZE:-24}"
# Shell and services are wired here as they land # Production desktop chrome: shells/desktop/shell (nebula-shell)
# exec nebula-shell-desktop # exec nebula-shell
+334
View File
@@ -0,0 +1,334 @@
# Nebula Desktop
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, 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
Nebula Desktop is a **Qt Quick / QML** Wayland shell. It draws system chrome only. It does not host application or game windows.
```text
Ubuntu
Wayland
Sway
temporary compositor
Nebula Shell
C++20 + Qt 6 + Qt Quick/QML + LayerShellQt
```
Eventual target:
```text
Ubuntu/Linux
Wayland
Nebula compositor
Nebula Shell
Qt Quick/QML
```
Normal applications remain ordinary Wayland / XWayland clients of the compositor:
```text
Nebula Launcher
ApplicationService
application process
Wayland / XWayland
Sway
```
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
| Piece | Technology |
|-------|------------|
| Language | C++20 |
| 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`) |
The executable is `nebula-shell`.
QML module URI: `Nebula.Shell`.
## Surfaces
Independent Wayland layer-shell windows:
| Surface | Layer | Role |
|---------|-------|------|
| Desktop | `BACKGROUND` | Fills the current output, no exclusive zone, no keyboard focus |
| Top Bar | `TOP` | 40 logical pixels, exclusive zone, spans the output width |
| Launcher | `OVERLAY` | Covers the output below the top bar while open; hidden otherwise |
Stacking:
```text
OVERLAY
Nebula Launcher
TOP
Nebula Top Bar
NORMAL WAYLAND WINDOWS
Foot, Firefox, applications, games, …
BACKGROUND
Nebula Desktop
```
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:
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. `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.
Search matches name, generic name, comment, and categories. Searching `term` should find Foot (`GenericName=Terminal`). Searching `firefox` should find Firefox if it is installed.
Application icons use the system icon theme through Qt (`QIcon::fromTheme`). Unresolved icons use a Nebula fallback glyph, not a broken-image placeholder.
Mock launcher data remains only as a fallback when no installed applications are discovered.
## Layout
```text
shells/desktop/
├── README.md this file
├── shell/ production Qt/QML shell
│ ├── CMakeLists.txt
│ ├── src/
│ └── qml/
└── prototype-react/ archived design reference only
```
Shared visual language lives in `packages/nebula-ui`. Application discovery lives in `core/applications`. Window management lives in `core/windows`.
## Linux build
This project will not compile on Windows. LayerShellQt is a Wayland API and is not stubbed.
On the Ubuntu NebulaOS VM:
```bash
sudo apt install \
qt6-base-dev \
qt6-declarative-dev \
qt6-wayland \
liblayershellqtinterface-dev \
qml6-module-org-kde-layershell \
qml6-module-qtquick \
qml6-module-qtquick-window \
adwaita-icon-theme \
cmake \
build-essential
```
Qt 6.4 or newer is required. `loadFromModule` is used on Qt 6.5+.
```bash
cd shells/desktop/shell
rm -rf build
cmake -S . -B build
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`:
```bash
qmllint \
shells/desktop/shell/qml/*.qml \
shells/desktop/shell/qml/surfaces/*.qml \
shells/desktop/shell/qml/components/*.qml \
shells/desktop/shell/qml/mock/*.qml \
packages/nebula-ui/qml/Nebula/UI/*.qml
```
Do not use ESLint or Oxlint on the production Qt shell.
## Development session (VMware)
From the repository root, the intended flow is:
```bash
sway -c compositor/sway/nebula-sway.conf
```
or, recommended on the VM (sets cursor env and starts Sway from the repo root):
```bash
sh compositor/sway/dev/run-sway-vm.sh
```
That should:
1. start Sway without a Sway bar
2. autostart `nebula-shell` through `scripts/run-nebula-vm.sh`
3. fill the current output
4. show a normal Adwaita 24px cursor
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:
```bash
NEBULA_SHELL_AUTOSTART=0 sway -c compositor/sway/nebula-sway.conf
```
### VMware software rendering
The VMware virtual GPU currently cannot run the Qt Quick accelerated path correctly. The **development wrapper** therefore sets:
```bash
QT_QUICK_BACKEND=software
```
That variable is not a production default. `scripts/run-nebula-vm.sh` is the only place that sets it automatically. Production `nebula-session` / `nebula-shell` must run GPU-accelerated.
Manual equivalent, if you start the shell yourself:
```bash
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. Window placement uses compositor workspace geometry, not 1920×1080.
Inspect outputs and modes:
```bash
swaymsg -t get_outputs
```
Set a mode explicitly if needed:
```bash
swaymsg output <name> mode 1600x900
swaymsg output <name> scale 1
```
In VMware, also set the guest display (or Autofit Guest) so the advertised modes match the host window.
### Cursor
Sway owns the cursor:
```text
XCURSOR_THEME=Adwaita
XCURSOR_SIZE=24
seat * xcursor_theme Adwaita 24
```
Install `adwaita-icon-theme` if the theme is missing. A branded Nebula cursor is a later task. `WLR_NO_HARDWARE_CURSORS=1` is set only by the VMware Sway wrapper.
### Emergency shortcuts
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 |
## Design reference
`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:
- 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
- a custom Nebula compositor
- Nebula Bigscreen
- Gamescope
Those come later. Bigscreen is expected to reuse `Nebula.UI`, `Nebula.Applications`, and `Nebula.Windows`.
-91
View File
@@ -1,91 +0,0 @@
# Nebula Desktop native host
This directory will contain the Linux native shell host for Nebula Desktop.
The host is a C++ process that creates **several** GTK4 windows, each backed by WebKitGTK 6.0 and placed with **gtk4-layer-shell**. It does not composite application windows. Those remain ordinary Wayland / XWayland clients of the compositor.
Planned stack:
- C++
- GTK4
- gtk4-layer-shell
- WebKitGTK 6.0
- Wayland
## Surfaces
The React UI in `shells/desktop/ui` can render each shell layer independently via a query parameter. The native host will create one WebKitGTK view per surface and append that parameter to the load URL.
| Surface | Query | Layer Shell layer | Placement |
|-----------|-----------------------|-------------------|-----------|
| Desktop | `?surface=desktop` | `BACKGROUND` | Anchored to all monitor edges |
| Top Bar | `?surface=topbar` | `TOP` | Anchored top/left/right, fixed shell-defined height, exclusive zone enabled |
| Launcher | `?surface=launcher` | `OVERLAY` | Hidden/unmapped when closed |
The host must not hardcode a Vite port. It loads a base URL and appends the surface parameter.
## Stacking
```text
OVERLAY
Nebula Launcher
notifications
OSDs
TOP
Nebula Top Bar ← exclusive zone; apps do not go under it
NORMAL APPLICATION WINDOWS
Firefox, Steam, Blender, games, terminals, …
BACKGROUND
Nebula Desktop
wallpaper
```
React draws **shell chrome only**. Applications and games render through the compositor, not through WebKit.
```text
Application / game
→ Wayland / XWayland
→ compositor
→ GPU
```
## Loading modes
### Development
The host will load a configurable Vite origin, conceptually:
```text
NEBULA_SHELL_DEV_URL=http://127.0.0.1:5174
```
and request:
```text
{NEBULA_SHELL_DEV_URL}/?surface=desktop
{NEBULA_SHELL_DEV_URL}/?surface=topbar
{NEBULA_SHELL_DEV_URL}/?surface=launcher
```
The port is environment configuration, not architecture. A combined browser preview (`/?surface=preview`, also the default) is for Windows UI work only; the native host should load the three surfaces separately.
### Production
The host will load the compiled assets from `npm run build` in `shells/desktop/ui/dist`, still using the same `?surface=` parameters. It will not depend on a Vite server.
## Shell bridge
Each WebKit view talks to the host through a JavaScript bridge (`shellBridge` in the React UI). The development build uses an in-page mock. The native host will inject the real implementation so the top bar can toggle the launcher, and so launching an app can unmap the overlay and update the top-bar context.
`nebula.*` remains the API boundary for system services (apps, audio, network, power). Those stay mocked until Linux services land.
## Current development compositor
Sway is the temporary compositor. See `compositor/sway/nebula-sway.conf`.
## Build notes
This host is Linux-only. Do not expect it to compile on Windows. Implementation is the next Linux-side task.
+48
View File
@@ -0,0 +1,48 @@
# Nebula Desktop UI — prototype (archived)
```text
DESIGN REFERENCE ONLY
NOT THE PRODUCTION NEBULA SHELL
```
This directory is the original React + Vite desktop chrome. It remains only as a visual and behavioural reference while the Qt Quick/QML shell catches up.
**Do not add production functionality here.**
The production Nebula Desktop shell is:
```text
shells/desktop/shell
C++20 + Qt 6 + Qt Quick/QML + LayerShellQt
```
This prototype must not be launched as the OS shell. It does not talk to Wayland layer-shell, and it is not wired to Nebula Core.
## What this is for
- comparing layout, colour, spacing, and motion against `shells/desktop/shell`
- checking launcher search, mock launch, and top-bar treatment
- discarding this tree later, once Qt parity is accepted
## Surfaces (browser preview only)
| URL | Renders |
|-----|---------|
| `/?surface=preview` (default) | Combined mockup |
| `/?surface=desktop` | Background only |
| `/?surface=topbar` | Top bar only |
| `/?surface=launcher` | Launcher only |
## Preview on a development machine
```powershell
npm install
npm run dev
```
```powershell
npm run lint
npm run build
```
Oxlint applies to this JavaScript prototype only. It is not used for the Qt shell.

Before

Width:  |  Height:  |  Size: 325 B

After

Width:  |  Height:  |  Size: 325 B

@@ -1,13 +1,13 @@
/** /**
* Nebula JavaScript API * Nebula JavaScript API
* *
* Boundary between the React shell UI and the future native Nebula Core: * Placeholder APIs for the archived React prototype.
* *
* React UI nebula.* native host / Nebula Core * Archived React prototype nebula.* placeholders
* *
* Every method here is a placeholder. Implementations must not call Node * Production system APIs will be C++ services exposed to QML, not this
* APIs, spawn processes, or talk to D-Bus, PipeWire, NetworkManager, or * JavaScript module. Methods here must not call Node APIs, spawn processes,
* Wayland. The WebKitGTK host will later inject a real bridge. * or talk to D-Bus, PipeWire, NetworkManager, or Wayland.
* *
* The shell is chrome only. Applications and games render as compositor * The shell is chrome only. Applications and games render as compositor
* surfaces, not inside this React tree. * surfaces, not inside this React tree.
@@ -1,9 +1,8 @@
/** /**
* Shell coordination bridge. * Shell coordination bridge.
* *
* Top bar, launcher, and desktop will later live in separate WebKitGTK views. * In-page mock for coordinating the archived browser preview.
* They must not share React component state. The native host will replace this * Production shell state is the QML ShellState singleton, not this bridge.
* module with an injected C++ bridge.
* *
* This development mock keeps in-page state and broadcasts it with window * This development mock keeps in-page state and broadcasts it with window
* CustomEvents. No networking, WebSockets, or Node APIs. * CustomEvents. No networking, WebSockets, or Node APIs.
@@ -1,9 +1,8 @@
/** /**
* Shell surface routing. * Shell surface routing.
* *
* The native host will load this UI in separate WebKitGTK views, each with a * Browser preview routing for the archived React prototype.
* surface query parameter. Never hardcode a host or port the loader decides * Production surfaces are separate Qt Quick windows, not WebKit views.
* the URL; this module only reads the current location.
*/ */
export const SURFACES = { export const SURFACES = {
@@ -9,7 +9,8 @@ import './surfaces.css';
/** /**
* Browser-only composition of the three shell surfaces. * Browser-only composition of the three shell surfaces.
* Production loads each surface in its own WebKitGTK view. * Browser-only composition of the three shell surfaces.
* Archived design reference production chrome is Qt Quick/QML.
*/ */
export function PreviewSurface() { export function PreviewSurface() {
const { launcherOpen } = useShellState(); const { launcherOpen } = useShellState();
+144
View File
@@ -0,0 +1,144 @@
cmake_minimum_required(VERSION 3.20)
project(nebula-shell LANGUAGES CXX)
if(NOT CMAKE_SYSTEM_NAME STREQUAL "Linux")
message(FATAL_ERROR
"nebula-shell is a Linux Wayland shell using LayerShellQt.\n"
"Build and run it on the Ubuntu NebulaOS VM. Windows is not a supported target, "
"and LayerShellQt is not stubbed with other platform APIs.")
endif()
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 Network)
find_package(LayerShellQt REQUIRED)
qt_standard_project_setup(REQUIRES 6.4)
set(QT_QML_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/qml")
add_subdirectory(
"${CMAKE_CURRENT_SOURCE_DIR}/../../../packages/nebula-ui"
"${CMAKE_CURRENT_BINARY_DIR}/nebula-ui"
)
add_subdirectory(
"${CMAKE_CURRENT_SOURCE_DIR}/../../../core/applications"
"${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
src/DesktopIconProvider.hpp
)
set(NEBULA_SHELL_QML_FILES
qml/Main.qml
qml/ShellState.qml
qml/surfaces/DesktopSurface.qml
qml/surfaces/TopBarSurface.qml
qml/surfaces/WindowChromeSurface.qml
qml/surfaces/LauncherSurface.qml
qml/components/TopBar.qml
qml/components/WindowControls.qml
qml/components/TrafficLightButton.qml
qml/components/NebulaButton.qml
qml/components/Clock.qml
qml/components/SystemArea.qml
qml/components/Launcher.qml
qml/components/AppItem.qml
qml/components/OpenWindowItem.qml
qml/mock/MockApps.qml
)
foreach(qml_file IN LISTS NEBULA_SHELL_QML_FILES)
get_filename_component(qml_name "${qml_file}" NAME)
set_source_files_properties("${qml_file}" PROPERTIES
QT_RESOURCE_ALIAS "${qml_name}"
)
endforeach()
set_source_files_properties(qml/ShellState.qml PROPERTIES
QT_QML_SINGLETON_TYPE TRUE
QT_RESOURCE_ALIAS ShellState.qml
)
set_source_files_properties(qml/mock/MockApps.qml PROPERTIES
QT_QML_SINGLETON_TYPE TRUE
QT_RESOURCE_ALIAS MockApps.qml
)
qt_add_qml_module(nebula-shell
URI Nebula.Shell
VERSION 1.0
RESOURCE_PREFIX /qt/qml
QML_FILES ${NEBULA_SHELL_QML_FILES}
SOURCES
src/OutputTracker.hpp
src/OutputTracker.cpp
DEPENDENCIES
QtQuick
Nebula.UI
Nebula.Applications
Nebula.Windows
)
target_link_libraries(nebula-shell
PRIVATE
Qt6::Core
Qt6::Gui
Qt6::Qml
Qt6::Quick
LayerShellQt::Interface
NebulaUI
NebulaApplications
NebulaWindows
)
if(TARGET NebulaUIplugin)
target_link_libraries(nebula-shell PRIVATE NebulaUIplugin)
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.
target_include_directories(nebula-shell PRIVATE
"${CMAKE_CURRENT_SOURCE_DIR}"
"${CMAKE_CURRENT_SOURCE_DIR}/src"
)
set_source_files_properties(
"${CMAKE_CURRENT_BINARY_DIR}/nebula-shell_qmltyperegistrations.cpp"
PROPERTIES
COMPILE_OPTIONS "-include;${CMAKE_CURRENT_SOURCE_DIR}/src/OutputTracker.hpp"
)
set_target_properties(nebula-shell PROPERTIES
OUTPUT_NAME nebula-shell
RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}"
)
# Shared Qt discovers QML plugins at runtime. Skip qmlimportscanner / static
# plugin importing so Qt 6.8+ does not warn about missing Qt6::*plugin targets.
if(QT6_IS_SHARED_LIBS_BUILD)
set_target_properties(nebula-shell PROPERTIES
QT_QML_MODULE_NO_IMPORT_SCAN TRUE
)
endif()
if(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang")
target_compile_options(nebula-shell PRIVATE -Wall -Wextra)
endif()
+17
View File
@@ -0,0 +1,17 @@
import QtQuick
QtObject {
id: shell
readonly property DesktopSurface desktop: DesktopSurface {}
readonly property TopBarSurface topBar: TopBarSurface {}
readonly property WindowChromeSurface windowChrome: WindowChromeSurface {}
readonly property LauncherSurface launcher: LauncherSurface {}
readonly property Shortcut closeLauncherShortcut: Shortcut {
sequence: "Escape"
enabled: ShellState.launcherOpen
context: Qt.ApplicationShortcut
onActivated: ShellState.closeLauncher()
}
}
+24
View File
@@ -0,0 +1,24 @@
pragma Singleton
import QtQuick
import Nebula.Windows
QtObject {
property bool launcherOpen: false
readonly property string activeApplication: WindowService.focusedApplicationName.length > 0
? WindowService.focusedApplicationName
: qsTr("Desktop")
function openLauncher() {
launcherOpen = true
}
function closeLauncher() {
launcherOpen = false
}
function toggleLauncher() {
launcherOpen = !launcherOpen
}
}
@@ -0,0 +1,173 @@
import QtQuick
import Nebula.UI
Item {
id: root
property string variant: "tile"
property var item
signal selected(var item)
readonly property bool rowVariant: variant === "row"
readonly property int iconSize: rowVariant ? 32 : (variant === "recent" ? 32 : 40)
readonly property int glyphSize: rowVariant ? 16 : 18
readonly property string iconName: item && item.iconName ? item.iconName : ""
readonly property string fallbackGlyph: {
if (item && item.glyph)
return item.glyph
if (item && item.kind)
return item.kind
return "application"
}
readonly property url iconSource: iconName.length > 0
? ("image://desktopicon/" + encodeURIComponent(iconName))
: ""
implicitWidth: rowVariant ? parent ? parent.width : 200 : 90
implicitHeight: rowVariant ? 48 : 78
Accessible.role: Accessible.Button
Accessible.name: item ? item.name : ""
Accessible.onPressAction: root.selected(item)
Rectangle {
anchors.fill: parent
radius: Theme.radiusMedium
color: hover.containsMouse ? Theme.tileHover : "transparent"
Behavior on color {
ColorAnimation {
duration: Theme.animationFast
}
}
}
Column {
visible: !root.rowVariant
anchors.fill: parent
anchors.topMargin: 10
anchors.bottomMargin: 8
anchors.leftMargin: 6
anchors.rightMargin: 6
spacing: 8
Rectangle {
width: root.iconSize
height: root.iconSize
radius: root.variant === "recent" ? 9 : 11
anchors.horizontalCenter: parent.horizontalCenter
color: Theme.iconWell
Rectangle {
anchors.fill: parent
radius: parent.radius
color: "transparent"
border.color: Theme.iconWellHighlight
border.width: 1
}
Image {
id: tileIcon
anchors.centerIn: parent
width: root.iconSize - 8
height: root.iconSize - 8
fillMode: Image.PreserveAspectFit
asynchronous: true
smooth: true
cache: true
visible: status === Image.Ready
source: root.iconSource
}
NebulaIcon {
anchors.centerIn: parent
visible: tileIcon.status !== Image.Ready
name: root.fallbackGlyph
size: root.glyphSize
color: item && item.tint ? item.tint : Theme.accent
}
}
Text {
width: parent.width
text: item ? item.name : ""
color: Theme.textPrimary
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeApp
elide: Text.ElideRight
horizontalAlignment: Text.AlignHCenter
}
}
Row {
visible: root.rowVariant
anchors.fill: parent
anchors.margins: 8
spacing: 10
Rectangle {
width: root.iconSize
height: root.iconSize
radius: 9
anchors.verticalCenter: parent.verticalCenter
color: Theme.iconWell
Image {
id: rowIcon
anchors.centerIn: parent
width: root.iconSize - 8
height: root.iconSize - 8
fillMode: Image.PreserveAspectFit
asynchronous: true
smooth: true
cache: true
visible: status === Image.Ready
source: root.iconSource
}
NebulaIcon {
anchors.centerIn: parent
visible: rowIcon.status !== Image.Ready
name: root.fallbackGlyph
size: root.glyphSize
color: item && item.tint ? item.tint : Theme.accent
}
}
Column {
anchors.verticalCenter: parent.verticalCenter
width: parent.width - root.iconSize - 10
spacing: 1
Text {
width: parent.width
text: item ? item.name : ""
color: Theme.textPrimary
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeApp
elide: Text.ElideRight
}
Text {
width: parent.width
visible: item && item.subtitle ? true : false
text: item && item.subtitle ? item.subtitle : ""
color: Theme.textTertiary
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
elide: Text.ElideRight
}
}
}
MouseArea {
id: hover
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.selected(item)
}
FocusFrame {
radius: Theme.radiusMedium
}
}
@@ -0,0 +1,39 @@
import QtQuick
import QtQml
import Nebula.UI
Text {
id: root
property date now: new Date()
text: formatClock(now)
color: Theme.textPrimary
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
verticalAlignment: Text.AlignVCenter
horizontalAlignment: Text.AlignHCenter
leftPadding: 8
rightPadding: 8
height: Theme.controlHeight
Accessible.role: Accessible.StaticText
Accessible.name: formatFull(now)
Timer {
interval: 1000
running: true
repeat: true
onTriggered: root.now = new Date()
}
function formatClock(date) {
const locale = Qt.locale()
const weekday = locale.toString(date, "ddd")
const time = locale.toString(date, locale.timeFormat(Locale.ShortFormat))
return weekday + " " + time
}
function formatFull(date) {
return Qt.locale().toString(date, Locale.LongFormat)
}
}
@@ -0,0 +1,350 @@
import QtQuick
import Nebula.UI
import Nebula.Applications
import Nebula.Windows
Item {
id: root
property real yOffset: 0
implicitWidth: 420
implicitHeight: 14 + searchField.implicitHeight + 14 + bodyColumn.implicitHeight + 14 + footer.height + 14
transform: Translate { y: root.yOffset }
transformOrigin: Item.TopLeft
Accessible.role: Accessible.Dialog
Accessible.name: qsTr("Launcher")
readonly property bool usingInstalledApps: ApplicationService.count > 0
readonly property bool searching: searchField.text.trim().length > 0
readonly property var mockMatches: {
const query = searchField.text.trim().toLowerCase()
const apps = MockApps.apps
if (!query)
return apps
return apps.filter((app) => {
const haystack = (app.name + " " + (app.comment || "")).toLowerCase()
return haystack.indexOf(query) !== -1
})
}
readonly property int resultCount: usingInstalledApps
? ApplicationService.filteredModel.count
: mockMatches.length
Keys.onEscapePressed: {
event.accepted = true
ShellState.closeLauncher()
}
Connections {
target: ShellState
function onLauncherOpenChanged() {
if (ShellState.launcherOpen) {
searchField.text = ""
searchField.input.forceActiveFocus()
} else {
searchField.text = ""
}
}
}
NebulaCard {
id: card
anchors.fill: parent
MouseArea {
anchors.fill: parent
acceptedButtons: Qt.LeftButton | Qt.RightButton
}
}
Column {
id: column
anchors.fill: parent
anchors.margins: 14
spacing: 0
NebulaTextField {
id: searchField
width: parent.width
placeholderText: qsTr("Search applications")
onTextChanged: ApplicationService.filter = text
}
Item {
width: 1
height: 14
}
Flickable {
id: body
width: parent.width
height: Math.max(0, column.height - searchField.height - footer.height - 29)
clip: true
contentWidth: width
contentHeight: bodyColumn.implicitHeight
boundsBehavior: Flickable.StopAtBounds
flickableDirection: Flickable.VerticalFlick
Column {
id: bodyColumn
width: body.width
spacing: 18
Column {
width: parent.width
spacing: 8
visible: !root.searching && !root.usingInstalledApps && MockApps.recents.length > 0
Text {
text: qsTr("Recent")
color: Theme.textSecondary
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.DemiBold
font.letterSpacing: 0.4
}
Grid {
id: recentGrid
width: parent.width
columns: 4
columnSpacing: 6
rowSpacing: 6
Repeater {
model: MockApps.recents
delegate: AppItem {
required property var modelData
width: (recentGrid.width - 18) / 4
variant: "recent"
item: modelData
onSelected: root.launchApp(item)
}
}
}
}
Column {
width: parent.width
spacing: 8
Text {
text: root.searching ? qsTr("Results") : qsTr("Applications")
color: Theme.textSecondary
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.DemiBold
font.letterSpacing: 0.4
}
Grid {
id: appGrid
visible: root.resultCount > 0
width: parent.width
columns: 4
columnSpacing: 6
rowSpacing: 6
Repeater {
model: root.usingInstalledApps ? ApplicationService.filteredModel : []
delegate: AppItem {
required property string desktopId
required property string name
required property string comment
required property string iconName
width: (appGrid.width - 18) / 4
variant: "tile"
item: ({
desktopId: desktopId,
name: name,
comment: comment,
iconName: iconName
})
onSelected: root.launchApp(item)
}
}
Repeater {
model: root.usingInstalledApps ? [] : root.mockMatches
delegate: AppItem {
required property var modelData
width: (appGrid.width - 18) / 4
variant: "tile"
item: modelData
onSelected: root.launchApp(item)
}
}
}
Text {
visible: root.resultCount === 0
width: parent.width
topPadding: 8
text: qsTr("No matching applications")
color: Theme.textTertiary
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
}
}
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
visible: !root.searching && !root.usingInstalledApps && MockApps.projects.length > 0
Text {
text: qsTr("Recent projects")
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: MockApps.projects
delegate: AppItem {
required property var modelData
width: parent.width
variant: "row"
item: modelData
onSelected: root.openProject(item)
}
}
}
}
}
}
Item {
width: 1
height: 14
}
Rectangle {
width: parent.width
height: 1
color: Theme.border
}
Item {
id: footer
width: parent.width
height: 38
Row {
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
spacing: 10
Rectangle {
width: 28
height: 28
radius: 14
color: Theme.accentSoft
anchors.verticalCenter: parent.verticalCenter
Text {
anchors.centerIn: parent
text: MockApps.user.initials
color: Theme.accentStrong
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeApp
font.weight: Font.DemiBold
}
}
Column {
anchors.verticalCenter: parent.verticalCenter
spacing: 0
Text {
text: MockApps.user.name
color: Theme.textPrimary
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
font.weight: Font.DemiBold
}
Text {
text: qsTr("Local session")
color: Theme.textTertiary
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
}
}
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 2
NebulaIconButton {
id: restartButton
label: qsTr("Restart")
onClicked: console.info("nebula: restart requested")
NebulaIcon {
name: "restart"
size: 16
color: restartButton.iconColor
}
}
NebulaIconButton {
id: shutdownButton
label: qsTr("Shut down")
danger: true
onClicked: console.info("nebula: shutdown requested")
NebulaIcon {
name: "power"
size: 16
color: shutdownButton.iconColor
}
}
}
}
}
function launchApp(app) {
if (app && app.desktopId)
ApplicationService.launch(app.desktopId)
ShellState.closeLauncher()
}
function openProject(project) {
ShellState.closeLauncher()
}
}
@@ -0,0 +1,64 @@
import QtQuick
import Nebula.UI
Item {
id: root
implicitWidth: row.implicitWidth + 18
implicitHeight: Theme.controlHeight
property bool checked: false
signal clicked()
Accessible.role: Accessible.Button
Accessible.name: qsTr("Nebula")
Accessible.checkable: true
Accessible.checked: root.checked
Accessible.onPressAction: root.clicked()
Rectangle {
anchors.fill: parent
radius: Theme.radiusSmall
color: (hover.containsMouse || root.checked) ? Theme.accentSoft : "transparent"
Behavior on color {
ColorAnimation {
duration: Theme.animationFast
}
}
}
Row {
id: row
anchors.centerIn: parent
spacing: 8
NebulaIcon {
name: "nebula"
size: 18
color: root.checked ? Theme.accentStrong : Theme.textPrimary
anchors.verticalCenter: parent.verticalCenter
}
Text {
text: qsTr("Nebula")
color: root.checked ? Theme.accentStrong : Theme.textPrimary
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
font.weight: Font.DemiBold
anchors.verticalCenter: parent.verticalCenter
}
}
MouseArea {
id: hover
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.clicked()
}
FocusFrame {
radius: Theme.radiusSmall
}
}
@@ -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
}
}
@@ -0,0 +1,47 @@
import QtQuick
import Nebula.UI
Row {
id: root
spacing: 2
// Placeholder system status until native services exist.
readonly property int wifiStrength: 3
readonly property int volumePercent: 72
readonly property int batteryPercent: 84
NebulaIconButton {
id: networkButton
label: qsTr("Network connected, Nebula-Net")
NebulaIcon {
name: "wifi"
size: 16
color: networkButton.iconColor
value: root.wifiStrength
}
}
NebulaIconButton {
id: volumeButton
label: qsTr("Volume %1%").arg(root.volumePercent)
NebulaIcon {
name: "speaker"
size: 16
color: volumeButton.iconColor
}
}
NebulaIconButton {
id: powerButton
label: qsTr("Battery %1%").arg(root.batteryPercent)
NebulaIcon {
name: "battery"
size: 16
color: powerButton.iconColor
value: root.batteryPercent
}
}
Clock {}
}
@@ -0,0 +1,63 @@
import QtQuick
import Nebula.UI
Item {
id: root
implicitHeight: Theme.topBarHeight
Rectangle {
anchors.fill: parent
color: Theme.topBarBackground
Rectangle {
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
height: 1
color: Theme.border
}
}
Row {
id: left
anchors.left: parent.left
anchors.leftMargin: 6
anchors.right: systemArea.left
anchors.rightMargin: 12
anchors.verticalCenter: parent.verticalCenter
spacing: 8
NebulaButton {
id: nebulaButton
checked: ShellState.launcherOpen
onClicked: ShellState.toggleLauncher()
}
Rectangle {
id: leftDivider
anchors.verticalCenter: parent.verticalCenter
width: 1
height: 14
color: Theme.borderStrong
}
Text {
anchors.verticalCenter: parent.verticalCenter
width: Math.min(implicitWidth, Math.max(0, left.width - nebulaButton.width - leftDivider.width - left.spacing * 2))
text: ShellState.activeApplication
color: Theme.textSecondary
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
elide: Text.ElideRight
maximumLineCount: 1
}
}
SystemArea {
id: systemArea
anchors.right: parent.right
anchors.rightMargin: 10
anchors.verticalCenter: parent.verticalCenter
}
}
@@ -0,0 +1,126 @@
import QtQuick
import Nebula.UI
Item {
id: root
property string label: ""
property color fill: Theme.trafficClose
property string glyph: "close"
property bool showGlyph: false
readonly property bool hovered: hover.containsMouse
readonly property bool pressed: hover.pressed
signal clicked()
implicitWidth: Theme.trafficLightSize + 6
implicitHeight: Theme.trafficLightSize + 6
Accessible.role: Accessible.Button
Accessible.name: root.label
Accessible.onPressAction: root.clicked()
Rectangle {
id: disc
anchors.centerIn: parent
width: Theme.trafficLightSize
height: Theme.trafficLightSize
radius: width / 2
color: hover.pressed ? Qt.darker(root.fill, 1.18) : root.fill
border.width: 1
border.color: Qt.rgba(0, 0, 0, 0.22)
Behavior on color {
ColorAnimation {
duration: Theme.animationFast
}
}
Rectangle {
anchors.horizontalCenter: parent.horizontalCenter
anchors.top: parent.top
anchors.topMargin: 1
width: parent.width - 4
height: Math.max(2, parent.height * 0.38)
radius: height / 2
color: Qt.rgba(1, 1, 1, 0.28)
Accessible.ignored: true
}
}
Canvas {
id: glyph
anchors.centerIn: parent
width: Math.round(Theme.trafficLightSize * 0.58)
height: width
antialiasing: true
opacity: root.showGlyph ? 1 : 0
visible: opacity > 0.01
onWidthChanged: requestPaint()
onHeightChanged: requestPaint()
onPaint: {
const ctx = getContext("2d")
ctx.reset()
ctx.scale(width / 8, height / 8)
ctx.strokeStyle = Qt.rgba(0.18, 0.12, 0.1, 0.78)
ctx.fillStyle = ctx.strokeStyle
ctx.lineWidth = 1.35
ctx.lineCap = "round"
ctx.lineJoin = "round"
switch (root.glyph) {
case "minimize":
ctx.beginPath()
ctx.moveTo(1.2, 4)
ctx.lineTo(6.8, 4)
ctx.stroke()
break
case "zoom":
ctx.beginPath()
ctx.moveTo(1.4, 4)
ctx.lineTo(6.6, 4)
ctx.moveTo(4, 1.4)
ctx.lineTo(4, 6.6)
ctx.stroke()
break
case "restore":
ctx.beginPath()
ctx.rect(2.3, 0.7, 4.3, 4.3)
ctx.stroke()
ctx.beginPath()
ctx.rect(0.7, 2.3, 4.3, 4.3)
ctx.stroke()
break
default:
ctx.beginPath()
ctx.moveTo(1.6, 1.6)
ctx.lineTo(6.4, 6.4)
ctx.moveTo(6.4, 1.6)
ctx.lineTo(1.6, 6.4)
ctx.stroke()
break
}
}
Behavior on opacity {
NumberAnimation {
duration: Theme.animationFast
}
}
}
onGlyphChanged: glyph.requestPaint()
onShowGlyphChanged: if (showGlyph) glyph.requestPaint()
MouseArea {
id: hover
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.clicked()
}
NebulaToolTip {
text: root.label
active: hover.containsMouse && !hover.pressed && root.label.length > 0
}
}
@@ -0,0 +1,48 @@
import QtQuick
import Nebula.UI
import Nebula.Windows
Item {
id: root
readonly property bool maximized: WindowService.focusedWindowMaximized
readonly property bool hovered: closeButton.hovered || minimizeButton.hovered || zoomButton.hovered
implicitWidth: row.implicitWidth
implicitHeight: row.implicitHeight
Accessible.role: Accessible.Grouping
Accessible.name: qsTr("Window controls")
Row {
id: row
anchors.centerIn: parent
spacing: Theme.trafficLightGap
TrafficLightButton {
id: closeButton
fill: Theme.trafficClose
glyph: "close"
label: qsTr("Close")
showGlyph: root.hovered
onClicked: WindowService.closeFocusedWindow()
}
TrafficLightButton {
id: minimizeButton
fill: Theme.trafficMinimise
glyph: "minimize"
label: qsTr("Minimise")
showGlyph: root.hovered
onClicked: WindowService.minimizeFocusedWindow()
}
TrafficLightButton {
id: zoomButton
fill: Theme.trafficZoom
glyph: root.maximized ? "restore" : "zoom"
label: root.maximized ? qsTr("Restore") : qsTr("Maximise")
showGlyph: root.hovered
onClicked: WindowService.toggleMaximizeFocusedWindow()
}
}
}
+100
View File
@@ -0,0 +1,100 @@
pragma Singleton
import QtQuick
QtObject {
readonly property var apps: [
{
id: "files",
name: "Files",
comment: "Browse files and folders",
recent: true,
glyph: "folder",
tint: "#c4a574"
},
{
id: "firefox",
name: "Firefox",
comment: "Web browser",
recent: true,
glyph: "globe",
tint: "#d08a5a"
},
{
id: "terminal",
name: "Terminal",
comment: "Command line",
recent: true,
glyph: "terminal",
tint: "#7eaa8e"
},
{
id: "settings",
name: "Settings",
comment: "System preferences",
recent: false,
glyph: "settings",
tint: "#8e99ab"
},
{
id: "steam",
name: "Steam",
comment: "Games",
recent: true,
glyph: "game",
tint: "#6f8fb8"
},
{
id: "photos",
name: "Photos",
comment: "Pictures and albums",
recent: false,
glyph: "photo",
tint: "#8aa8c4"
},
{
id: "music",
name: "Music",
comment: "Listen and organise",
recent: false,
glyph: "music",
tint: "#b48a9a"
},
{
id: "text-editor",
name: "Text Editor",
comment: "Edit documents",
recent: false,
glyph: "document",
tint: "#9aa7bc"
}
]
readonly property var recents: apps.filter((app) => app.recent)
readonly property var projects: [
{
id: "nebulaos",
name: "NebulaOS",
subtitle: "Repository",
kind: "folder"
},
{
id: "website",
name: "Website",
subtitle: "Project",
kind: "folder"
},
{
id: "session-notes",
name: "Session notes",
subtitle: "Document",
kind: "document"
}
]
readonly property var user: ({
name: "User",
initials: "N"
})
}
@@ -0,0 +1,71 @@
import QtQuick
import QtQuick.Window
import org.kde.layershell 1.0 as LayerShell
import Nebula.UI
Window {
id: root
readonly property int outputWidth: OutputTracker.width > 0
? OutputTracker.width
: (screen && screen.width > 0 ? screen.width : Screen.width)
readonly property int outputHeight: OutputTracker.height > 0
? OutputTracker.height
: (screen && screen.height > 0 ? screen.height : Screen.height)
title: qsTr("Nebula Desktop")
color: "transparent"
flags: Qt.FramelessWindowHint | Qt.WindowDoesNotAcceptFocus
visible: true
width: outputWidth
height: outputHeight
LayerShell.Window.scope: "nebula-desktop"
LayerShell.Window.layer: LayerShell.Window.LayerBackground
LayerShell.Window.anchors: LayerShell.Window.AnchorTop
| LayerShell.Window.AnchorBottom
| LayerShell.Window.AnchorLeft
| LayerShell.Window.AnchorRight
LayerShell.Window.exclusionZone: 0
LayerShell.Window.keyboardInteractivity: LayerShell.Window.KeyboardInteractivityNone
Canvas {
id: backdrop
anchors.fill: parent
onPaint: {
const ctx = getContext("2d")
ctx.reset()
ctx.fillStyle = Theme.background
ctx.fillRect(0, 0, width, height)
let glow = ctx.createRadialGradient(width * 0.12, height * -0.08, 0, width * 0.12, height * -0.08, width * 0.7)
glow.addColorStop(0, "rgba(110, 140, 180, 0.16)")
glow.addColorStop(0.52, "rgba(110, 140, 180, 0)")
ctx.fillStyle = glow
ctx.fillRect(0, 0, width, height)
glow = ctx.createRadialGradient(width * 0.88, height * 1.08, 0, width * 0.88, height * 1.08, width * 0.45)
glow.addColorStop(0, "rgba(42, 68, 98, 0.28)")
glow.addColorStop(0.58, "rgba(42, 68, 98, 0)")
ctx.fillStyle = glow
ctx.fillRect(0, 0, width, height)
glow = ctx.createRadialGradient(width * 0.62, height * 0.38, 0, width * 0.62, height * 0.38, width * 0.28)
glow.addColorStop(0, "rgba(72, 92, 120, 0.08)")
glow.addColorStop(0.7, "rgba(72, 92, 120, 0)")
ctx.fillStyle = glow
ctx.fillRect(0, 0, width, height)
const veil = ctx.createLinearGradient(0, 0, 0, height)
veil.addColorStop(0, "rgba(8, 11, 17, 0.22)")
veil.addColorStop(0.16, "rgba(8, 11, 17, 0)")
veil.addColorStop(0.78, "rgba(8, 11, 17, 0)")
veil.addColorStop(1, "rgba(8, 11, 17, 0.38)")
ctx.fillStyle = veil
ctx.fillRect(0, 0, width, height)
}
onWidthChanged: requestPaint()
onHeightChanged: requestPaint()
}
}
@@ -0,0 +1,82 @@
import QtQuick
import QtQuick.Window
import org.kde.layershell 1.0 as LayerShell
import Nebula.UI
Window {
id: root
readonly property int outputWidth: OutputTracker.width > 0
? OutputTracker.width
: (screen && screen.width > 0 ? screen.width : Screen.width)
readonly property int outputHeight: OutputTracker.height > 0
? OutputTracker.height
: (screen && screen.height > 0 ? screen.height : Screen.height)
title: qsTr("Nebula Launcher")
color: "transparent"
flags: Qt.FramelessWindowHint
visible: ShellState.launcherOpen || launcher.opacity > 0.01
width: outputWidth
height: Math.max(1, outputHeight - Theme.topBarHeight)
LayerShell.Window.scope: "nebula-launcher"
LayerShell.Window.layer: LayerShell.Window.LayerOverlay
LayerShell.Window.anchors: LayerShell.Window.AnchorTop
| LayerShell.Window.AnchorBottom
| LayerShell.Window.AnchorLeft
| LayerShell.Window.AnchorRight
LayerShell.Window.margins.top: Theme.topBarHeight
LayerShell.Window.exclusionZone: 0
LayerShell.Window.keyboardInteractivity: ShellState.launcherOpen
? LayerShell.Window.KeyboardInteractivityExclusive
: LayerShell.Window.KeyboardInteractivityNone
Item {
anchors.fill: parent
focus: ShellState.launcherOpen
Keys.onEscapePressed: {
event.accepted = true
ShellState.closeLauncher()
}
}
MouseArea {
anchors.fill: parent
acceptedButtons: Qt.LeftButton | Qt.RightButton
onClicked: ShellState.closeLauncher()
}
Launcher {
id: launcher
x: 10
y: 10
width: Math.min(420, parent.width - 20)
height: Math.min(implicitHeight, Math.min(640, parent.height - 24))
opacity: ShellState.launcherOpen ? 1 : 0
yOffset: ShellState.launcherOpen ? 0 : -6
scale: ShellState.launcherOpen ? 1 : 0.985
focus: ShellState.launcherOpen
Behavior on opacity {
NumberAnimation {
duration: Theme.animationNormal
easing.type: Easing.OutCubic
}
}
Behavior on yOffset {
NumberAnimation {
duration: Theme.animationNormal
easing.type: Easing.OutCubic
}
}
Behavior on scale {
NumberAnimation {
duration: Theme.animationNormal
easing.type: Easing.OutCubic
}
}
}
}
@@ -0,0 +1,31 @@
import QtQuick
import QtQuick.Window
import org.kde.layershell 1.0 as LayerShell
import Nebula.UI
Window {
id: root
readonly property int outputWidth: OutputTracker.width > 0
? OutputTracker.width
: (screen && screen.width > 0 ? screen.width : Screen.width)
title: qsTr("Nebula Top Bar")
color: "transparent"
flags: Qt.FramelessWindowHint | Qt.WindowDoesNotAcceptFocus
visible: true
width: outputWidth
height: Theme.topBarHeight
LayerShell.Window.scope: "nebula-topbar"
LayerShell.Window.layer: LayerShell.Window.LayerTop
LayerShell.Window.anchors: LayerShell.Window.AnchorTop
| LayerShell.Window.AnchorLeft
| LayerShell.Window.AnchorRight
LayerShell.Window.exclusionZone: Theme.topBarHeight
LayerShell.Window.keyboardInteractivity: LayerShell.Window.KeyboardInteractivityNone
TopBar {
anchors.fill: parent
}
}
@@ -0,0 +1,42 @@
import QtQuick
import QtQuick.Window
import org.kde.layershell 1.0 as LayerShell
import Nebula.UI
import Nebula.Windows
Window {
id: root
readonly property bool active: WindowService.hasFocusedWindow
&& !WindowService.focusedWindowFullscreen
&& !ShellState.launcherOpen
readonly property int chromeX: WindowService.focusedWindowX
+ Theme.windowChromeInset
- WindowService.focusedWorkspaceX
readonly property int chromeY: {
const barH = WindowService.focusedWindowTitleBarHeight
const centered = WindowService.focusedWindowTitleBarY
+ Math.max(0, Math.round((barH - height) / 2))
return centered - WindowService.focusedWorkspaceY
}
title: qsTr("Nebula Window Chrome")
color: "transparent"
flags: Qt.FramelessWindowHint | Qt.WindowDoesNotAcceptFocus
visible: root.active
width: Math.max(1, controls.implicitWidth)
height: Math.max(1, controls.implicitHeight)
LayerShell.Window.scope: "nebula-window-chrome"
LayerShell.Window.layer: LayerShell.Window.LayerTop
LayerShell.Window.anchors: LayerShell.Window.AnchorLeft | LayerShell.Window.AnchorTop
LayerShell.Window.margins.left: Math.max(0, chromeX)
LayerShell.Window.margins.top: Math.max(0, chromeY)
LayerShell.Window.exclusionZone: 0
LayerShell.Window.keyboardInteractivity: LayerShell.Window.KeyboardInteractivityNone
WindowControls {
id: controls
anchors.centerIn: parent
}
}
@@ -0,0 +1,87 @@
#include "DesktopIconProvider.hpp"
#include <QFileInfo>
#include <QIcon>
#include <QPixmap>
#include <QUrl>
namespace {
QIcon resolveDesktopIcon(const QString &name)
{
if (name.isEmpty())
return {};
const QFileInfo direct(name);
if (direct.isAbsolute() && direct.exists())
return QIcon(name);
QIcon themed = QIcon::fromTheme(name);
if (!themed.isNull())
return themed;
QString base = name;
if (base.endsWith(QLatin1String(".png"), Qt::CaseInsensitive)
|| base.endsWith(QLatin1String(".svg"), Qt::CaseInsensitive)
|| base.endsWith(QLatin1String(".xpm"), Qt::CaseInsensitive)) {
base = QFileInfo(base).completeBaseName();
themed = QIcon::fromTheme(base);
if (!themed.isNull())
return themed;
}
const QStringList pixmapDirs = {
QStringLiteral("/usr/share/pixmaps"),
QStringLiteral("/usr/share/icons/hicolor/48x48/apps"),
QStringLiteral("/usr/share/icons/hicolor/scalable/apps"),
};
const QStringList suffixes = {
QString(),
QStringLiteral(".png"),
QStringLiteral(".svg"),
QStringLiteral(".xpm"),
};
for (const QString &dir : pixmapDirs) {
for (const QString &suffix : suffixes) {
const QString candidate = dir + QLatin1Char('/') + (suffix.isEmpty() ? name : base) + suffix;
if (QFileInfo::exists(candidate))
return QIcon(candidate);
}
}
return {};
}
} // namespace
DesktopIconProvider::DesktopIconProvider()
: QQuickImageProvider(QQuickImageProvider::Pixmap)
{
}
QPixmap DesktopIconProvider::requestPixmap(const QString &id, QSize *size, const QSize &requestedSize)
{
const QString iconName = QUrl::fromPercentEncoding(id.toUtf8());
const QIcon icon = resolveDesktopIcon(iconName);
const QSize target = requestedSize.isValid() && requestedSize.width() > 0 && requestedSize.height() > 0
? requestedSize
: QSize(48, 48);
if (icon.isNull()) {
if (size)
*size = target;
return {};
}
const QPixmap pixmap = icon.pixmap(target);
if (pixmap.isNull()) {
if (size)
*size = target;
return {};
}
if (size)
*size = pixmap.size();
return pixmap;
}

Some files were not shown because too many files have changed in this diff Show More