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 |
|
| Path | Purpose |
|
||||||
|------|---------|
|
|------|---------|
|
||||||
| `core/nebula-core` | Core runtime and platform APIs |
|
| `core/nebula-core` | Core runtime and platform APIs (growing) |
|
||||||
|
| `core/applications` | Installed `.desktop` discovery and launching |
|
||||||
| `compositor/` | Compositor configuration (Sway is temporary) |
|
| `compositor/` | Compositor configuration (Sway is temporary) |
|
||||||
| `shells/` | Desktop and future Bigscreen shells |
|
| `shells/` | Desktop and future Bigscreen shells |
|
||||||
| `packages/` | Shared UI (`Nebula.UI`), icons, API, and design tokens |
|
| `packages/` | Shared UI (`Nebula.UI`), icons, API, and design tokens |
|
||||||
@@ -14,12 +15,13 @@ A Linux desktop environment built around a custom compositor, shell, and service
|
|||||||
| `session/` | Session manager and desktop entry |
|
| `session/` | Session manager and desktop entry |
|
||||||
| `packaging/` | Distribution packaging (Ubuntu) |
|
| `packaging/` | Distribution packaging (Ubuntu) |
|
||||||
| `branding/` | Boot assets, wallpapers, and sounds |
|
| `branding/` | Boot assets, wallpapers, and sounds |
|
||||||
|
| `scripts/` | Development helpers (including the VMware shell wrapper) |
|
||||||
|
|
||||||
## Shells
|
## Shells
|
||||||
|
|
||||||
**Nebula Desktop** (production) is a C++20 Qt Quick/QML Wayland shell using LayerShellQt. See `shells/desktop/`.
|
**Nebula Desktop 0.1** is a C++20 Qt Quick/QML Wayland shell using LayerShellQt. It can discover installed applications and launch them (including Foot) from the GUI. See `shells/desktop/`.
|
||||||
|
|
||||||
**Nebula Bigscreen** is planned as a separate Qt Quick/QML shell with a controller-first interaction model, sharing `Nebula.UI` and Nebula Core.
|
**Nebula Bigscreen** is planned as a separate Qt Quick/QML shell with a controller-first interaction model, sharing `Nebula.UI` and `Nebula.Applications`.
|
||||||
|
|
||||||
Standalone Nebula applications may still use React/Tauri where that is a better fit. The operating-system shell does not.
|
Standalone Nebula applications may still use React/Tauri where that is a better fit. The operating-system shell does not.
|
||||||
|
|
||||||
@@ -39,8 +41,27 @@ Nebula Shell Qt Quick/QML
|
|||||||
|
|
||||||
Applications and games render on their own Wayland/XWayland paths. They are not composited inside QML.
|
Applications and games render on their own Wayland/XWayland paths. They are not composited inside QML.
|
||||||
|
|
||||||
## Getting started
|
## Getting started (development VM)
|
||||||
|
|
||||||
Desktop shell build instructions: `shells/desktop/README.md`.
|
```bash
|
||||||
|
cd shells/desktop/shell
|
||||||
|
rm -rf build
|
||||||
|
cmake -S . -B build
|
||||||
|
cmake --build build -j$(nproc)
|
||||||
|
```
|
||||||
|
|
||||||
|
Then, from the repository root:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./compositor/sway/dev/run-sway-vm.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
or:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sway -c compositor/sway/nebula-sway.conf
|
||||||
|
```
|
||||||
|
|
||||||
|
The VMware wrapper sets `QT_QUICK_BACKEND=software`. Production `nebula-shell` does not. Details: `shells/desktop/README.md` and `compositor/README.md`.
|
||||||
|
|
||||||
The Qt shell must be compiled on Linux (the Ubuntu NebulaOS VM). It will not build on Windows.
|
The Qt shell must be compiled on Linux (the Ubuntu NebulaOS VM). It will not build on Windows.
|
||||||
|
|||||||
+59
-1
@@ -7,7 +7,7 @@ Sway currently provides:
|
|||||||
- Wayland compositor
|
- Wayland compositor
|
||||||
- application window management
|
- application window management
|
||||||
- XWayland
|
- XWayland
|
||||||
- input
|
- input and the pointer cursor
|
||||||
- output handling
|
- output handling
|
||||||
|
|
||||||
Nebula provides the visible shell (Qt Quick/QML via LayerShellQt). The Sway config must not enable a Sway bar.
|
Nebula provides the visible shell (Qt Quick/QML via LayerShellQt). The Sway config must not enable a Sway bar.
|
||||||
@@ -25,3 +25,61 @@ Nebula compositor later
|
|||||||
Nebula Shell
|
Nebula Shell
|
||||||
Qt Quick/QML
|
Qt Quick/QML
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Development session
|
||||||
|
|
||||||
|
From the NebulaOS repository root:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sway -c compositor/sway/nebula-sway.conf
|
||||||
|
```
|
||||||
|
|
||||||
|
Recommended on the Ubuntu VMware VM (repo-root `cd`, cursor environment, software cursors):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./compositor/sway/dev/run-sway-vm.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Sway then autostarts Nebula Desktop through `scripts/run-nebula-vm.sh`.
|
||||||
|
|
||||||
|
Skip shell autostart:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
NEBULA_SHELL_AUTOSTART=0 ./compositor/sway/dev/run-sway-vm.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### Output / resolution
|
||||||
|
|
||||||
|
The development config uses scale `1` and requests `1920x1080` when that mode is advertised. If it is not, Sway keeps the preferred mode. This is a VM convenience, not a production requirement.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
swaymsg -t get_outputs
|
||||||
|
swaymsg output <name> mode 1920x1080
|
||||||
|
swaymsg output <name> scale 1
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cursor
|
||||||
|
|
||||||
|
```text
|
||||||
|
seat * xcursor_theme Adwaita 24
|
||||||
|
XCURSOR_THEME=Adwaita
|
||||||
|
XCURSOR_SIZE=24
|
||||||
|
```
|
||||||
|
|
||||||
|
Requires `adwaita-icon-theme`. A Nebula-branded cursor theme is a later task.
|
||||||
|
|
||||||
|
`WLR_NO_HARDWARE_CURSORS=1` is VMware-specific and is set by `run-sway-vm.sh`, not by production session files.
|
||||||
|
|
||||||
|
### Emergency shortcuts
|
||||||
|
|
||||||
|
| Shortcut | Action |
|
||||||
|
|----------|--------|
|
||||||
|
| Super+Enter | open Foot |
|
||||||
|
| Super+Shift+E | exit Sway |
|
||||||
|
| Super+Shift+C | reload Sway config |
|
||||||
|
|
||||||
|
Normal application launching is done from the Nebula launcher, not these shortcuts.
|
||||||
|
|
||||||
|
### Rendering
|
||||||
|
|
||||||
|
`scripts/run-nebula-vm.sh` sets `QT_QUICK_BACKEND=software` for the VMware guest GPU. Production `nebula-shell` must not set that variable.
|
||||||
|
|||||||
@@ -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
|
set $term foot
|
||||||
|
|
||||||
# NebulaOS temporary development compositor configuration
|
# NebulaOS temporary development compositor configuration
|
||||||
|
#
|
||||||
|
# Sway is invisible infrastructure. Nebula Desktop is the visible shell.
|
||||||
|
# Do NOT add a bar block; Nebula provides its own top bar.
|
||||||
|
|
||||||
# No Sway wallpaper or UI
|
# -----------------------------------------------------------------------------
|
||||||
|
# Output / resolution
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Development default: 1:1 scale, fill the current VMware display.
|
||||||
|
# Request 1920x1080 when the output advertises that mode. If the mode is not
|
||||||
|
# available, Sway keeps the output's preferred mode instead of failing.
|
||||||
|
#
|
||||||
|
# This is a development convenience, not a production architecture requirement.
|
||||||
|
# Inspect modes: swaymsg -t get_outputs
|
||||||
|
# Force another mode (example): swaymsg output <name> mode 1600x900
|
||||||
|
output * scale 1
|
||||||
|
output * mode 1920x1080
|
||||||
output * bg #000000 solid_color
|
output * bg #000000 solid_color
|
||||||
|
|
||||||
# Development terminal
|
# -----------------------------------------------------------------------------
|
||||||
|
# Cursor
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# The compositor owns the pointer cursor, not QML.
|
||||||
|
# A branded Nebula cursor theme is a later task.
|
||||||
|
seat * xcursor_theme Adwaita 24
|
||||||
|
# If the cursor is invisible in VMware, start Sway with:
|
||||||
|
# WLR_NO_HARDWARE_CURSORS=1 sway -c compositor/sway/nebula-sway.conf
|
||||||
|
# compositor/sway/dev/run-sway-vm.sh sets that automatically.
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Nebula shell autostart
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Starts nebula-shell through the VMware development wrapper, which sets
|
||||||
|
# QT_QUICK_BACKEND=software. Production sessions must start nebula-shell
|
||||||
|
# without that variable.
|
||||||
|
#
|
||||||
|
# Disable autostart for debugging:
|
||||||
|
# NEBULA_SHELL_AUTOSTART=0 sway -c compositor/sway/nebula-sway.conf
|
||||||
|
#
|
||||||
|
# Run from the repository root so this relative path resolves, or use
|
||||||
|
# compositor/sway/dev/run-sway-vm.sh which does that for you.
|
||||||
|
exec /bin/sh scripts/run-nebula-vm.sh
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Emergency development shortcuts (not the normal UX)
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
bindsym $mod+Return exec $term
|
bindsym $mod+Return exec $term
|
||||||
|
|
||||||
# Exit compositor
|
|
||||||
bindsym $mod+Shift+e exec swaymsg exit
|
bindsym $mod+Shift+e exec swaymsg exit
|
||||||
|
|
||||||
# Reload configuration
|
|
||||||
bindsym $mod+Shift+c reload
|
bindsym $mod+Shift+c reload
|
||||||
|
|
||||||
# Remove Sway window decorations
|
# Remove Sway window decorations
|
||||||
@@ -21,6 +57,3 @@ default_floating_border pixel 0
|
|||||||
|
|
||||||
# Basic floating controls for development
|
# Basic floating controls for development
|
||||||
floating_modifier $mod normal
|
floating_modifier $mod normal
|
||||||
|
|
||||||
# Do NOT add a bar block.
|
|
||||||
# Nebula provides its own top bar.
|
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
#include "ApplicationFilterModel.hpp"
|
||||||
|
#include "ApplicationModel.hpp"
|
||||||
|
|
||||||
|
ApplicationFilterModel::ApplicationFilterModel(QObject *parent)
|
||||||
|
: QSortFilterProxyModel(parent)
|
||||||
|
{
|
||||||
|
setDynamicSortFilter(true);
|
||||||
|
setFilterCaseSensitivity(Qt::CaseInsensitive);
|
||||||
|
|
||||||
|
connect(this, &QAbstractItemModel::modelReset, this, &ApplicationFilterModel::countChanged);
|
||||||
|
connect(this, &QAbstractItemModel::rowsInserted, this, &ApplicationFilterModel::countChanged);
|
||||||
|
connect(this, &QAbstractItemModel::rowsRemoved, this, &ApplicationFilterModel::countChanged);
|
||||||
|
connect(this, &QAbstractItemModel::layoutChanged, this, &ApplicationFilterModel::countChanged);
|
||||||
|
}
|
||||||
|
|
||||||
|
QString ApplicationFilterModel::filter() const
|
||||||
|
{
|
||||||
|
return m_filter;
|
||||||
|
}
|
||||||
|
|
||||||
|
void ApplicationFilterModel::setFilter(const QString &filter)
|
||||||
|
{
|
||||||
|
const QString trimmed = filter.trimmed();
|
||||||
|
if (m_filter == trimmed)
|
||||||
|
return;
|
||||||
|
|
||||||
|
m_filter = trimmed;
|
||||||
|
invalidateFilter();
|
||||||
|
emit filterChanged();
|
||||||
|
emit countChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
int ApplicationFilterModel::count() const
|
||||||
|
{
|
||||||
|
return rowCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ApplicationFilterModel::filterAcceptsRow(int sourceRow, const QModelIndex &sourceParent) const
|
||||||
|
{
|
||||||
|
if (m_filter.isEmpty())
|
||||||
|
return true;
|
||||||
|
|
||||||
|
const QModelIndex index = sourceModel()->index(sourceRow, 0, sourceParent);
|
||||||
|
if (!index.isValid())
|
||||||
|
return false;
|
||||||
|
|
||||||
|
const auto matches = [this, &index](int role) {
|
||||||
|
return index.data(role).toString().contains(m_filter, Qt::CaseInsensitive);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (matches(ApplicationModel::NameRole)
|
||||||
|
|| matches(ApplicationModel::GenericNameRole)
|
||||||
|
|| matches(ApplicationModel::CommentRole)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QStringList categories = index.data(ApplicationModel::CategoriesRole).toStringList();
|
||||||
|
for (const QString &category : categories) {
|
||||||
|
if (category.contains(m_filter, Qt::CaseInsensitive))
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QSortFilterProxyModel>
|
||||||
|
#include <QtQml/qqmlregistration.h>
|
||||||
|
|
||||||
|
class ApplicationFilterModel : public QSortFilterProxyModel
|
||||||
|
{
|
||||||
|
Q_OBJECT
|
||||||
|
QML_ELEMENT
|
||||||
|
QML_UNCREATABLE("Obtained from ApplicationService")
|
||||||
|
Q_PROPERTY(QString filter READ filter WRITE setFilter NOTIFY filterChanged)
|
||||||
|
Q_PROPERTY(int count READ count NOTIFY countChanged)
|
||||||
|
|
||||||
|
public:
|
||||||
|
explicit ApplicationFilterModel(QObject *parent = nullptr);
|
||||||
|
|
||||||
|
QString filter() const;
|
||||||
|
void setFilter(const QString &filter);
|
||||||
|
int count() const;
|
||||||
|
|
||||||
|
signals:
|
||||||
|
void filterChanged();
|
||||||
|
void countChanged();
|
||||||
|
|
||||||
|
protected:
|
||||||
|
bool filterAcceptsRow(int sourceRow, const QModelIndex &sourceParent) const override;
|
||||||
|
|
||||||
|
private:
|
||||||
|
QString m_filter;
|
||||||
|
};
|
||||||
@@ -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":
|
case "document":
|
||||||
paintDocument(ctx)
|
paintDocument(ctx)
|
||||||
break
|
break
|
||||||
|
case "application":
|
||||||
|
paintApplication(ctx)
|
||||||
|
break
|
||||||
default:
|
default:
|
||||||
paintFolder(ctx)
|
paintApplication(ctx)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -189,6 +192,23 @@ Item {
|
|||||||
ctx.stroke()
|
ctx.stroke()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function paintApplication(ctx) {
|
||||||
|
ctx.lineWidth = 1.5
|
||||||
|
ctx.beginPath()
|
||||||
|
roundedRect(ctx, 4.2, 5.2, 15.6, 13.6, 2.2)
|
||||||
|
ctx.stroke()
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.moveTo(4.2, 8.4)
|
||||||
|
ctx.lineTo(19.8, 8.4)
|
||||||
|
ctx.stroke()
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.arc(7, 6.8, 0.7, 0, Math.PI * 2)
|
||||||
|
ctx.fill()
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.arc(9.4, 6.8, 0.7, 0, Math.PI * 2)
|
||||||
|
ctx.fill()
|
||||||
|
}
|
||||||
|
|
||||||
function paintFolder(ctx) {
|
function paintFolder(ctx) {
|
||||||
ctx.beginPath()
|
ctx.beginPath()
|
||||||
ctx.moveTo(3.5, 8.2)
|
ctx.moveTo(3.5, 8.2)
|
||||||
|
|||||||
@@ -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
|
#!/bin/sh
|
||||||
# NebulaOS session startup
|
# NebulaOS production session startup
|
||||||
|
#
|
||||||
|
# Production must start nebula-shell without QT_QUICK_BACKEND=software.
|
||||||
|
# GPU-accelerated Qt Quick is the production default.
|
||||||
|
#
|
||||||
|
# The Ubuntu VMware development VM is different: use
|
||||||
|
# compositor/sway/dev/run-sway-vm.sh (or sway -c compositor/sway/nebula-sway.conf
|
||||||
|
# from the repository root) so the development wrapper can set the software
|
||||||
|
# scene-graph backend.
|
||||||
|
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
export XDG_CURRENT_DESKTOP=NebulaOS
|
export XDG_CURRENT_DESKTOP=NebulaOS
|
||||||
export XDG_SESSION_DESKTOP=nebula
|
export XDG_SESSION_DESKTOP=nebula
|
||||||
|
export XCURSOR_THEME="${XCURSOR_THEME:-Adwaita}"
|
||||||
|
export XCURSOR_SIZE="${XCURSOR_SIZE:-24}"
|
||||||
|
|
||||||
# Shell and services are wired here as they land
|
|
||||||
# Production desktop chrome: shells/desktop/shell (nebula-shell)
|
# Production desktop chrome: shells/desktop/shell (nebula-shell)
|
||||||
# exec nebula-shell
|
# exec nebula-shell
|
||||||
|
|||||||
+131
-32
@@ -1,6 +1,8 @@
|
|||||||
# Nebula Desktop
|
# Nebula Desktop
|
||||||
|
|
||||||
Production shell for the mouse-and-keyboard NebulaOS desktop.
|
Production shell for the mouse-and-keyboard NebulaOS desktop. This is Nebula Desktop **0.1**: a usable session foundation, not only a visual prototype.
|
||||||
|
|
||||||
|
The user can enter the development session, use the full output, open the Nebula launcher, and start real installed applications (including Foot) without Sway keyboard shortcuts.
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
@@ -34,25 +36,15 @@ Qt Quick/QML
|
|||||||
Normal applications remain ordinary Wayland / XWayland clients of the compositor:
|
Normal applications remain ordinary Wayland / XWayland clients of the compositor:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
Normal Application
|
Nebula Launcher
|
||||||
|
↓
|
||||||
|
ApplicationService
|
||||||
|
↓
|
||||||
|
application process
|
||||||
↓
|
↓
|
||||||
Wayland / XWayland
|
Wayland / XWayland
|
||||||
↓
|
↓
|
||||||
Compositor
|
Sway
|
||||||
↓
|
|
||||||
GPU
|
|
||||||
```
|
|
||||||
|
|
||||||
Nebula chrome:
|
|
||||||
|
|
||||||
```text
|
|
||||||
QML
|
|
||||||
↓
|
|
||||||
Qt Quick
|
|
||||||
↓
|
|
||||||
Wayland Layer Shell
|
|
||||||
↓
|
|
||||||
Compositor
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Games, browsers, terminals, and other apps never pass through Qt Quick.
|
Games, browsers, terminals, and other apps never pass through Qt Quick.
|
||||||
@@ -64,6 +56,7 @@ Games, browsers, terminals, and other apps never pass through Qt Quick.
|
|||||||
| Language | C++20 |
|
| Language | C++20 |
|
||||||
| UI | Qt 6, Qt Quick, QML |
|
| UI | Qt 6, Qt Quick, QML |
|
||||||
| Layer surfaces | LayerShellQt |
|
| Layer surfaces | LayerShellQt |
|
||||||
|
| Installed apps | `core/applications` (`import Nebula.Applications`) |
|
||||||
| Build | CMake |
|
| Build | CMake |
|
||||||
| Shared design system | `packages/nebula-ui` (`import Nebula.UI`) |
|
| Shared design system | `packages/nebula-ui` (`import Nebula.UI`) |
|
||||||
|
|
||||||
@@ -77,9 +70,9 @@ Independent Wayland layer-shell windows:
|
|||||||
|
|
||||||
| Surface | Layer | Role |
|
| Surface | Layer | Role |
|
||||||
|---------|-------|------|
|
|---------|-------|------|
|
||||||
| Desktop | `BACKGROUND` | Wallpaper / desktop, no exclusive zone, no keyboard focus |
|
| Desktop | `BACKGROUND` | Fills the current output, no exclusive zone, no keyboard focus |
|
||||||
| Top Bar | `TOP` | 40px bar, exclusive zone, no keyboard focus by default |
|
| Top Bar | `TOP` | 40 logical pixels, exclusive zone, spans the output width |
|
||||||
| Launcher | `OVERLAY` | Hidden until the Nebula button opens it |
|
| Launcher | `OVERLAY` | Covers the output below the top bar while open; hidden otherwise |
|
||||||
|
|
||||||
Stacking:
|
Stacking:
|
||||||
|
|
||||||
@@ -91,12 +84,33 @@ TOP
|
|||||||
Nebula Top Bar
|
Nebula Top Bar
|
||||||
|
|
||||||
NORMAL WAYLAND WINDOWS
|
NORMAL WAYLAND WINDOWS
|
||||||
Firefox, Steam, Blender, games, terminals, …
|
Foot, Firefox, applications, games, …
|
||||||
|
|
||||||
BACKGROUND
|
BACKGROUND
|
||||||
Nebula Desktop
|
Nebula Desktop
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Surfaces follow the live Wayland output geometry from Qt (`OutputTracker`). They do not assume 1920×1080 or other fixed desktop sizes. If VMware resizes the display, the shell updates.
|
||||||
|
|
||||||
|
## Applications
|
||||||
|
|
||||||
|
`core/applications` discovers freedesktop `.desktop` entries from XDG data directories:
|
||||||
|
|
||||||
|
1. `$XDG_DATA_HOME/applications` (default `~/.local/share/applications`)
|
||||||
|
2. `$XDG_DATA_DIRS/applications` (default `/usr/local/share:/usr/share`)
|
||||||
|
|
||||||
|
Launcher-visible apps respect `Hidden=true`, `NoDisplay=true`, `OnlyShowIn` / `NotShowIn`, and `TryExec` when present. Localized `Name` values are used when available.
|
||||||
|
|
||||||
|
Launching uses `QProcess::startDetached` after `QProcess::splitCommand`. Exec field codes (`%f`, `%F`, `%u`, `%U`, `%i`, `%c`, `%k`, `%%`, …) are handled for launches without file arguments. Desktop-entry strings are never passed to a shell.
|
||||||
|
|
||||||
|
Search matches name, generic name, comment, and categories. Searching `term` should find Foot (`GenericName=Terminal`). Searching `firefox` should find Firefox if it is installed.
|
||||||
|
|
||||||
|
Application icons use the system icon theme through Qt (`QIcon::fromTheme`). Unresolved icons use a Nebula fallback glyph, not a broken-image placeholder.
|
||||||
|
|
||||||
|
The top-bar context text stays **Desktop** in this milestone. Launching an app is not treated as focus; real window tracking comes next.
|
||||||
|
|
||||||
|
Mock launcher data remains only as a fallback when no installed applications are discovered.
|
||||||
|
|
||||||
## Layout
|
## Layout
|
||||||
|
|
||||||
```text
|
```text
|
||||||
@@ -104,12 +118,12 @@ shells/desktop/
|
|||||||
├── README.md this file
|
├── README.md this file
|
||||||
├── shell/ production Qt/QML shell
|
├── shell/ production Qt/QML shell
|
||||||
│ ├── CMakeLists.txt
|
│ ├── CMakeLists.txt
|
||||||
│ ├── src/main.cpp
|
│ ├── src/
|
||||||
│ └── qml/
|
│ └── qml/
|
||||||
└── prototype-react/ archived design reference only
|
└── prototype-react/ archived design reference only
|
||||||
```
|
```
|
||||||
|
|
||||||
Shared visual language lives in `packages/nebula-ui`, not in Desktop-specific chrome.
|
Shared visual language lives in `packages/nebula-ui`. Application discovery lives in `core/applications`.
|
||||||
|
|
||||||
## Linux build
|
## Linux build
|
||||||
|
|
||||||
@@ -126,6 +140,7 @@ sudo apt install \
|
|||||||
qml6-module-org-kde-layershell \
|
qml6-module-org-kde-layershell \
|
||||||
qml6-module-qtquick \
|
qml6-module-qtquick \
|
||||||
qml6-module-qtquick-window \
|
qml6-module-qtquick-window \
|
||||||
|
adwaita-icon-theme \
|
||||||
cmake \
|
cmake \
|
||||||
build-essential
|
build-essential
|
||||||
```
|
```
|
||||||
@@ -135,14 +150,12 @@ Qt 6.4 or newer is required. `loadFromModule` is used on Qt 6.5+.
|
|||||||
```bash
|
```bash
|
||||||
cd shells/desktop/shell
|
cd shells/desktop/shell
|
||||||
|
|
||||||
|
rm -rf build
|
||||||
cmake -S . -B build
|
cmake -S . -B build
|
||||||
cmake --build build -j$(nproc)
|
cmake --build build -j$(nproc)
|
||||||
./build/nebula-shell
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Run inside a Wayland compositor such as Sway (`compositor/sway/nebula-sway.conf`). Sway must not show its own bar.
|
The production binary is `shells/desktop/shell/build/nebula-shell`. It does **not** force `QT_QUICK_BACKEND=software`. GPU-accelerated Qt Quick is the production default.
|
||||||
|
|
||||||
There is no npm server, Vite server, `NEBULA_SHELL_DEV_URL`, WebKitGTK, GTK4, or React involved in running the production shell.
|
|
||||||
|
|
||||||
### QML lint
|
### QML lint
|
||||||
|
|
||||||
@@ -159,6 +172,91 @@ qmllint \
|
|||||||
|
|
||||||
Do not use ESLint or Oxlint on the production Qt shell.
|
Do not use ESLint or Oxlint on the production Qt shell.
|
||||||
|
|
||||||
|
## Development session (VMware)
|
||||||
|
|
||||||
|
From the repository root, the intended flow is:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sway -c compositor/sway/nebula-sway.conf
|
||||||
|
```
|
||||||
|
|
||||||
|
or, recommended on the VM (sets cursor env and starts Sway from the repo root):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./compositor/sway/dev/run-sway-vm.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
That should:
|
||||||
|
|
||||||
|
1. start Sway without a Sway bar
|
||||||
|
2. autostart `nebula-shell` through `scripts/run-nebula-vm.sh`
|
||||||
|
3. fill the current output
|
||||||
|
4. show a normal Adwaita 24px cursor
|
||||||
|
5. let the Nebula launcher start Foot and other installed apps
|
||||||
|
|
||||||
|
Skip autostart while debugging the compositor:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
NEBULA_SHELL_AUTOSTART=0 sway -c compositor/sway/nebula-sway.conf
|
||||||
|
```
|
||||||
|
|
||||||
|
### VMware software rendering
|
||||||
|
|
||||||
|
The VMware virtual GPU currently cannot run the Qt Quick accelerated path correctly. The **development wrapper** therefore sets:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
QT_QUICK_BACKEND=software
|
||||||
|
```
|
||||||
|
|
||||||
|
That variable is not a production default. `scripts/run-nebula-vm.sh` is the only place that sets it automatically. Production `nebula-session` / `nebula-shell` must run GPU-accelerated.
|
||||||
|
|
||||||
|
Manual equivalent, if you start the shell yourself:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
QT_QUICK_BACKEND=software ./build/nebula-shell
|
||||||
|
```
|
||||||
|
|
||||||
|
### Output / resolution
|
||||||
|
|
||||||
|
The development Sway config requests `1920x1080` at scale `1` when that mode exists. If the VM does not advertise it, Sway keeps the preferred mode. This is a development default, not a production requirement.
|
||||||
|
|
||||||
|
Inspect outputs and modes:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
swaymsg -t get_outputs
|
||||||
|
```
|
||||||
|
|
||||||
|
Set a mode explicitly if needed:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
swaymsg output <name> mode 1920x1080
|
||||||
|
swaymsg output <name> scale 1
|
||||||
|
```
|
||||||
|
|
||||||
|
In VMware, also set the guest display (or Autofit Guest) so the advertised modes match the host window.
|
||||||
|
|
||||||
|
### Cursor
|
||||||
|
|
||||||
|
Sway owns the cursor:
|
||||||
|
|
||||||
|
```text
|
||||||
|
XCURSOR_THEME=Adwaita
|
||||||
|
XCURSOR_SIZE=24
|
||||||
|
seat * xcursor_theme Adwaita 24
|
||||||
|
```
|
||||||
|
|
||||||
|
Install `adwaita-icon-theme` if the theme is missing. A branded Nebula cursor is a later task. `WLR_NO_HARDWARE_CURSORS=1` is set only by the VMware Sway wrapper.
|
||||||
|
|
||||||
|
### Emergency shortcuts
|
||||||
|
|
||||||
|
These remain for development fallback. They are not the normal UX once the GUI launcher works.
|
||||||
|
|
||||||
|
| Shortcut | Action |
|
||||||
|
|----------|--------|
|
||||||
|
| Super+Enter | open Foot |
|
||||||
|
| Super+Shift+E | exit Sway |
|
||||||
|
| Super+Shift+C | reload Sway config |
|
||||||
|
|
||||||
## Design reference
|
## Design reference
|
||||||
|
|
||||||
`prototype-react/` is an archived React/Vite UI. It is **not** the production shell. Keep it until the Qt shell reaches visual and behavioural parity, then remove it in a later cleanup.
|
`prototype-react/` is an archived React/Vite UI. It is **not** the production shell. Keep it until the Qt shell reaches visual and behavioural parity, then remove it in a later cleanup.
|
||||||
@@ -167,12 +265,13 @@ Do not use ESLint or Oxlint on the production Qt shell.
|
|||||||
|
|
||||||
Not implemented in this milestone:
|
Not implemented in this milestone:
|
||||||
|
|
||||||
|
- Sway IPC / focused-window tracking
|
||||||
|
- workspaces, window overview, dock
|
||||||
- PipeWire, NetworkManager, BlueZ, UPower
|
- PipeWire, NetworkManager, BlueZ, UPower
|
||||||
- D-Bus services
|
- notifications, XDG portals, Polkit
|
||||||
- real `.desktop` discovery or app launching
|
- login/lock screen, greetd
|
||||||
- Sway IPC, workspaces, notifications
|
|
||||||
- authentication, greetd, Gamescope
|
|
||||||
- a custom Nebula compositor
|
- a custom Nebula compositor
|
||||||
- Nebula Bigscreen
|
- Nebula Bigscreen
|
||||||
|
- Gamescope
|
||||||
|
|
||||||
Those come later. Bigscreen is expected to reuse `Nebula.UI` and shared C++ services with a different interaction model.
|
Those come later. Bigscreen is expected to reuse `Nebula.UI` and `Nebula.Applications`.
|
||||||
|
|||||||
@@ -25,8 +25,15 @@ add_subdirectory(
|
|||||||
"${CMAKE_CURRENT_BINARY_DIR}/nebula-ui"
|
"${CMAKE_CURRENT_BINARY_DIR}/nebula-ui"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
add_subdirectory(
|
||||||
|
"${CMAKE_CURRENT_SOURCE_DIR}/../../../core/applications"
|
||||||
|
"${CMAKE_CURRENT_BINARY_DIR}/nebula-applications"
|
||||||
|
)
|
||||||
|
|
||||||
qt_add_executable(nebula-shell
|
qt_add_executable(nebula-shell
|
||||||
src/main.cpp
|
src/main.cpp
|
||||||
|
src/DesktopIconProvider.cpp
|
||||||
|
src/DesktopIconProvider.hpp
|
||||||
)
|
)
|
||||||
|
|
||||||
set(NEBULA_SHELL_QML_FILES
|
set(NEBULA_SHELL_QML_FILES
|
||||||
@@ -65,9 +72,13 @@ qt_add_qml_module(nebula-shell
|
|||||||
VERSION 1.0
|
VERSION 1.0
|
||||||
RESOURCE_PREFIX /qt/qml
|
RESOURCE_PREFIX /qt/qml
|
||||||
QML_FILES ${NEBULA_SHELL_QML_FILES}
|
QML_FILES ${NEBULA_SHELL_QML_FILES}
|
||||||
|
SOURCES
|
||||||
|
src/OutputTracker.hpp
|
||||||
|
src/OutputTracker.cpp
|
||||||
DEPENDENCIES
|
DEPENDENCIES
|
||||||
QtQuick
|
QtQuick
|
||||||
Nebula.UI
|
Nebula.UI
|
||||||
|
Nebula.Applications
|
||||||
)
|
)
|
||||||
|
|
||||||
target_link_libraries(nebula-shell
|
target_link_libraries(nebula-shell
|
||||||
@@ -78,11 +89,15 @@ target_link_libraries(nebula-shell
|
|||||||
Qt6::Quick
|
Qt6::Quick
|
||||||
LayerShellQt::Interface
|
LayerShellQt::Interface
|
||||||
NebulaUI
|
NebulaUI
|
||||||
|
NebulaApplications
|
||||||
)
|
)
|
||||||
|
|
||||||
if(TARGET NebulaUIplugin)
|
if(TARGET NebulaUIplugin)
|
||||||
target_link_libraries(nebula-shell PRIVATE NebulaUIplugin)
|
target_link_libraries(nebula-shell PRIVATE NebulaUIplugin)
|
||||||
endif()
|
endif()
|
||||||
|
if(TARGET NebulaApplicationsplugin)
|
||||||
|
target_link_libraries(nebula-shell PRIVATE NebulaApplicationsplugin)
|
||||||
|
endif()
|
||||||
|
|
||||||
set_target_properties(nebula-shell PROPERTIES
|
set_target_properties(nebula-shell PROPERTIES
|
||||||
OUTPUT_NAME nebula-shell
|
OUTPUT_NAME nebula-shell
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ import QtQuick
|
|||||||
|
|
||||||
QtObject {
|
QtObject {
|
||||||
property bool launcherOpen: false
|
property bool launcherOpen: false
|
||||||
|
|
||||||
|
// Focused-window tracking is a later compositor milestone. Launching an
|
||||||
|
// application does not mean it has focus, so this stays "Desktop" for now.
|
||||||
property string activeApplication: "Desktop"
|
property string activeApplication: "Desktop"
|
||||||
|
|
||||||
function openLauncher() {
|
function openLauncher() {
|
||||||
@@ -17,9 +20,4 @@ QtObject {
|
|||||||
function toggleLauncher() {
|
function toggleLauncher() {
|
||||||
launcherOpen = !launcherOpen
|
launcherOpen = !launcherOpen
|
||||||
}
|
}
|
||||||
|
|
||||||
function launch(name) {
|
|
||||||
activeApplication = name
|
|
||||||
closeLauncher()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,17 @@ Item {
|
|||||||
readonly property bool rowVariant: variant === "row"
|
readonly property bool rowVariant: variant === "row"
|
||||||
readonly property int iconSize: rowVariant ? 32 : (variant === "recent" ? 32 : 40)
|
readonly property int iconSize: rowVariant ? 32 : (variant === "recent" ? 32 : 40)
|
||||||
readonly property int glyphSize: rowVariant ? 16 : 18
|
readonly property int glyphSize: rowVariant ? 16 : 18
|
||||||
|
readonly property string iconName: item && item.iconName ? item.iconName : ""
|
||||||
|
readonly property string fallbackGlyph: {
|
||||||
|
if (item && item.glyph)
|
||||||
|
return item.glyph
|
||||||
|
if (item && item.kind)
|
||||||
|
return item.kind
|
||||||
|
return "application"
|
||||||
|
}
|
||||||
|
readonly property url iconSource: iconName.length > 0
|
||||||
|
? ("image://desktopicon/" + encodeURIComponent(iconName))
|
||||||
|
: ""
|
||||||
|
|
||||||
implicitWidth: rowVariant ? parent ? parent.width : 200 : 90
|
implicitWidth: rowVariant ? parent ? parent.width : 200 : 90
|
||||||
implicitHeight: rowVariant ? 48 : 78
|
implicitHeight: rowVariant ? 48 : 78
|
||||||
@@ -54,9 +65,23 @@ Item {
|
|||||||
border.width: 1
|
border.width: 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Image {
|
||||||
|
id: tileIcon
|
||||||
|
anchors.centerIn: parent
|
||||||
|
width: root.iconSize - 8
|
||||||
|
height: root.iconSize - 8
|
||||||
|
fillMode: Image.PreserveAspectFit
|
||||||
|
asynchronous: true
|
||||||
|
smooth: true
|
||||||
|
cache: true
|
||||||
|
visible: status === Image.Ready
|
||||||
|
source: root.iconSource
|
||||||
|
}
|
||||||
|
|
||||||
NebulaIcon {
|
NebulaIcon {
|
||||||
anchors.centerIn: parent
|
anchors.centerIn: parent
|
||||||
name: item && item.glyph ? item.glyph : "folder"
|
visible: tileIcon.status !== Image.Ready
|
||||||
|
name: root.fallbackGlyph
|
||||||
size: root.glyphSize
|
size: root.glyphSize
|
||||||
color: item && item.tint ? item.tint : Theme.accent
|
color: item && item.tint ? item.tint : Theme.accent
|
||||||
}
|
}
|
||||||
@@ -86,9 +111,23 @@ Item {
|
|||||||
anchors.verticalCenter: parent.verticalCenter
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
color: Theme.iconWell
|
color: Theme.iconWell
|
||||||
|
|
||||||
|
Image {
|
||||||
|
id: rowIcon
|
||||||
|
anchors.centerIn: parent
|
||||||
|
width: root.iconSize - 8
|
||||||
|
height: root.iconSize - 8
|
||||||
|
fillMode: Image.PreserveAspectFit
|
||||||
|
asynchronous: true
|
||||||
|
smooth: true
|
||||||
|
cache: true
|
||||||
|
visible: status === Image.Ready
|
||||||
|
source: root.iconSource
|
||||||
|
}
|
||||||
|
|
||||||
NebulaIcon {
|
NebulaIcon {
|
||||||
anchors.centerIn: parent
|
anchors.centerIn: parent
|
||||||
name: item && (item.glyph || item.kind) ? (item.glyph || item.kind) : "folder"
|
visible: rowIcon.status !== Image.Ready
|
||||||
|
name: root.fallbackGlyph
|
||||||
size: root.glyphSize
|
size: root.glyphSize
|
||||||
color: item && item.tint ? item.tint : Theme.accent
|
color: item && item.tint ? item.tint : Theme.accent
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import QtQuick
|
import QtQuick
|
||||||
import Nebula.UI
|
import Nebula.UI
|
||||||
|
import Nebula.Applications
|
||||||
|
|
||||||
Item {
|
Item {
|
||||||
id: root
|
id: root
|
||||||
@@ -13,8 +14,9 @@ Item {
|
|||||||
Accessible.role: Accessible.Dialog
|
Accessible.role: Accessible.Dialog
|
||||||
Accessible.name: qsTr("Launcher")
|
Accessible.name: qsTr("Launcher")
|
||||||
|
|
||||||
|
readonly property bool usingInstalledApps: ApplicationService.count > 0
|
||||||
readonly property bool searching: searchField.text.trim().length > 0
|
readonly property bool searching: searchField.text.trim().length > 0
|
||||||
readonly property var filteredApps: {
|
readonly property var mockMatches: {
|
||||||
const query = searchField.text.trim().toLowerCase()
|
const query = searchField.text.trim().toLowerCase()
|
||||||
const apps = MockApps.apps
|
const apps = MockApps.apps
|
||||||
if (!query)
|
if (!query)
|
||||||
@@ -24,6 +26,9 @@ Item {
|
|||||||
return haystack.indexOf(query) !== -1
|
return haystack.indexOf(query) !== -1
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
readonly property int resultCount: usingInstalledApps
|
||||||
|
? ApplicationService.filteredModel.count
|
||||||
|
: mockMatches.length
|
||||||
|
|
||||||
Keys.onEscapePressed: {
|
Keys.onEscapePressed: {
|
||||||
event.accepted = true
|
event.accepted = true
|
||||||
@@ -62,6 +67,7 @@ Item {
|
|||||||
id: searchField
|
id: searchField
|
||||||
width: parent.width
|
width: parent.width
|
||||||
placeholderText: qsTr("Search applications")
|
placeholderText: qsTr("Search applications")
|
||||||
|
onTextChanged: ApplicationService.filter = text
|
||||||
}
|
}
|
||||||
|
|
||||||
Item {
|
Item {
|
||||||
@@ -87,7 +93,7 @@ Item {
|
|||||||
Column {
|
Column {
|
||||||
width: parent.width
|
width: parent.width
|
||||||
spacing: 8
|
spacing: 8
|
||||||
visible: !root.searching && MockApps.recents.length > 0
|
visible: !root.searching && !root.usingInstalledApps && MockApps.recents.length > 0
|
||||||
|
|
||||||
Text {
|
Text {
|
||||||
text: qsTr("Recent")
|
text: qsTr("Recent")
|
||||||
@@ -133,14 +139,33 @@ Item {
|
|||||||
|
|
||||||
Grid {
|
Grid {
|
||||||
id: appGrid
|
id: appGrid
|
||||||
visible: root.filteredApps.length > 0
|
visible: root.resultCount > 0
|
||||||
width: parent.width
|
width: parent.width
|
||||||
columns: 4
|
columns: 4
|
||||||
columnSpacing: 6
|
columnSpacing: 6
|
||||||
rowSpacing: 6
|
rowSpacing: 6
|
||||||
|
|
||||||
Repeater {
|
Repeater {
|
||||||
model: root.filteredApps
|
model: root.usingInstalledApps ? ApplicationService.filteredModel : []
|
||||||
|
delegate: AppItem {
|
||||||
|
required property string desktopId
|
||||||
|
required property string name
|
||||||
|
required property string comment
|
||||||
|
required property string iconName
|
||||||
|
width: (appGrid.width - 18) / 4
|
||||||
|
variant: "tile"
|
||||||
|
item: ({
|
||||||
|
desktopId: desktopId,
|
||||||
|
name: name,
|
||||||
|
comment: comment,
|
||||||
|
iconName: iconName
|
||||||
|
})
|
||||||
|
onSelected: root.launchApp(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: root.usingInstalledApps ? [] : root.mockMatches
|
||||||
delegate: AppItem {
|
delegate: AppItem {
|
||||||
required property var modelData
|
required property var modelData
|
||||||
width: (appGrid.width - 18) / 4
|
width: (appGrid.width - 18) / 4
|
||||||
@@ -152,7 +177,7 @@ Item {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Text {
|
Text {
|
||||||
visible: root.filteredApps.length === 0
|
visible: root.resultCount === 0
|
||||||
width: parent.width
|
width: parent.width
|
||||||
topPadding: 8
|
topPadding: 8
|
||||||
text: qsTr("No matching applications")
|
text: qsTr("No matching applications")
|
||||||
@@ -165,7 +190,7 @@ Item {
|
|||||||
Column {
|
Column {
|
||||||
width: parent.width
|
width: parent.width
|
||||||
spacing: 8
|
spacing: 8
|
||||||
visible: !root.searching && MockApps.projects.length > 0
|
visible: !root.searching && !root.usingInstalledApps && MockApps.projects.length > 0
|
||||||
|
|
||||||
Text {
|
Text {
|
||||||
text: qsTr("Recent projects")
|
text: qsTr("Recent projects")
|
||||||
@@ -286,10 +311,12 @@ Item {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function launchApp(app) {
|
function launchApp(app) {
|
||||||
ShellState.launch(app.name)
|
if (app && app.desktopId)
|
||||||
|
ApplicationService.launch(app.desktopId)
|
||||||
|
ShellState.closeLauncher()
|
||||||
}
|
}
|
||||||
|
|
||||||
function openProject(project) {
|
function openProject(project) {
|
||||||
ShellState.launch(project.name)
|
ShellState.closeLauncher()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,12 +6,19 @@ import Nebula.UI
|
|||||||
Window {
|
Window {
|
||||||
id: root
|
id: root
|
||||||
|
|
||||||
|
readonly property int outputWidth: OutputTracker.width > 0
|
||||||
|
? OutputTracker.width
|
||||||
|
: (screen && screen.width > 0 ? screen.width : Screen.width)
|
||||||
|
readonly property int outputHeight: OutputTracker.height > 0
|
||||||
|
? OutputTracker.height
|
||||||
|
: (screen && screen.height > 0 ? screen.height : Screen.height)
|
||||||
|
|
||||||
title: qsTr("Nebula Desktop")
|
title: qsTr("Nebula Desktop")
|
||||||
color: "transparent"
|
color: "transparent"
|
||||||
flags: Qt.FramelessWindowHint | Qt.WindowDoesNotAcceptFocus
|
flags: Qt.FramelessWindowHint | Qt.WindowDoesNotAcceptFocus
|
||||||
visible: true
|
visible: true
|
||||||
width: Screen.width
|
width: outputWidth
|
||||||
height: Screen.height
|
height: outputHeight
|
||||||
|
|
||||||
LayerShell.Window.scope: "nebula-desktop"
|
LayerShell.Window.scope: "nebula-desktop"
|
||||||
LayerShell.Window.layer: LayerShell.Window.LayerBackground
|
LayerShell.Window.layer: LayerShell.Window.LayerBackground
|
||||||
|
|||||||
@@ -6,12 +6,19 @@ import Nebula.UI
|
|||||||
Window {
|
Window {
|
||||||
id: root
|
id: root
|
||||||
|
|
||||||
|
readonly property int outputWidth: OutputTracker.width > 0
|
||||||
|
? OutputTracker.width
|
||||||
|
: (screen && screen.width > 0 ? screen.width : Screen.width)
|
||||||
|
readonly property int outputHeight: OutputTracker.height > 0
|
||||||
|
? OutputTracker.height
|
||||||
|
: (screen && screen.height > 0 ? screen.height : Screen.height)
|
||||||
|
|
||||||
title: qsTr("Nebula Launcher")
|
title: qsTr("Nebula Launcher")
|
||||||
color: "transparent"
|
color: "transparent"
|
||||||
flags: Qt.FramelessWindowHint
|
flags: Qt.FramelessWindowHint
|
||||||
visible: ShellState.launcherOpen || launcher.opacity > 0.01
|
visible: ShellState.launcherOpen || launcher.opacity > 0.01
|
||||||
width: Screen.width
|
width: outputWidth
|
||||||
height: Screen.height - Theme.topBarHeight
|
height: Math.max(1, outputHeight - Theme.topBarHeight)
|
||||||
|
|
||||||
LayerShell.Window.scope: "nebula-launcher"
|
LayerShell.Window.scope: "nebula-launcher"
|
||||||
LayerShell.Window.layer: LayerShell.Window.LayerOverlay
|
LayerShell.Window.layer: LayerShell.Window.LayerOverlay
|
||||||
|
|||||||
@@ -6,11 +6,15 @@ import Nebula.UI
|
|||||||
Window {
|
Window {
|
||||||
id: root
|
id: root
|
||||||
|
|
||||||
|
readonly property int outputWidth: OutputTracker.width > 0
|
||||||
|
? OutputTracker.width
|
||||||
|
: (screen && screen.width > 0 ? screen.width : Screen.width)
|
||||||
|
|
||||||
title: qsTr("Nebula Top Bar")
|
title: qsTr("Nebula Top Bar")
|
||||||
color: "transparent"
|
color: "transparent"
|
||||||
flags: Qt.FramelessWindowHint | Qt.WindowDoesNotAcceptFocus
|
flags: Qt.FramelessWindowHint | Qt.WindowDoesNotAcceptFocus
|
||||||
visible: true
|
visible: true
|
||||||
width: Screen.width
|
width: outputWidth
|
||||||
height: Theme.topBarHeight
|
height: Theme.topBarHeight
|
||||||
|
|
||||||
LayerShell.Window.scope: "nebula-topbar"
|
LayerShell.Window.scope: "nebula-topbar"
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
#include "DesktopIconProvider.hpp"
|
||||||
|
|
||||||
|
#include <QFileInfo>
|
||||||
|
#include <QIcon>
|
||||||
|
#include <QPixmap>
|
||||||
|
#include <QUrl>
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
QIcon resolveDesktopIcon(const QString &name)
|
||||||
|
{
|
||||||
|
if (name.isEmpty())
|
||||||
|
return {};
|
||||||
|
|
||||||
|
const QFileInfo direct(name);
|
||||||
|
if (direct.isAbsolute() && direct.exists())
|
||||||
|
return QIcon(name);
|
||||||
|
|
||||||
|
QIcon themed = QIcon::fromTheme(name);
|
||||||
|
if (!themed.isNull())
|
||||||
|
return themed;
|
||||||
|
|
||||||
|
QString base = name;
|
||||||
|
if (base.endsWith(QLatin1String(".png"), Qt::CaseInsensitive)
|
||||||
|
|| base.endsWith(QLatin1String(".svg"), Qt::CaseInsensitive)
|
||||||
|
|| base.endsWith(QLatin1String(".xpm"), Qt::CaseInsensitive)) {
|
||||||
|
base = QFileInfo(base).completeBaseName();
|
||||||
|
themed = QIcon::fromTheme(base);
|
||||||
|
if (!themed.isNull())
|
||||||
|
return themed;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QStringList pixmapDirs = {
|
||||||
|
QStringLiteral("/usr/share/pixmaps"),
|
||||||
|
QStringLiteral("/usr/share/icons/hicolor/48x48/apps"),
|
||||||
|
QStringLiteral("/usr/share/icons/hicolor/scalable/apps"),
|
||||||
|
};
|
||||||
|
const QStringList suffixes = {
|
||||||
|
QString(),
|
||||||
|
QStringLiteral(".png"),
|
||||||
|
QStringLiteral(".svg"),
|
||||||
|
QStringLiteral(".xpm"),
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const QString &dir : pixmapDirs) {
|
||||||
|
for (const QString &suffix : suffixes) {
|
||||||
|
const QString candidate = dir + QLatin1Char('/') + (suffix.isEmpty() ? name : base) + suffix;
|
||||||
|
if (QFileInfo::exists(candidate))
|
||||||
|
return QIcon(candidate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
DesktopIconProvider::DesktopIconProvider()
|
||||||
|
: QQuickImageProvider(QQuickImageProvider::Pixmap)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
QPixmap DesktopIconProvider::requestPixmap(const QString &id, QSize *size, const QSize &requestedSize)
|
||||||
|
{
|
||||||
|
const QString iconName = QUrl::fromPercentEncoding(id.toUtf8());
|
||||||
|
const QIcon icon = resolveDesktopIcon(iconName);
|
||||||
|
const QSize target = requestedSize.isValid() && requestedSize.width() > 0 && requestedSize.height() > 0
|
||||||
|
? requestedSize
|
||||||
|
: QSize(48, 48);
|
||||||
|
|
||||||
|
if (icon.isNull()) {
|
||||||
|
if (size)
|
||||||
|
*size = target;
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const QPixmap pixmap = icon.pixmap(target);
|
||||||
|
if (pixmap.isNull()) {
|
||||||
|
if (size)
|
||||||
|
*size = target;
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (size)
|
||||||
|
*size = pixmap.size();
|
||||||
|
return pixmap;
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QQuickImageProvider>
|
||||||
|
|
||||||
|
class DesktopIconProvider : public QQuickImageProvider
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
DesktopIconProvider();
|
||||||
|
QPixmap requestPixmap(const QString &id, QSize *size, const QSize &requestedSize) override;
|
||||||
|
};
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
#include "OutputTracker.hpp"
|
||||||
|
|
||||||
|
#include <QCoreApplication>
|
||||||
|
#include <QGuiApplication>
|
||||||
|
#include <QJSEngine>
|
||||||
|
#include <QRect>
|
||||||
|
|
||||||
|
OutputTracker::OutputTracker(QObject *parent)
|
||||||
|
: QObject(parent)
|
||||||
|
{
|
||||||
|
if (auto *app = qobject_cast<QGuiApplication *>(QCoreApplication::instance())) {
|
||||||
|
connect(app, &QGuiApplication::primaryScreenChanged, this, [this](QScreen *screen) {
|
||||||
|
bindScreen(screen);
|
||||||
|
});
|
||||||
|
connect(app, &QGuiApplication::screenAdded, this, [this](QScreen *) {
|
||||||
|
if (!m_screen)
|
||||||
|
bindScreen(QGuiApplication::primaryScreen());
|
||||||
|
});
|
||||||
|
connect(app, &QGuiApplication::screenRemoved, this, [this](QScreen *screen) {
|
||||||
|
if (m_screen == screen)
|
||||||
|
bindScreen(QGuiApplication::primaryScreen());
|
||||||
|
});
|
||||||
|
bindScreen(app->primaryScreen());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
OutputTracker *OutputTracker::create(QQmlEngine *engine, QJSEngine *)
|
||||||
|
{
|
||||||
|
auto *tracker = new OutputTracker(engine);
|
||||||
|
QQmlEngine::setObjectOwnership(tracker, QQmlEngine::CppOwnership);
|
||||||
|
return tracker;
|
||||||
|
}
|
||||||
|
|
||||||
|
void OutputTracker::bindScreen(QScreen *screen)
|
||||||
|
{
|
||||||
|
if (m_screen == screen)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (m_screen) {
|
||||||
|
disconnect(m_screen, nullptr, this, nullptr);
|
||||||
|
}
|
||||||
|
|
||||||
|
m_screen = screen;
|
||||||
|
if (m_screen) {
|
||||||
|
connect(m_screen, &QScreen::geometryChanged, this, [this](const QRect &) {
|
||||||
|
logGeometry();
|
||||||
|
emit geometryChanged();
|
||||||
|
});
|
||||||
|
connect(m_screen, &QScreen::physicalDotsPerInchChanged, this, &OutputTracker::geometryChanged);
|
||||||
|
}
|
||||||
|
|
||||||
|
logGeometry();
|
||||||
|
emit geometryChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
void OutputTracker::logGeometry() const
|
||||||
|
{
|
||||||
|
if (!m_screen) {
|
||||||
|
qWarning("nebula-shell: no Wayland output is available yet");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QRect geo = m_screen->geometry();
|
||||||
|
qInfo("nebula-shell: output %s %dx%d+%d+%d scale %g",
|
||||||
|
qPrintable(m_screen->name()),
|
||||||
|
geo.width(),
|
||||||
|
geo.height(),
|
||||||
|
geo.x(),
|
||||||
|
geo.y(),
|
||||||
|
m_screen->devicePixelRatio());
|
||||||
|
}
|
||||||
|
|
||||||
|
int OutputTracker::width() const
|
||||||
|
{
|
||||||
|
return m_screen ? m_screen->geometry().width() : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
int OutputTracker::height() const
|
||||||
|
{
|
||||||
|
return m_screen ? m_screen->geometry().height() : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
qreal OutputTracker::devicePixelRatio() const
|
||||||
|
{
|
||||||
|
return m_screen ? m_screen->devicePixelRatio() : 1.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
QString OutputTracker::name() const
|
||||||
|
{
|
||||||
|
return m_screen ? m_screen->name() : QString();
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QObject>
|
||||||
|
#include <QPointer>
|
||||||
|
#include <QQmlEngine>
|
||||||
|
#include <QScreen>
|
||||||
|
#include <QtQml/qqmlregistration.h>
|
||||||
|
|
||||||
|
class QJSEngine;
|
||||||
|
|
||||||
|
class OutputTracker : public QObject
|
||||||
|
{
|
||||||
|
Q_OBJECT
|
||||||
|
QML_ELEMENT
|
||||||
|
QML_SINGLETON
|
||||||
|
Q_PROPERTY(int width READ width NOTIFY geometryChanged)
|
||||||
|
Q_PROPERTY(int height READ height NOTIFY geometryChanged)
|
||||||
|
Q_PROPERTY(qreal devicePixelRatio READ devicePixelRatio NOTIFY geometryChanged)
|
||||||
|
Q_PROPERTY(QString name READ name NOTIFY geometryChanged)
|
||||||
|
|
||||||
|
public:
|
||||||
|
explicit OutputTracker(QObject *parent = nullptr);
|
||||||
|
static OutputTracker *create(QQmlEngine *engine, QJSEngine *scriptEngine);
|
||||||
|
|
||||||
|
int width() const;
|
||||||
|
int height() const;
|
||||||
|
qreal devicePixelRatio() const;
|
||||||
|
QString name() const;
|
||||||
|
|
||||||
|
signals:
|
||||||
|
void geometryChanged();
|
||||||
|
|
||||||
|
private:
|
||||||
|
void bindScreen(QScreen *screen);
|
||||||
|
void logGeometry() const;
|
||||||
|
|
||||||
|
QPointer<QScreen> m_screen;
|
||||||
|
};
|
||||||
@@ -1,11 +1,48 @@
|
|||||||
|
#include "DesktopIconProvider.hpp"
|
||||||
|
|
||||||
#include <LayerShellQt/Shell>
|
#include <LayerShellQt/Shell>
|
||||||
|
|
||||||
|
#include <QCoreApplication>
|
||||||
|
#include <QDir>
|
||||||
#include <QGuiApplication>
|
#include <QGuiApplication>
|
||||||
|
#include <QIcon>
|
||||||
#include <QQmlApplicationEngine>
|
#include <QQmlApplicationEngine>
|
||||||
#include <QQuickWindow>
|
#include <QQuickWindow>
|
||||||
#include <QUrl>
|
#include <QUrl>
|
||||||
#include <QtGlobal>
|
#include <QtGlobal>
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
void initializeIconTheme()
|
||||||
|
{
|
||||||
|
if (QIcon::themeName().isEmpty()) {
|
||||||
|
const QStringList candidates = {
|
||||||
|
QStringLiteral("Adwaita"),
|
||||||
|
QStringLiteral("Yaru"),
|
||||||
|
QStringLiteral("ubuntu-mono-dark"),
|
||||||
|
QStringLiteral("breeze"),
|
||||||
|
QStringLiteral("hicolor"),
|
||||||
|
};
|
||||||
|
|
||||||
|
const QStringList searchPaths = QIcon::themeSearchPaths();
|
||||||
|
for (const QString &theme : candidates) {
|
||||||
|
for (const QString &root : searchPaths) {
|
||||||
|
if (QDir(root + QLatin1Char('/') + theme).exists()) {
|
||||||
|
QIcon::setThemeName(theme);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!QIcon::themeName().isEmpty())
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (QIcon::fallbackThemeName().isEmpty())
|
||||||
|
QIcon::setFallbackThemeName(QStringLiteral("hicolor"));
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
int main(int argc, char *argv[])
|
int main(int argc, char *argv[])
|
||||||
{
|
{
|
||||||
LayerShellQt::Shell::useLayerShell();
|
LayerShellQt::Shell::useLayerShell();
|
||||||
@@ -16,10 +53,12 @@ int main(int argc, char *argv[])
|
|||||||
app.setOrganizationName(QStringLiteral("NebulaOS"));
|
app.setOrganizationName(QStringLiteral("NebulaOS"));
|
||||||
app.setDesktopFileName(QStringLiteral("org.nebulaos.shell"));
|
app.setDesktopFileName(QStringLiteral("org.nebulaos.shell"));
|
||||||
|
|
||||||
|
initializeIconTheme();
|
||||||
QQuickWindow::setDefaultAlphaBuffer(true);
|
QQuickWindow::setDefaultAlphaBuffer(true);
|
||||||
|
|
||||||
QQmlApplicationEngine engine;
|
QQmlApplicationEngine engine;
|
||||||
engine.addImportPath(QStringLiteral("qrc:/qt/qml"));
|
engine.addImportPath(QStringLiteral("qrc:/qt/qml"));
|
||||||
|
engine.addImageProvider(QStringLiteral("desktopicon"), new DesktopIconProvider);
|
||||||
|
|
||||||
QObject::connect(
|
QObject::connect(
|
||||||
&engine,
|
&engine,
|
||||||
|
|||||||
Reference in New Issue
Block a user