From b0c95b9e780f0c11e580da7d043632db3555b1f4 Mon Sep 17 00:00:00 2001 From: Andrew Zambazos Date: Wed, 26 Aug 2026 17:19:30 +1200 Subject: [PATCH] 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. --- README.md | 31 +- compositor/README.md | 60 ++- compositor/sway/dev/run-nebula-vm.sh | 5 + compositor/sway/dev/run-sway-vm.sh | 39 ++ compositor/sway/nebula-sway.conf | 51 ++- core/applications/ApplicationFilterModel.cpp | 64 +++ core/applications/ApplicationFilterModel.hpp | 30 ++ core/applications/ApplicationModel.cpp | 93 ++++ core/applications/ApplicationModel.hpp | 44 ++ core/applications/ApplicationService.cpp | 109 +++++ core/applications/ApplicationService.hpp | 42 ++ core/applications/CMakeLists.txt | 44 ++ core/applications/DesktopEntry.cpp | 413 ++++++++++++++++++ core/applications/DesktopEntry.hpp | 31 ++ core/applications/README.md | 18 + .../nebula-ui/qml/Nebula/UI/NebulaIcon.qml | 22 +- scripts/run-nebula-vm.sh | 67 +++ session/nebula-session | 13 +- shells/desktop/README.md | 167 +++++-- shells/desktop/shell/CMakeLists.txt | 15 + shells/desktop/shell/qml/ShellState.qml | 8 +- .../desktop/shell/qml/components/AppItem.qml | 43 +- .../desktop/shell/qml/components/Launcher.qml | 43 +- .../shell/qml/surfaces/DesktopSurface.qml | 11 +- .../shell/qml/surfaces/LauncherSurface.qml | 11 +- .../shell/qml/surfaces/TopBarSurface.qml | 6 +- .../desktop/shell/src/DesktopIconProvider.cpp | 87 ++++ .../desktop/shell/src/DesktopIconProvider.hpp | 10 + shells/desktop/shell/src/OutputTracker.cpp | 91 ++++ shells/desktop/shell/src/OutputTracker.hpp | 38 ++ shells/desktop/shell/src/main.cpp | 39 ++ 31 files changed, 1673 insertions(+), 72 deletions(-) create mode 100644 compositor/sway/dev/run-nebula-vm.sh create mode 100644 compositor/sway/dev/run-sway-vm.sh create mode 100644 core/applications/ApplicationFilterModel.cpp create mode 100644 core/applications/ApplicationFilterModel.hpp create mode 100644 core/applications/ApplicationModel.cpp create mode 100644 core/applications/ApplicationModel.hpp create mode 100644 core/applications/ApplicationService.cpp create mode 100644 core/applications/ApplicationService.hpp create mode 100644 core/applications/CMakeLists.txt create mode 100644 core/applications/DesktopEntry.cpp create mode 100644 core/applications/DesktopEntry.hpp create mode 100644 core/applications/README.md create mode 100644 scripts/run-nebula-vm.sh create mode 100644 shells/desktop/shell/src/DesktopIconProvider.cpp create mode 100644 shells/desktop/shell/src/DesktopIconProvider.hpp create mode 100644 shells/desktop/shell/src/OutputTracker.cpp create mode 100644 shells/desktop/shell/src/OutputTracker.hpp diff --git a/README.md b/README.md index f9b6e2b..b034353 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/compositor/README.md b/compositor/README.md index c52b0b3..10d8986 100644 --- a/compositor/README.md +++ b/compositor/README.md @@ -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 mode 1920x1080 +swaymsg output 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. diff --git a/compositor/sway/dev/run-nebula-vm.sh b/compositor/sway/dev/run-nebula-vm.sh new file mode 100644 index 0000000..20a25e8 --- /dev/null +++ b/compositor/sway/dev/run-nebula-vm.sh @@ -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" "$@" diff --git a/compositor/sway/dev/run-sway-vm.sh b/compositor/sway/dev/run-sway-vm.sh new file mode 100644 index 0000000..72d9bc4 --- /dev/null +++ b/compositor/sway/dev/run-sway-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}" "$@" diff --git a/compositor/sway/nebula-sway.conf b/compositor/sway/nebula-sway.conf index 3f54d46..1fb979d 100644 --- a/compositor/sway/nebula-sway.conf +++ b/compositor/sway/nebula-sway.conf @@ -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 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. \ No newline at end of file diff --git a/core/applications/ApplicationFilterModel.cpp b/core/applications/ApplicationFilterModel.cpp new file mode 100644 index 0000000..ad14140 --- /dev/null +++ b/core/applications/ApplicationFilterModel.cpp @@ -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; +} diff --git a/core/applications/ApplicationFilterModel.hpp b/core/applications/ApplicationFilterModel.hpp new file mode 100644 index 0000000..758b73e --- /dev/null +++ b/core/applications/ApplicationFilterModel.hpp @@ -0,0 +1,30 @@ +#pragma once + +#include +#include + +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; +}; diff --git a/core/applications/ApplicationModel.cpp b/core/applications/ApplicationModel.cpp new file mode 100644 index 0000000..9849aac --- /dev/null +++ b/core/applications/ApplicationModel.cpp @@ -0,0 +1,93 @@ +#include "ApplicationModel.hpp" + +#include +#include + +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 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 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; +} diff --git a/core/applications/ApplicationModel.hpp b/core/applications/ApplicationModel.hpp new file mode 100644 index 0000000..01b8f93 --- /dev/null +++ b/core/applications/ApplicationModel.hpp @@ -0,0 +1,44 @@ +#pragma once + +#include "DesktopEntry.hpp" + +#include +#include +#include + +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 roleNames() const override; + + int count() const; + void setEntries(QVector entries); + const DesktopEntry *entryAt(int row) const; + const DesktopEntry *entryById(const QString &desktopId) const; + +signals: + void countChanged(); + +private: + QVector m_entries; +}; diff --git a/core/applications/ApplicationService.cpp b/core/applications/ApplicationService.cpp new file mode 100644 index 0000000..06822de --- /dev/null +++ b/core/applications/ApplicationService.cpp @@ -0,0 +1,109 @@ +#include "ApplicationService.hpp" + +#include "DesktopEntry.hpp" + +#include +#include +#include +#include +#include +#include + +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 entries; + QSet 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)); +} diff --git a/core/applications/ApplicationService.hpp b/core/applications/ApplicationService.hpp new file mode 100644 index 0000000..14b44d3 --- /dev/null +++ b/core/applications/ApplicationService.hpp @@ -0,0 +1,42 @@ +#pragma once + +#include "ApplicationFilterModel.hpp" +#include "ApplicationModel.hpp" + +#include +#include +#include +#include + +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; +}; diff --git a/core/applications/CMakeLists.txt b/core/applications/CMakeLists.txt new file mode 100644 index 0000000..2c73ae3 --- /dev/null +++ b/core/applications/CMakeLists.txt @@ -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() diff --git a/core/applications/DesktopEntry.cpp b/core/applications/DesktopEntry.cpp new file mode 100644 index 0000000..a303436 --- /dev/null +++ b/core/applications/DesktopEntry.cpp @@ -0,0 +1,413 @@ +#include "DesktopEntry.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +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 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(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; +} diff --git a/core/applications/DesktopEntry.hpp b/core/applications/DesktopEntry.hpp new file mode 100644 index 0000000..5ad8f5a --- /dev/null +++ b/core/applications/DesktopEntry.hpp @@ -0,0 +1,31 @@ +#pragma once + +#include +#include + +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); diff --git a/core/applications/README.md b/core/applications/README.md new file mode 100644 index 0000000..a2078b1 --- /dev/null +++ b/core/applications/README.md @@ -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. + diff --git a/packages/nebula-ui/qml/Nebula/UI/NebulaIcon.qml b/packages/nebula-ui/qml/Nebula/UI/NebulaIcon.qml index 1c946be..f53500e 100644 --- a/packages/nebula-ui/qml/Nebula/UI/NebulaIcon.qml +++ b/packages/nebula-ui/qml/Nebula/UI/NebulaIcon.qml @@ -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) diff --git a/scripts/run-nebula-vm.sh b/scripts/run-nebula-vm.sh new file mode 100644 index 0000000..c9cdef5 --- /dev/null +++ b/scripts/run-nebula-vm.sh @@ -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}" "$@" diff --git a/session/nebula-session b/session/nebula-session index 1b5c773..307d73d 100644 --- a/session/nebula-session +++ b/session/nebula-session @@ -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 diff --git a/shells/desktop/README.md b/shells/desktop/README.md index f809f9c..1ea078a 100644 --- a/shells/desktop/README.md +++ b/shells/desktop/README.md @@ -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 mode 1920x1080 +swaymsg output 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`. diff --git a/shells/desktop/shell/CMakeLists.txt b/shells/desktop/shell/CMakeLists.txt index db3cee6..bd621aa 100644 --- a/shells/desktop/shell/CMakeLists.txt +++ b/shells/desktop/shell/CMakeLists.txt @@ -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 diff --git a/shells/desktop/shell/qml/ShellState.qml b/shells/desktop/shell/qml/ShellState.qml index 4041453..706b90c 100644 --- a/shells/desktop/shell/qml/ShellState.qml +++ b/shells/desktop/shell/qml/ShellState.qml @@ -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() - } } diff --git a/shells/desktop/shell/qml/components/AppItem.qml b/shells/desktop/shell/qml/components/AppItem.qml index 3e2b80e..504b899 100644 --- a/shells/desktop/shell/qml/components/AppItem.qml +++ b/shells/desktop/shell/qml/components/AppItem.qml @@ -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 } diff --git a/shells/desktop/shell/qml/components/Launcher.qml b/shells/desktop/shell/qml/components/Launcher.qml index f3c0eaf..8e1782e 100644 --- a/shells/desktop/shell/qml/components/Launcher.qml +++ b/shells/desktop/shell/qml/components/Launcher.qml @@ -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() } } diff --git a/shells/desktop/shell/qml/surfaces/DesktopSurface.qml b/shells/desktop/shell/qml/surfaces/DesktopSurface.qml index 233fa98..7e29174 100644 --- a/shells/desktop/shell/qml/surfaces/DesktopSurface.qml +++ b/shells/desktop/shell/qml/surfaces/DesktopSurface.qml @@ -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 diff --git a/shells/desktop/shell/qml/surfaces/LauncherSurface.qml b/shells/desktop/shell/qml/surfaces/LauncherSurface.qml index a0a756f..35c63a2 100644 --- a/shells/desktop/shell/qml/surfaces/LauncherSurface.qml +++ b/shells/desktop/shell/qml/surfaces/LauncherSurface.qml @@ -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 diff --git a/shells/desktop/shell/qml/surfaces/TopBarSurface.qml b/shells/desktop/shell/qml/surfaces/TopBarSurface.qml index 8187f8f..beb6aec 100644 --- a/shells/desktop/shell/qml/surfaces/TopBarSurface.qml +++ b/shells/desktop/shell/qml/surfaces/TopBarSurface.qml @@ -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" diff --git a/shells/desktop/shell/src/DesktopIconProvider.cpp b/shells/desktop/shell/src/DesktopIconProvider.cpp new file mode 100644 index 0000000..bf5e9e5 --- /dev/null +++ b/shells/desktop/shell/src/DesktopIconProvider.cpp @@ -0,0 +1,87 @@ +#include "DesktopIconProvider.hpp" + +#include +#include +#include +#include + +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; +} diff --git a/shells/desktop/shell/src/DesktopIconProvider.hpp b/shells/desktop/shell/src/DesktopIconProvider.hpp new file mode 100644 index 0000000..216c68c --- /dev/null +++ b/shells/desktop/shell/src/DesktopIconProvider.hpp @@ -0,0 +1,10 @@ +#pragma once + +#include + +class DesktopIconProvider : public QQuickImageProvider +{ +public: + DesktopIconProvider(); + QPixmap requestPixmap(const QString &id, QSize *size, const QSize &requestedSize) override; +}; diff --git a/shells/desktop/shell/src/OutputTracker.cpp b/shells/desktop/shell/src/OutputTracker.cpp new file mode 100644 index 0000000..efb73e2 --- /dev/null +++ b/shells/desktop/shell/src/OutputTracker.cpp @@ -0,0 +1,91 @@ +#include "OutputTracker.hpp" + +#include +#include +#include +#include + +OutputTracker::OutputTracker(QObject *parent) + : QObject(parent) +{ + if (auto *app = qobject_cast(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(); +} diff --git a/shells/desktop/shell/src/OutputTracker.hpp b/shells/desktop/shell/src/OutputTracker.hpp new file mode 100644 index 0000000..dcf7697 --- /dev/null +++ b/shells/desktop/shell/src/OutputTracker.hpp @@ -0,0 +1,38 @@ +#pragma once + +#include +#include +#include +#include +#include + +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 m_screen; +}; diff --git a/shells/desktop/shell/src/main.cpp b/shells/desktop/shell/src/main.cpp index 1525973..ef4d5e4 100644 --- a/shells/desktop/shell/src/main.cpp +++ b/shells/desktop/shell/src/main.cpp @@ -1,11 +1,48 @@ +#include "DesktopIconProvider.hpp" + #include +#include +#include #include +#include #include #include #include #include +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,