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