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 |
|------|---------|
| `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) |
| `shells/` | Desktop and future Bigscreen shells |
| `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 |
| `packaging/` | Distribution packaging (Ubuntu) |
| `branding/` | Boot assets, wallpapers, and sounds |
| `scripts/` | Development helpers (including the VMware shell wrapper) |
## 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.
@@ -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.
## 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.
+59 -1
View File
@@ -7,7 +7,7 @@ Sway currently provides:
- Wayland compositor
- application window management
- XWayland
- input
- input and the pointer cursor
- output handling
Nebula provides the visible shell (Qt Quick/QML via LayerShellQt). The Sway config must not enable a Sway bar.
@@ -25,3 +25,61 @@ Nebula compositor later
Nebula Shell
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
# 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
# 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
# Exit compositor
bindsym $mod+Shift+e exec swaymsg exit
# Reload configuration
bindsym $mod+Shift+c reload
# Remove Sway window decorations
@@ -21,6 +57,3 @@ default_floating_border pixel 0
# Basic floating controls for development
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":
paintDocument(ctx)
break
case "application":
paintApplication(ctx)
break
default:
paintFolder(ctx)
paintApplication(ctx)
break
}
}
@@ -189,6 +192,23 @@ Item {
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)
+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
# 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
export XDG_CURRENT_DESKTOP=NebulaOS
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
+133 -34
View File
@@ -1,6 +1,8 @@
# 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
@@ -34,25 +36,15 @@ Qt Quick/QML
Normal applications remain ordinary Wayland / XWayland clients of the compositor:
```text
Normal Application
Nebula Launcher
ApplicationService
application process
Wayland / XWayland
Compositor
GPU
```
Nebula chrome:
```text
QML
Qt Quick
Wayland Layer Shell
Compositor
Sway
```
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 |
| UI | Qt 6, Qt Quick, QML |
| Layer surfaces | LayerShellQt |
| Installed apps | `core/applications` (`import Nebula.Applications`) |
| Build | CMake |
| Shared design system | `packages/nebula-ui` (`import Nebula.UI`) |
@@ -77,9 +70,9 @@ Independent Wayland layer-shell windows:
| Surface | Layer | Role |
|---------|-------|------|
| Desktop | `BACKGROUND` | Wallpaper / desktop, no exclusive zone, no keyboard focus |
| Top Bar | `TOP` | 40px bar, exclusive zone, no keyboard focus by default |
| Launcher | `OVERLAY` | Hidden until the Nebula button opens it |
| 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:
@@ -91,12 +84,33 @@ TOP
Nebula Top Bar
NORMAL WAYLAND WINDOWS
Firefox, Steam, Blender, games, terminals, …
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.
## 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
```text
@@ -104,12 +118,12 @@ shells/desktop/
├── README.md this file
├── shell/ production Qt/QML shell
│ ├── CMakeLists.txt
│ ├── src/main.cpp
│ ├── src/
│ └── qml/
└── 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
@@ -126,6 +140,7 @@ sudo apt install \
qml6-module-org-kde-layershell \
qml6-module-qtquick \
qml6-module-qtquick-window \
adwaita-icon-theme \
cmake \
build-essential
```
@@ -135,14 +150,12 @@ 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)
./build/nebula-shell
```
Run inside a Wayland compositor such as Sway (`compositor/sway/nebula-sway.conf`). Sway must not show its own bar.
There is no npm server, Vite server, `NEBULA_SHELL_DEV_URL`, WebKitGTK, GTK4, or React involved in running the production shell.
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.
### QML lint
@@ -159,6 +172,91 @@ qmllint \
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
`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:
- Sway IPC / focused-window tracking
- workspaces, window overview, dock
- PipeWire, NetworkManager, BlueZ, UPower
- D-Bus services
- real `.desktop` discovery or app launching
- Sway IPC, workspaces, notifications
- authentication, greetd, Gamescope
- 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` 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"
)
add_subdirectory(
"${CMAKE_CURRENT_SOURCE_DIR}/../../../core/applications"
"${CMAKE_CURRENT_BINARY_DIR}/nebula-applications"
)
qt_add_executable(nebula-shell
src/main.cpp
src/DesktopIconProvider.cpp
src/DesktopIconProvider.hpp
)
set(NEBULA_SHELL_QML_FILES
@@ -65,9 +72,13 @@ qt_add_qml_module(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
)
target_link_libraries(nebula-shell
@@ -78,11 +89,15 @@ target_link_libraries(nebula-shell
Qt6::Quick
LayerShellQt::Interface
NebulaUI
NebulaApplications
)
if(TARGET NebulaUIplugin)
target_link_libraries(nebula-shell PRIVATE NebulaUIplugin)
endif()
if(TARGET NebulaApplicationsplugin)
target_link_libraries(nebula-shell PRIVATE NebulaApplicationsplugin)
endif()
set_target_properties(nebula-shell PROPERTIES
OUTPUT_NAME nebula-shell
+3 -5
View File
@@ -4,6 +4,9 @@ import QtQuick
QtObject {
property bool launcherOpen: false
// Focused-window tracking is a later compositor milestone. Launching an
// application does not mean it has focus, so this stays "Desktop" for now.
property string activeApplication: "Desktop"
function openLauncher() {
@@ -17,9 +20,4 @@ QtObject {
function toggleLauncher() {
launcherOpen = !launcherOpen
}
function launch(name) {
activeApplication = name
closeLauncher()
}
}
@@ -11,6 +11,17 @@ 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
@@ -54,9 +65,23 @@ Item {
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
name: item && item.glyph ? item.glyph : "folder"
visible: tileIcon.status !== Image.Ready
name: root.fallbackGlyph
size: root.glyphSize
color: item && item.tint ? item.tint : Theme.accent
}
@@ -86,9 +111,23 @@ Item {
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
name: item && (item.glyph || item.kind) ? (item.glyph || item.kind) : "folder"
visible: rowIcon.status !== Image.Ready
name: root.fallbackGlyph
size: root.glyphSize
color: item && item.tint ? item.tint : Theme.accent
}
@@ -1,5 +1,6 @@
import QtQuick
import Nebula.UI
import Nebula.Applications
Item {
id: root
@@ -13,8 +14,9 @@ Item {
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 filteredApps: {
readonly property var mockMatches: {
const query = searchField.text.trim().toLowerCase()
const apps = MockApps.apps
if (!query)
@@ -24,6 +26,9 @@ Item {
return haystack.indexOf(query) !== -1
})
}
readonly property int resultCount: usingInstalledApps
? ApplicationService.filteredModel.count
: mockMatches.length
Keys.onEscapePressed: {
event.accepted = true
@@ -62,6 +67,7 @@ Item {
id: searchField
width: parent.width
placeholderText: qsTr("Search applications")
onTextChanged: ApplicationService.filter = text
}
Item {
@@ -87,7 +93,7 @@ Item {
Column {
width: parent.width
spacing: 8
visible: !root.searching && MockApps.recents.length > 0
visible: !root.searching && !root.usingInstalledApps && MockApps.recents.length > 0
Text {
text: qsTr("Recent")
@@ -133,14 +139,33 @@ Item {
Grid {
id: appGrid
visible: root.filteredApps.length > 0
visible: root.resultCount > 0
width: parent.width
columns: 4
columnSpacing: 6
rowSpacing: 6
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 {
required property var modelData
width: (appGrid.width - 18) / 4
@@ -152,7 +177,7 @@ Item {
}
Text {
visible: root.filteredApps.length === 0
visible: root.resultCount === 0
width: parent.width
topPadding: 8
text: qsTr("No matching applications")
@@ -165,7 +190,7 @@ Item {
Column {
width: parent.width
spacing: 8
visible: !root.searching && MockApps.projects.length > 0
visible: !root.searching && !root.usingInstalledApps && MockApps.projects.length > 0
Text {
text: qsTr("Recent projects")
@@ -286,10 +311,12 @@ Item {
}
function launchApp(app) {
ShellState.launch(app.name)
if (app && app.desktopId)
ApplicationService.launch(app.desktopId)
ShellState.closeLauncher()
}
function openProject(project) {
ShellState.launch(project.name)
ShellState.closeLauncher()
}
}
@@ -6,12 +6,19 @@ 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: Screen.width
height: Screen.height
width: outputWidth
height: outputHeight
LayerShell.Window.scope: "nebula-desktop"
LayerShell.Window.layer: LayerShell.Window.LayerBackground
@@ -6,12 +6,19 @@ 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: Screen.width
height: Screen.height - Theme.topBarHeight
width: outputWidth
height: Math.max(1, outputHeight - Theme.topBarHeight)
LayerShell.Window.scope: "nebula-launcher"
LayerShell.Window.layer: LayerShell.Window.LayerOverlay
@@ -6,11 +6,15 @@ 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: Screen.width
width: outputWidth
height: Theme.topBarHeight
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 <QCoreApplication>
#include <QDir>
#include <QGuiApplication>
#include <QIcon>
#include <QQmlApplicationEngine>
#include <QQuickWindow>
#include <QUrl>
#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[])
{
LayerShellQt::Shell::useLayerShell();
@@ -16,10 +53,12 @@ int main(int argc, char *argv[])
app.setOrganizationName(QStringLiteral("NebulaOS"));
app.setDesktopFileName(QStringLiteral("org.nebulaos.shell"));
initializeIconTheme();
QQuickWindow::setDefaultAlphaBuffer(true);
QQmlApplicationEngine engine;
engine.addImportPath(QStringLiteral("qrc:/qt/qml"));
engine.addImageProvider(QStringLiteral("desktopicon"), new DesktopIconProvider);
QObject::connect(
&engine,