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:
@@ -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
@@ -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.
|
||||
|
||||
@@ -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" "$@"
|
||||
@@ -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}" "$@"
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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()
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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`.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user