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.
This commit is contained in:
2026-08-26 17:19:30 +12:00
parent a7d204f241
commit b0c95b9e78
31 changed files with 1673 additions and 72 deletions
+26 -5
View File
@@ -6,7 +6,8 @@ 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) |
| `core/applications` | Installed `.desktop` discovery and launching |
| `compositor/` | Compositor configuration (Sway is temporary) | | `compositor/` | Compositor configuration (Sway is temporary) |
| `shells/` | Desktop and future Bigscreen shells | | `shells/` | Desktop and future Bigscreen shells |
| `packages/` | Shared UI (`Nebula.UI`), icons, API, and design tokens | | `packages/` | Shared UI (`Nebula.UI`), icons, API, and design tokens |
@@ -14,12 +15,13 @@ A Linux desktop environment built around a custom compositor, shell, and service
| `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) |
## Shells ## Shells
**Nebula Desktop** (production) is a C++20 Qt Quick/QML Wayland shell using LayerShellQt. See `shells/desktop/`. **Nebula Desktop 0.1** is a C++20 Qt Quick/QML Wayland shell using LayerShellQt. It can discover installed applications and launch them (including Foot) from the GUI. See `shells/desktop/`.
**Nebula Bigscreen** is planned as a separate Qt Quick/QML shell with a controller-first interaction model, sharing `Nebula.UI` and Nebula Core. **Nebula Bigscreen** is planned as a separate Qt Quick/QML shell with a controller-first interaction model, sharing `Nebula.UI` and `Nebula.Applications`.
Standalone Nebula applications may still use React/Tauri where that is a better fit. The operating-system shell does not. Standalone Nebula applications may still use React/Tauri where that is a better fit. The operating-system shell does not.
@@ -39,8 +41,27 @@ Nebula Shell Qt Quick/QML
Applications and games render on their own Wayland/XWayland paths. They are not composited inside QML. Applications and games render on their own Wayland/XWayland paths. They are not composited inside QML.
## Getting started ## Getting started (development VM)
Desktop shell build instructions: `shells/desktop/README.md`. ```bash
cd shells/desktop/shell
rm -rf build
cmake -S . -B build
cmake --build build -j$(nproc)
```
Then, from the repository root:
```bash
./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. The Qt shell must be compiled on Linux (the Ubuntu NebulaOS VM). It will not build on Windows.
+59 -1
View File
@@ -7,7 +7,7 @@ Sway currently provides:
- Wayland compositor - Wayland compositor
- application window management - application window management
- XWayland - XWayland
- input - input and the pointer cursor
- output handling - output handling
Nebula provides the visible shell (Qt Quick/QML via LayerShellQt). The Sway config must not enable a Sway bar. Nebula provides the visible shell (Qt Quick/QML via LayerShellQt). The Sway config must not enable a Sway bar.
@@ -25,3 +25,61 @@ Nebula compositor later
Nebula Shell Nebula Shell
Qt Quick/QML Qt Quick/QML
``` ```
## 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
./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 ./compositor/sway/dev/run-sway-vm.sh
```
### Output / resolution
The development config uses scale `1` and requests `1920x1080` when that mode is advertised. If it is not, Sway keeps the preferred mode. This is a VM convenience, not a production requirement.
```bash
swaymsg -t get_outputs
swaymsg output <name> mode 1920x1080
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 shortcuts
| Shortcut | Action |
|----------|--------|
| Super+Enter | open Foot |
| Super+Shift+E | exit Sway |
| Super+Shift+C | reload Sway config |
Normal application launching is done from the Nebula launcher, not these shortcuts.
### 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" "$@"
+39
View File
@@ -0,0 +1,39 @@
#!/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"
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
cd "${repo_root}"
echo "nebula: starting Sway with ${config}"
exec sway -c "${config}" "$@"
+42 -9
View File
@@ -2,17 +2,53 @@ set $mod Mod4
set $term foot set $term foot
# NebulaOS temporary development compositor configuration # NebulaOS temporary development compositor configuration
#
# Sway is invisible infrastructure. Nebula Desktop is the visible shell.
# Do NOT add a bar block; Nebula provides its own top bar.
# No Sway wallpaper or UI # -----------------------------------------------------------------------------
# Output / resolution
# -----------------------------------------------------------------------------
# Development default: 1:1 scale, fill the current VMware display.
# Request 1920x1080 when the output advertises that mode. If the mode is not
# available, Sway keeps the output's preferred mode instead of failing.
#
# This is a development convenience, not a production architecture requirement.
# Inspect modes: swaymsg -t get_outputs
# Force another mode (example): swaymsg output <name> mode 1600x900
output * scale 1
output * mode 1920x1080
output * bg #000000 solid_color output * bg #000000 solid_color
# Development terminal # -----------------------------------------------------------------------------
# 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.
# -----------------------------------------------------------------------------
# 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
# Exit compositor
bindsym $mod+Shift+e exec swaymsg exit bindsym $mod+Shift+e exec swaymsg exit
# Reload configuration
bindsym $mod+Shift+c reload bindsym $mod+Shift+c reload
# Remove Sway window decorations # Remove Sway window decorations
@@ -21,6 +57,3 @@ default_floating_border pixel 0
# Basic floating controls for development # Basic floating controls for development
floating_modifier $mod normal floating_modifier $mod normal
# Do NOT add a bar block.
# Nebula provides its own top bar.
@@ -0,0 +1,64 @@
#include "ApplicationFilterModel.hpp"
#include "ApplicationModel.hpp"
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;
invalidateFilter();
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;
};
+93
View File
@@ -0,0 +1,93 @@
#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;
}
+44
View File
@@ -0,0 +1,44 @@
#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;
signals:
void countChanged();
private:
QVector<DesktopEntry> m_entries;
};
+109
View File
@@ -0,0 +1,109 @@
#include "ApplicationService.hpp"
#include "DesktopEntry.hpp"
#include <QDir>
#include <QDirIterator>
#include <QFileInfo>
#include <QSet>
#include <QVector>
#include <utility>
ApplicationService::ApplicationService(QObject *parent)
: QObject(parent)
, m_model(new ApplicationModel(this))
, m_filtered(new ApplicationFilterModel(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::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();
}
void ApplicationService::refresh()
{
QVector<DesktopEntry> entries;
QSet<QString> seenIds;
const QStringList directories = applicationDirectories();
qInfo("nebula: scanning %d application director%s",
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",
entries.size(),
entries.size() == 1 ? "" : "s");
m_model->setEntries(std::move(entries));
}
+42
View File
@@ -0,0 +1,42 @@
#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);
static ApplicationService *create(QQmlEngine *engine, QJSEngine *scriptEngine);
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 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()
+413
View File
@@ -0,0 +1,413 @@
#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("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;
}
+31
View File
@@ -0,0 +1,31 @@
#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 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);
+18
View File
@@ -0,0 +1,18 @@
# 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.
## 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`).
- 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.
@@ -79,8 +79,11 @@ Item {
case "document": case "document":
paintDocument(ctx) paintDocument(ctx)
break break
case "application":
paintApplication(ctx)
break
default: default:
paintFolder(ctx) paintApplication(ctx)
break break
} }
} }
@@ -189,6 +192,23 @@ Item {
ctx.stroke() 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) { function paintFolder(ctx) {
ctx.beginPath() ctx.beginPath()
ctx.moveTo(3.5, 8.2) ctx.moveTo(3.5, 8.2)
+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
if command -v nebula-shell >/dev/null 2>&1; then
command -v 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
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}" "$@"
+11 -2
View File
@@ -1,11 +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) # Production desktop chrome: shells/desktop/shell (nebula-shell)
# exec nebula-shell # exec nebula-shell
+133 -34
View File
@@ -1,6 +1,8 @@
# Nebula Desktop # Nebula Desktop
Production shell for the mouse-and-keyboard NebulaOS desktop. Production shell for the mouse-and-keyboard NebulaOS desktop. This is Nebula Desktop **0.1**: a usable session foundation, not only a visual prototype.
The user can enter the development session, use the full output, open the Nebula launcher, and start real installed applications (including Foot) without Sway keyboard shortcuts.
## Architecture ## Architecture
@@ -34,25 +36,15 @@ Qt Quick/QML
Normal applications remain ordinary Wayland / XWayland clients of the compositor: Normal applications remain ordinary Wayland / XWayland clients of the compositor:
```text ```text
Normal Application Nebula Launcher
ApplicationService
application process
Wayland / XWayland Wayland / XWayland
Compositor Sway
GPU
```
Nebula chrome:
```text
QML
Qt Quick
Wayland Layer Shell
Compositor
``` ```
Games, browsers, terminals, and other apps never pass through Qt Quick. Games, browsers, terminals, and other apps never pass through Qt Quick.
@@ -64,6 +56,7 @@ Games, browsers, terminals, and other apps never pass through Qt Quick.
| Language | C++20 | | Language | C++20 |
| UI | Qt 6, Qt Quick, QML | | UI | Qt 6, Qt Quick, QML |
| Layer surfaces | LayerShellQt | | Layer surfaces | LayerShellQt |
| Installed apps | `core/applications` (`import Nebula.Applications`) |
| Build | CMake | | Build | CMake |
| Shared design system | `packages/nebula-ui` (`import Nebula.UI`) | | Shared design system | `packages/nebula-ui` (`import Nebula.UI`) |
@@ -77,9 +70,9 @@ Independent Wayland layer-shell windows:
| Surface | Layer | Role | | Surface | Layer | Role |
|---------|-------|------| |---------|-------|------|
| Desktop | `BACKGROUND` | Wallpaper / desktop, no exclusive zone, no keyboard focus | | Desktop | `BACKGROUND` | Fills the current output, no exclusive zone, no keyboard focus |
| Top Bar | `TOP` | 40px bar, exclusive zone, no keyboard focus by default | | Top Bar | `TOP` | 40 logical pixels, exclusive zone, spans the output width |
| Launcher | `OVERLAY` | Hidden until the Nebula button opens it | | Launcher | `OVERLAY` | Covers the output below the top bar while open; hidden otherwise |
Stacking: Stacking:
@@ -91,12 +84,33 @@ TOP
Nebula Top Bar Nebula Top Bar
NORMAL WAYLAND WINDOWS NORMAL WAYLAND WINDOWS
Firefox, Steam, Blender, games, terminals, … Foot, Firefox, applications, games, …
BACKGROUND BACKGROUND
Nebula Desktop 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.
## 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.
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.
The top-bar context text stays **Desktop** in this milestone. Launching an app is not treated as focus; real window tracking comes next.
Mock launcher data remains only as a fallback when no installed applications are discovered.
## Layout ## Layout
```text ```text
@@ -104,12 +118,12 @@ shells/desktop/
├── README.md this file ├── README.md this file
├── shell/ production Qt/QML shell ├── shell/ production Qt/QML shell
│ ├── CMakeLists.txt │ ├── CMakeLists.txt
│ ├── src/main.cpp │ ├── src/
│ └── qml/ │ └── qml/
└── prototype-react/ archived design reference only └── prototype-react/ archived design reference only
``` ```
Shared visual language lives in `packages/nebula-ui`, not in Desktop-specific chrome. Shared visual language lives in `packages/nebula-ui`. Application discovery lives in `core/applications`.
## Linux build ## Linux build
@@ -126,6 +140,7 @@ sudo apt install \
qml6-module-org-kde-layershell \ qml6-module-org-kde-layershell \
qml6-module-qtquick \ qml6-module-qtquick \
qml6-module-qtquick-window \ qml6-module-qtquick-window \
adwaita-icon-theme \
cmake \ cmake \
build-essential build-essential
``` ```
@@ -135,14 +150,12 @@ Qt 6.4 or newer is required. `loadFromModule` is used on Qt 6.5+.
```bash ```bash
cd shells/desktop/shell cd shells/desktop/shell
rm -rf build
cmake -S . -B build cmake -S . -B build
cmake --build build -j$(nproc) cmake --build build -j$(nproc)
./build/nebula-shell
``` ```
Run inside a Wayland compositor such as Sway (`compositor/sway/nebula-sway.conf`). Sway must not show its own bar. 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.
There is no npm server, Vite server, `NEBULA_SHELL_DEV_URL`, WebKitGTK, GTK4, or React involved in running the production shell.
### QML lint ### QML lint
@@ -159,6 +172,91 @@ qmllint \
Do not use ESLint or Oxlint on the production Qt shell. 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
./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. let the Nebula launcher start 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 requests `1920x1080` at scale `1` when that mode exists. If the VM does not advertise it, Sway keeps the preferred mode. This is a development default, not a production requirement.
Inspect outputs and modes:
```bash
swaymsg -t get_outputs
```
Set a mode explicitly if needed:
```bash
swaymsg output <name> mode 1920x1080
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 works.
| Shortcut | Action |
|----------|--------|
| Super+Enter | open Foot |
| Super+Shift+E | exit Sway |
| Super+Shift+C | reload Sway config |
## Design reference ## 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. `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.
@@ -167,12 +265,13 @@ Do not use ESLint or Oxlint on the production Qt shell.
Not implemented in this milestone: Not implemented in this milestone:
- Sway IPC / focused-window tracking
- workspaces, window overview, dock
- PipeWire, NetworkManager, BlueZ, UPower - PipeWire, NetworkManager, BlueZ, UPower
- D-Bus services - notifications, XDG portals, Polkit
- real `.desktop` discovery or app launching - login/lock screen, greetd
- Sway IPC, workspaces, notifications
- authentication, greetd, Gamescope
- a custom Nebula compositor - a custom Nebula compositor
- Nebula Bigscreen - Nebula Bigscreen
- Gamescope
Those come later. Bigscreen is expected to reuse `Nebula.UI` and shared C++ services with a different interaction model. Those come later. Bigscreen is expected to reuse `Nebula.UI` and `Nebula.Applications`.
+15
View File
@@ -25,8 +25,15 @@ add_subdirectory(
"${CMAKE_CURRENT_BINARY_DIR}/nebula-ui" "${CMAKE_CURRENT_BINARY_DIR}/nebula-ui"
) )
add_subdirectory(
"${CMAKE_CURRENT_SOURCE_DIR}/../../../core/applications"
"${CMAKE_CURRENT_BINARY_DIR}/nebula-applications"
)
qt_add_executable(nebula-shell qt_add_executable(nebula-shell
src/main.cpp src/main.cpp
src/DesktopIconProvider.cpp
src/DesktopIconProvider.hpp
) )
set(NEBULA_SHELL_QML_FILES set(NEBULA_SHELL_QML_FILES
@@ -65,9 +72,13 @@ qt_add_qml_module(nebula-shell
VERSION 1.0 VERSION 1.0
RESOURCE_PREFIX /qt/qml RESOURCE_PREFIX /qt/qml
QML_FILES ${NEBULA_SHELL_QML_FILES} QML_FILES ${NEBULA_SHELL_QML_FILES}
SOURCES
src/OutputTracker.hpp
src/OutputTracker.cpp
DEPENDENCIES DEPENDENCIES
QtQuick QtQuick
Nebula.UI Nebula.UI
Nebula.Applications
) )
target_link_libraries(nebula-shell target_link_libraries(nebula-shell
@@ -78,11 +89,15 @@ target_link_libraries(nebula-shell
Qt6::Quick Qt6::Quick
LayerShellQt::Interface LayerShellQt::Interface
NebulaUI NebulaUI
NebulaApplications
) )
if(TARGET NebulaUIplugin) if(TARGET NebulaUIplugin)
target_link_libraries(nebula-shell PRIVATE NebulaUIplugin) target_link_libraries(nebula-shell PRIVATE NebulaUIplugin)
endif() endif()
if(TARGET NebulaApplicationsplugin)
target_link_libraries(nebula-shell PRIVATE NebulaApplicationsplugin)
endif()
set_target_properties(nebula-shell PROPERTIES set_target_properties(nebula-shell PROPERTIES
OUTPUT_NAME nebula-shell OUTPUT_NAME nebula-shell
+3 -5
View File
@@ -4,6 +4,9 @@ import QtQuick
QtObject { QtObject {
property bool launcherOpen: false property bool launcherOpen: false
// Focused-window tracking is a later compositor milestone. Launching an
// application does not mean it has focus, so this stays "Desktop" for now.
property string activeApplication: "Desktop" property string activeApplication: "Desktop"
function openLauncher() { function openLauncher() {
@@ -17,9 +20,4 @@ QtObject {
function toggleLauncher() { function toggleLauncher() {
launcherOpen = !launcherOpen launcherOpen = !launcherOpen
} }
function launch(name) {
activeApplication = name
closeLauncher()
}
} }
@@ -11,6 +11,17 @@ Item {
readonly property bool rowVariant: variant === "row" readonly property bool rowVariant: variant === "row"
readonly property int iconSize: rowVariant ? 32 : (variant === "recent" ? 32 : 40) readonly property int iconSize: rowVariant ? 32 : (variant === "recent" ? 32 : 40)
readonly property int glyphSize: rowVariant ? 16 : 18 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 implicitWidth: rowVariant ? parent ? parent.width : 200 : 90
implicitHeight: rowVariant ? 48 : 78 implicitHeight: rowVariant ? 48 : 78
@@ -54,9 +65,23 @@ Item {
border.width: 1 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 { NebulaIcon {
anchors.centerIn: parent anchors.centerIn: parent
name: item && item.glyph ? item.glyph : "folder" visible: tileIcon.status !== Image.Ready
name: root.fallbackGlyph
size: root.glyphSize size: root.glyphSize
color: item && item.tint ? item.tint : Theme.accent color: item && item.tint ? item.tint : Theme.accent
} }
@@ -86,9 +111,23 @@ Item {
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
color: Theme.iconWell 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 { NebulaIcon {
anchors.centerIn: parent anchors.centerIn: parent
name: item && (item.glyph || item.kind) ? (item.glyph || item.kind) : "folder" visible: rowIcon.status !== Image.Ready
name: root.fallbackGlyph
size: root.glyphSize size: root.glyphSize
color: item && item.tint ? item.tint : Theme.accent color: item && item.tint ? item.tint : Theme.accent
} }
@@ -1,5 +1,6 @@
import QtQuick import QtQuick
import Nebula.UI import Nebula.UI
import Nebula.Applications
Item { Item {
id: root id: root
@@ -13,8 +14,9 @@ Item {
Accessible.role: Accessible.Dialog Accessible.role: Accessible.Dialog
Accessible.name: qsTr("Launcher") Accessible.name: qsTr("Launcher")
readonly property bool usingInstalledApps: ApplicationService.count > 0
readonly property bool searching: searchField.text.trim().length > 0 readonly property bool searching: searchField.text.trim().length > 0
readonly property var filteredApps: { readonly property var mockMatches: {
const query = searchField.text.trim().toLowerCase() const query = searchField.text.trim().toLowerCase()
const apps = MockApps.apps const apps = MockApps.apps
if (!query) if (!query)
@@ -24,6 +26,9 @@ Item {
return haystack.indexOf(query) !== -1 return haystack.indexOf(query) !== -1
}) })
} }
readonly property int resultCount: usingInstalledApps
? ApplicationService.filteredModel.count
: mockMatches.length
Keys.onEscapePressed: { Keys.onEscapePressed: {
event.accepted = true event.accepted = true
@@ -62,6 +67,7 @@ Item {
id: searchField id: searchField
width: parent.width width: parent.width
placeholderText: qsTr("Search applications") placeholderText: qsTr("Search applications")
onTextChanged: ApplicationService.filter = text
} }
Item { Item {
@@ -87,7 +93,7 @@ Item {
Column { Column {
width: parent.width width: parent.width
spacing: 8 spacing: 8
visible: !root.searching && MockApps.recents.length > 0 visible: !root.searching && !root.usingInstalledApps && MockApps.recents.length > 0
Text { Text {
text: qsTr("Recent") text: qsTr("Recent")
@@ -133,14 +139,33 @@ Item {
Grid { Grid {
id: appGrid id: appGrid
visible: root.filteredApps.length > 0 visible: root.resultCount > 0
width: parent.width width: parent.width
columns: 4 columns: 4
columnSpacing: 6 columnSpacing: 6
rowSpacing: 6 rowSpacing: 6
Repeater { Repeater {
model: root.filteredApps 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 { delegate: AppItem {
required property var modelData required property var modelData
width: (appGrid.width - 18) / 4 width: (appGrid.width - 18) / 4
@@ -152,7 +177,7 @@ Item {
} }
Text { Text {
visible: root.filteredApps.length === 0 visible: root.resultCount === 0
width: parent.width width: parent.width
topPadding: 8 topPadding: 8
text: qsTr("No matching applications") text: qsTr("No matching applications")
@@ -165,7 +190,7 @@ Item {
Column { Column {
width: parent.width width: parent.width
spacing: 8 spacing: 8
visible: !root.searching && MockApps.projects.length > 0 visible: !root.searching && !root.usingInstalledApps && MockApps.projects.length > 0
Text { Text {
text: qsTr("Recent projects") text: qsTr("Recent projects")
@@ -286,10 +311,12 @@ Item {
} }
function launchApp(app) { function launchApp(app) {
ShellState.launch(app.name) if (app && app.desktopId)
ApplicationService.launch(app.desktopId)
ShellState.closeLauncher()
} }
function openProject(project) { function openProject(project) {
ShellState.launch(project.name) ShellState.closeLauncher()
} }
} }
@@ -6,12 +6,19 @@ import Nebula.UI
Window { Window {
id: root 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") title: qsTr("Nebula Desktop")
color: "transparent" color: "transparent"
flags: Qt.FramelessWindowHint | Qt.WindowDoesNotAcceptFocus flags: Qt.FramelessWindowHint | Qt.WindowDoesNotAcceptFocus
visible: true visible: true
width: Screen.width width: outputWidth
height: Screen.height height: outputHeight
LayerShell.Window.scope: "nebula-desktop" LayerShell.Window.scope: "nebula-desktop"
LayerShell.Window.layer: LayerShell.Window.LayerBackground LayerShell.Window.layer: LayerShell.Window.LayerBackground
@@ -6,12 +6,19 @@ import Nebula.UI
Window { Window {
id: root 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") title: qsTr("Nebula Launcher")
color: "transparent" color: "transparent"
flags: Qt.FramelessWindowHint flags: Qt.FramelessWindowHint
visible: ShellState.launcherOpen || launcher.opacity > 0.01 visible: ShellState.launcherOpen || launcher.opacity > 0.01
width: Screen.width width: outputWidth
height: Screen.height - Theme.topBarHeight height: Math.max(1, outputHeight - Theme.topBarHeight)
LayerShell.Window.scope: "nebula-launcher" LayerShell.Window.scope: "nebula-launcher"
LayerShell.Window.layer: LayerShell.Window.LayerOverlay LayerShell.Window.layer: LayerShell.Window.LayerOverlay
@@ -6,11 +6,15 @@ import Nebula.UI
Window { Window {
id: root id: root
readonly property int outputWidth: OutputTracker.width > 0
? OutputTracker.width
: (screen && screen.width > 0 ? screen.width : Screen.width)
title: qsTr("Nebula Top Bar") title: qsTr("Nebula Top Bar")
color: "transparent" color: "transparent"
flags: Qt.FramelessWindowHint | Qt.WindowDoesNotAcceptFocus flags: Qt.FramelessWindowHint | Qt.WindowDoesNotAcceptFocus
visible: true visible: true
width: Screen.width width: outputWidth
height: Theme.topBarHeight height: Theme.topBarHeight
LayerShell.Window.scope: "nebula-topbar" LayerShell.Window.scope: "nebula-topbar"
@@ -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;
}
@@ -0,0 +1,10 @@
#pragma once
#include <QQuickImageProvider>
class DesktopIconProvider : public QQuickImageProvider
{
public:
DesktopIconProvider();
QPixmap requestPixmap(const QString &id, QSize *size, const QSize &requestedSize) override;
};
@@ -0,0 +1,91 @@
#include "OutputTracker.hpp"
#include <QCoreApplication>
#include <QGuiApplication>
#include <QJSEngine>
#include <QRect>
OutputTracker::OutputTracker(QObject *parent)
: QObject(parent)
{
if (auto *app = qobject_cast<QGuiApplication *>(QCoreApplication::instance())) {
connect(app, &QGuiApplication::primaryScreenChanged, this, [this](QScreen *screen) {
bindScreen(screen);
});
connect(app, &QGuiApplication::screenAdded, this, [this](QScreen *) {
if (!m_screen)
bindScreen(QGuiApplication::primaryScreen());
});
connect(app, &QGuiApplication::screenRemoved, this, [this](QScreen *screen) {
if (m_screen == screen)
bindScreen(QGuiApplication::primaryScreen());
});
bindScreen(app->primaryScreen());
}
}
OutputTracker *OutputTracker::create(QQmlEngine *engine, QJSEngine *)
{
auto *tracker = new OutputTracker(engine);
QQmlEngine::setObjectOwnership(tracker, QQmlEngine::CppOwnership);
return tracker;
}
void OutputTracker::bindScreen(QScreen *screen)
{
if (m_screen == screen)
return;
if (m_screen) {
disconnect(m_screen, nullptr, this, nullptr);
}
m_screen = screen;
if (m_screen) {
connect(m_screen, &QScreen::geometryChanged, this, [this](const QRect &) {
logGeometry();
emit geometryChanged();
});
connect(m_screen, &QScreen::physicalDotsPerInchChanged, this, &OutputTracker::geometryChanged);
}
logGeometry();
emit geometryChanged();
}
void OutputTracker::logGeometry() const
{
if (!m_screen) {
qWarning("nebula-shell: no Wayland output is available yet");
return;
}
const QRect geo = m_screen->geometry();
qInfo("nebula-shell: output %s %dx%d+%d+%d scale %g",
qPrintable(m_screen->name()),
geo.width(),
geo.height(),
geo.x(),
geo.y(),
m_screen->devicePixelRatio());
}
int OutputTracker::width() const
{
return m_screen ? m_screen->geometry().width() : 0;
}
int OutputTracker::height() const
{
return m_screen ? m_screen->geometry().height() : 0;
}
qreal OutputTracker::devicePixelRatio() const
{
return m_screen ? m_screen->devicePixelRatio() : 1.0;
}
QString OutputTracker::name() const
{
return m_screen ? m_screen->name() : QString();
}
@@ -0,0 +1,38 @@
#pragma once
#include <QObject>
#include <QPointer>
#include <QQmlEngine>
#include <QScreen>
#include <QtQml/qqmlregistration.h>
class QJSEngine;
class OutputTracker : public QObject
{
Q_OBJECT
QML_ELEMENT
QML_SINGLETON
Q_PROPERTY(int width READ width NOTIFY geometryChanged)
Q_PROPERTY(int height READ height NOTIFY geometryChanged)
Q_PROPERTY(qreal devicePixelRatio READ devicePixelRatio NOTIFY geometryChanged)
Q_PROPERTY(QString name READ name NOTIFY geometryChanged)
public:
explicit OutputTracker(QObject *parent = nullptr);
static OutputTracker *create(QQmlEngine *engine, QJSEngine *scriptEngine);
int width() const;
int height() const;
qreal devicePixelRatio() const;
QString name() const;
signals:
void geometryChanged();
private:
void bindScreen(QScreen *screen);
void logGeometry() const;
QPointer<QScreen> m_screen;
};
+39
View File
@@ -1,11 +1,48 @@
#include "DesktopIconProvider.hpp"
#include <LayerShellQt/Shell> #include <LayerShellQt/Shell>
#include <QCoreApplication>
#include <QDir>
#include <QGuiApplication> #include <QGuiApplication>
#include <QIcon>
#include <QQmlApplicationEngine> #include <QQmlApplicationEngine>
#include <QQuickWindow> #include <QQuickWindow>
#include <QUrl> #include <QUrl>
#include <QtGlobal> #include <QtGlobal>
namespace {
void initializeIconTheme()
{
if (QIcon::themeName().isEmpty()) {
const QStringList candidates = {
QStringLiteral("Adwaita"),
QStringLiteral("Yaru"),
QStringLiteral("ubuntu-mono-dark"),
QStringLiteral("breeze"),
QStringLiteral("hicolor"),
};
const QStringList searchPaths = QIcon::themeSearchPaths();
for (const QString &theme : candidates) {
for (const QString &root : searchPaths) {
if (QDir(root + QLatin1Char('/') + theme).exists()) {
QIcon::setThemeName(theme);
break;
}
}
if (!QIcon::themeName().isEmpty())
break;
}
}
if (QIcon::fallbackThemeName().isEmpty())
QIcon::setFallbackThemeName(QStringLiteral("hicolor"));
}
} // namespace
int main(int argc, char *argv[]) int main(int argc, char *argv[])
{ {
LayerShellQt::Shell::useLayerShell(); LayerShellQt::Shell::useLayerShell();
@@ -16,10 +53,12 @@ int main(int argc, char *argv[])
app.setOrganizationName(QStringLiteral("NebulaOS")); app.setOrganizationName(QStringLiteral("NebulaOS"));
app.setDesktopFileName(QStringLiteral("org.nebulaos.shell")); app.setDesktopFileName(QStringLiteral("org.nebulaos.shell"));
initializeIconTheme();
QQuickWindow::setDefaultAlphaBuffer(true); QQuickWindow::setDefaultAlphaBuffer(true);
QQmlApplicationEngine engine; QQmlApplicationEngine engine;
engine.addImportPath(QStringLiteral("qrc:/qt/qml")); engine.addImportPath(QStringLiteral("qrc:/qt/qml"));
engine.addImageProvider(QStringLiteral("desktopicon"), new DesktopIconProvider);
QObject::connect( QObject::connect(
&engine, &engine,