diff --git a/README.md b/README.md index b9d7ac1..f9b6e2b 100644 --- a/README.md +++ b/README.md @@ -7,14 +7,40 @@ A Linux desktop environment built around a custom compositor, shell, and service | Path | Purpose | |------|---------| | `core/nebula-core` | Core runtime and platform APIs | -| `compositor/` | Compositor configuration (Sway) | -| `shells/` | Desktop and bigscreen shell implementations | -| `packages/` | Shared UI libraries, icons, API, and design tokens | +| `compositor/` | Compositor configuration (Sway is temporary) | +| `shells/` | Desktop and future Bigscreen shells | +| `packages/` | Shared UI (`Nebula.UI`), icons, API, and design tokens | | `services/` | System services (audio, network, bluetooth, power, accounts) | | `session/` | Session manager and desktop entry | | `packaging/` | Distribution packaging (Ubuntu) | | `branding/` | Boot assets, wallpapers, and sounds | +## Shells + +**Nebula Desktop** (production) is a C++20 Qt Quick/QML Wayland shell using LayerShellQt. 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. + +Standalone Nebula applications may still use React/Tauri where that is a better fit. The operating-system shell does not. + +An archived React desktop prototype lives in `shells/desktop/prototype-react/` as a design reference only. + +## Current stack + +```text +Ubuntu + ↓ +Wayland + ↓ +Sway temporary compositor + ↓ +Nebula Shell Qt Quick/QML +``` + +Applications and games render on their own Wayland/XWayland paths. They are not composited inside QML. + ## Getting started -Documentation and build instructions will be added as components land. +Desktop shell build instructions: `shells/desktop/README.md`. + +The Qt shell must be compiled on Linux (the Ubuntu NebulaOS VM). It will not build on Windows. diff --git a/compositor/README.md b/compositor/README.md index 153f5b6..c52b0b3 100644 --- a/compositor/README.md +++ b/compositor/README.md @@ -1,3 +1,27 @@ # Compositor -NebulaOS uses Sway as its Wayland compositor. Configuration lives in `sway/nebula-sway.conf`. +Sway is the **temporary** Wayland compositor while Nebula Desktop is developed. Configuration lives in `sway/nebula-sway.conf`. + +Sway currently provides: + +- Wayland compositor +- application window management +- XWayland +- input +- output handling + +Nebula provides the visible shell (Qt Quick/QML via LayerShellQt). The Sway config must not enable a Sway bar. + +A custom Nebula compositor is a later milestone. Do not treat this Sway setup as the final architecture. + +```text +Ubuntu/Linux + ↓ +Wayland + ↓ +Sway now +Nebula compositor later + ↓ +Nebula Shell +Qt Quick/QML +``` diff --git a/packages/nebula-ui/.gitkeep b/packages/nebula-ui/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/packages/nebula-ui/CMakeLists.txt b/packages/nebula-ui/CMakeLists.txt new file mode 100644 index 0000000..95db608 --- /dev/null +++ b/packages/nebula-ui/CMakeLists.txt @@ -0,0 +1,41 @@ +cmake_minimum_required(VERSION 3.20) + +if(NOT TARGET Qt6::Quick) + project(NebulaUI LANGUAGES CXX) + find_package(Qt6 6.4 REQUIRED COMPONENTS Quick) + qt_standard_project_setup(REQUIRES 6.4) +endif() + +qt_add_library(NebulaUI STATIC) + +set(NEBULA_UI_QML_DIR "${CMAKE_CURRENT_SOURCE_DIR}/qml/Nebula/UI") + +set(NEBULA_UI_QML_FILES + Theme.qml + NebulaButton.qml + NebulaCard.qml + NebulaIconButton.qml + NebulaTextField.qml + FocusFrame.qml + NebulaIcon.qml +) + +set(NEBULA_UI_QML_PATHS) +foreach(qml_file IN LISTS NEBULA_UI_QML_FILES) + set(qml_path "${NEBULA_UI_QML_DIR}/${qml_file}") + set_source_files_properties("${qml_path}" PROPERTIES + QT_RESOURCE_ALIAS "${qml_file}" + ) + list(APPEND NEBULA_UI_QML_PATHS "${qml_path}") +endforeach() + +set_source_files_properties("${NEBULA_UI_QML_DIR}/Theme.qml" PROPERTIES + QT_QML_SINGLETON_TYPE TRUE +) + +qt_add_qml_module(NebulaUI + URI Nebula.UI + VERSION 1.0 + RESOURCE_PREFIX /qt/qml + QML_FILES ${NEBULA_UI_QML_PATHS} +) diff --git a/packages/nebula-ui/README.md b/packages/nebula-ui/README.md new file mode 100644 index 0000000..8dd85e1 --- /dev/null +++ b/packages/nebula-ui/README.md @@ -0,0 +1,20 @@ +# Nebula.UI + +Shared Qt Quick/QML design system for NebulaOS shells. + +Desktop and the future Bigscreen shell should both import: + +```qml +import Nebula.UI +``` + +This module contains reusable visual language only: + +- colour, spacing, type, and motion tokens (`Theme`) +- generic controls (`NebulaButton`, `NebulaCard`, `NebulaIconButton`, `NebulaTextField`) +- focus treatment (`FocusFrame`) +- compact vector icons (`NebulaIcon`) + +Do not put Desktop- or Bigscreen-specific chrome here (top bar, launcher, session flow). + +Built as part of `shells/desktop/shell`. It is not a standalone application. diff --git a/packages/nebula-ui/qml/Nebula/UI/FocusFrame.qml b/packages/nebula-ui/qml/Nebula/UI/FocusFrame.qml new file mode 100644 index 0000000..eab6c73 --- /dev/null +++ b/packages/nebula-ui/qml/Nebula/UI/FocusFrame.qml @@ -0,0 +1,24 @@ +import QtQuick + +Item { + id: root + + property Item target: parent + property real radius: Theme.radiusSmall + property bool active: target && target.activeFocus + + anchors.fill: target + anchors.margins: -2 + z: 20 + enabled: false + visible: active + Accessible.ignored: true + + Rectangle { + anchors.fill: parent + color: "transparent" + border.width: 2 + border.color: Theme.focus + radius: root.radius + 2 + } +} diff --git a/packages/nebula-ui/qml/Nebula/UI/NebulaButton.qml b/packages/nebula-ui/qml/Nebula/UI/NebulaButton.qml new file mode 100644 index 0000000..bb9ca32 --- /dev/null +++ b/packages/nebula-ui/qml/Nebula/UI/NebulaButton.qml @@ -0,0 +1,68 @@ +import QtQuick + +Item { + id: root + + property string text: "" + property bool checked: false + property color textColor: checked ? Theme.accentStrong : Theme.textPrimary + + default property alias icon: iconHolder.data + + signal clicked() + + implicitWidth: row.implicitWidth + 18 + implicitHeight: Theme.controlHeight + Accessible.role: Accessible.Button + Accessible.name: root.text + Accessible.checkable: true + Accessible.checked: root.checked + Accessible.onPressAction: root.clicked() + + Rectangle { + anchors.fill: parent + radius: Theme.radiusSmall + color: (hover.containsMouse || root.checked) ? Theme.accentSoft : "transparent" + + Behavior on color { + ColorAnimation { + duration: Theme.animationFast + } + } + } + + Row { + id: row + anchors.centerIn: parent + spacing: 8 + + Item { + id: iconHolder + width: children.length > 0 ? 18 : 0 + height: 18 + visible: width > 0 + } + + Text { + text: root.text + color: root.textColor + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSize + font.weight: Font.DemiBold + visible: root.text.length > 0 + anchors.verticalCenter: parent.verticalCenter + } + } + + MouseArea { + id: hover + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: root.clicked() + } + + FocusFrame { + radius: Theme.radiusSmall + } +} diff --git a/packages/nebula-ui/qml/Nebula/UI/NebulaCard.qml b/packages/nebula-ui/qml/Nebula/UI/NebulaCard.qml new file mode 100644 index 0000000..7dd1e73 --- /dev/null +++ b/packages/nebula-ui/qml/Nebula/UI/NebulaCard.qml @@ -0,0 +1,21 @@ +import QtQuick + +Rectangle { + id: root + + color: Theme.surface + border.color: Theme.border + border.width: 1 + radius: Theme.radiusLarge + + Rectangle { + id: shadow + z: -1 + anchors.fill: parent + anchors.topMargin: 10 + anchors.bottomMargin: -10 + radius: root.radius + color: Theme.shadow + opacity: 0.85 + } +} diff --git a/packages/nebula-ui/qml/Nebula/UI/NebulaIcon.qml b/packages/nebula-ui/qml/Nebula/UI/NebulaIcon.qml new file mode 100644 index 0000000..1c946be --- /dev/null +++ b/packages/nebula-ui/qml/Nebula/UI/NebulaIcon.qml @@ -0,0 +1,369 @@ +import QtQuick + +Item { + id: root + + property string name: "nebula" + property color color: Theme.textPrimary + property int size: 16 + property real value: 1 + + implicitWidth: size + implicitHeight: size + Accessible.ignored: true + + Canvas { + id: canvas + anchors.fill: parent + antialiasing: true + onPaint: root.paint(getContext("2d")) + } + + onNameChanged: canvas.requestPaint() + onColorChanged: canvas.requestPaint() + onValueChanged: canvas.requestPaint() + onWidthChanged: canvas.requestPaint() + onHeightChanged: canvas.requestPaint() + + function paint(ctx) { + ctx.reset() + ctx.strokeStyle = root.color + ctx.fillStyle = root.color + ctx.lineCap = "round" + ctx.lineJoin = "round" + ctx.scale(width / 24, height / 24) + + switch (root.name) { + case "nebula": + paintNebula(ctx) + break + case "search": + paintSearch(ctx) + break + case "wifi": + paintWifi(ctx) + break + case "speaker": + paintSpeaker(ctx) + break + case "battery": + paintBattery(ctx) + break + case "power": + paintPower(ctx) + break + case "restart": + paintRestart(ctx) + break + case "folder": + paintFolder(ctx) + break + case "globe": + paintGlobe(ctx) + break + case "terminal": + paintTerminal(ctx) + break + case "settings": + paintSettings(ctx) + break + case "game": + paintGame(ctx) + break + case "photo": + paintPhoto(ctx) + break + case "music": + paintMusic(ctx) + break + case "document": + paintDocument(ctx) + break + default: + paintFolder(ctx) + break + } + } + + function paintNebula(ctx) { + ctx.beginPath() + ctx.arc(12, 12, 4.2, 0, Math.PI * 2) + ctx.fill() + + ctx.save() + ctx.translate(12, 12) + ctx.rotate(-26 * Math.PI / 180) + ctx.scale(10 / 4.15, 1) + ctx.lineWidth = 1.35 * (4.15 / 10) + ctx.beginPath() + ctx.arc(0, 0, 4.15, 0, Math.PI * 2) + ctx.stroke() + ctx.restore() + } + + function paintSearch(ctx) { + ctx.lineWidth = 1.6 + ctx.beginPath() + ctx.arc(11, 11, 6.25, 0, Math.PI * 2) + ctx.stroke() + ctx.beginPath() + ctx.moveTo(15.8, 15.8) + ctx.lineTo(20, 20) + ctx.stroke() + } + + function paintWifi(ctx) { + const strength = root.value + ctx.lineWidth = 1.6 + ctx.globalAlpha = strength > 0 ? 1 : 0.28 + ctx.beginPath() + ctx.moveTo(4.8, 15.2) + ctx.bezierCurveTo(8.8, 11.2, 15.2, 11.2, 19.2, 15.2) + ctx.stroke() + ctx.globalAlpha = strength > 1 ? 1 : 0.28 + ctx.beginPath() + ctx.moveTo(7.4, 17.4) + ctx.bezierCurveTo(10, 14.9, 14, 14.9, 16.6, 17.4) + ctx.stroke() + ctx.globalAlpha = 1 + ctx.beginPath() + ctx.arc(12, 20, 1.15, 0, Math.PI * 2) + ctx.fill() + } + + function paintSpeaker(ctx) { + ctx.beginPath() + ctx.moveTo(4.5, 9.4) + ctx.lineTo(7.6, 9.4) + ctx.lineTo(12, 6.2) + ctx.lineTo(12, 17.8) + ctx.lineTo(7.6, 14.6) + ctx.lineTo(4.5, 14.6) + ctx.closePath() + ctx.fill() + ctx.lineWidth = 1.5 + ctx.beginPath() + ctx.arc(12.5, 12, 4.2, -0.7, 0.7) + ctx.stroke() + ctx.globalAlpha = 0.7 + ctx.beginPath() + ctx.arc(12.5, 12, 7.2, -0.85, 0.85) + ctx.stroke() + ctx.globalAlpha = 1 + } + + function paintBattery(ctx) { + ctx.lineWidth = 1.5 + ctx.beginPath() + roundedRect(ctx, 2.5, 7.25, 16.5, 9.5, 2) + ctx.stroke() + ctx.beginPath() + roundedRect(ctx, 19.5, 10, 2, 4, 0.6) + ctx.fill() + const fillWidth = Math.max(1.5, (14 * Math.min(100, Math.max(0, root.value))) / 100) + ctx.beginPath() + roundedRect(ctx, 4.2, 9, fillWidth, 6, 1) + ctx.fill() + } + + function paintPower(ctx) { + ctx.lineWidth = 1.7 + ctx.beginPath() + ctx.moveTo(12, 4.5) + ctx.lineTo(12, 11.7) + ctx.stroke() + ctx.beginPath() + ctx.arc(12, 13, 6.2, Math.PI * 1.15, Math.PI * -0.15, false) + ctx.stroke() + } + + function paintRestart(ctx) { + ctx.lineWidth = 1.7 + ctx.beginPath() + ctx.arc(12, 12, 7, -0.2, Math.PI * 1.55, true) + ctx.stroke() + ctx.beginPath() + ctx.moveTo(19, 5.2) + ctx.lineTo(19, 9.6) + ctx.lineTo(14.6, 9.6) + ctx.stroke() + } + + function paintFolder(ctx) { + ctx.beginPath() + ctx.moveTo(3.5, 8.2) + ctx.quadraticCurveTo(3.5, 6.4, 5.3, 6.4) + ctx.lineTo(9.4, 6.4) + ctx.lineTo(11, 8.2) + ctx.lineTo(18.7, 8.2) + ctx.quadraticCurveTo(20.5, 8.2, 20.5, 10) + ctx.lineTo(20.5, 16.8) + ctx.quadraticCurveTo(20.5, 18.6, 18.7, 18.6) + ctx.lineTo(5.3, 18.6) + ctx.quadraticCurveTo(3.5, 18.6, 3.5, 16.8) + ctx.closePath() + ctx.fill() + } + + function paintGlobe(ctx) { + ctx.lineWidth = 1.6 + ctx.beginPath() + ctx.arc(12, 12, 7.25, 0, Math.PI * 2) + ctx.stroke() + ctx.lineWidth = 1.4 + ctx.save() + ctx.translate(12, 12) + ctx.scale(3.1 / 7.25, 1) + ctx.beginPath() + ctx.arc(0, 0, 7.25, 0, Math.PI * 2) + ctx.stroke() + ctx.restore() + ctx.beginPath() + ctx.moveTo(5.2, 12) + ctx.lineTo(18.8, 12) + ctx.stroke() + } + + function paintTerminal(ctx) { + ctx.lineWidth = 1.7 + ctx.beginPath() + ctx.moveTo(7, 9.2) + ctx.lineTo(10.4, 12) + ctx.lineTo(7, 14.8) + ctx.stroke() + ctx.beginPath() + ctx.moveTo(12.2, 15.2) + ctx.lineTo(17, 15.2) + ctx.stroke() + } + + function paintSettings(ctx) { + ctx.beginPath() + ctx.arc(12, 12, 2.4, 0, Math.PI * 2) + ctx.fill() + ctx.lineWidth = 1.6 + const ticks = [ + [12, 5.2, 12, 6.8], + [12, 17.2, 12, 18.8], + [5.2, 12, 6.8, 12], + [17.2, 12, 18.8, 12], + [7.2, 7.2, 8.3, 8.3], + [15.7, 15.7, 16.8, 16.8], + [16.8, 7.2, 15.7, 8.3], + [8.3, 15.7, 7.2, 16.8] + ] + for (let i = 0; i < ticks.length; i++) { + const t = ticks[i] + ctx.beginPath() + ctx.moveTo(t[0], t[1]) + ctx.lineTo(t[2], t[3]) + ctx.stroke() + } + } + + function paintGame(ctx) { + ctx.lineWidth = 1.5 + ctx.beginPath() + ctx.moveTo(5.2, 10.4) + ctx.lineTo(18.8, 10.4) + ctx.quadraticCurveTo(20.4, 10.4, 20.3, 12.1) + ctx.lineTo(19.8, 15.3) + ctx.quadraticCurveTo(19.6, 16.9, 18.5, 16.9) + ctx.lineTo(16.3, 16.9) + ctx.lineTo(15, 15.1) + ctx.lineTo(10, 15.1) + ctx.lineTo(8.7, 16.9) + ctx.lineTo(6.5, 16.9) + ctx.quadraticCurveTo(5.4, 16.9, 5.2, 15.3) + ctx.lineTo(4.7, 12.1) + ctx.quadraticCurveTo(4.6, 10.4, 5.2, 10.4) + ctx.closePath() + ctx.stroke() + ctx.beginPath() + ctx.moveTo(8, 12.4) + ctx.lineTo(8, 15.6) + ctx.moveTo(6.4, 14) + ctx.lineTo(9.6, 14) + ctx.stroke() + ctx.beginPath() + ctx.arc(15.4, 13.2, 0.9, 0, Math.PI * 2) + ctx.fill() + ctx.beginPath() + ctx.arc(17.2, 15, 0.9, 0, Math.PI * 2) + ctx.fill() + } + + function paintPhoto(ctx) { + ctx.lineWidth = 1.5 + ctx.beginPath() + roundedRect(ctx, 4, 6.2, 16, 11.6, 2) + ctx.stroke() + ctx.beginPath() + ctx.arc(9, 10.2, 1.3, 0, Math.PI * 2) + ctx.fill() + ctx.beginPath() + ctx.moveTo(7.2, 15.8) + ctx.lineTo(10.6, 12.4) + ctx.lineTo(13.2, 15) + ctx.lineTo(15.4, 12.9) + ctx.lineTo(18.8, 15.8) + ctx.stroke() + } + + function paintMusic(ctx) { + ctx.lineWidth = 1.6 + ctx.beginPath() + ctx.moveTo(10, 16.6) + ctx.lineTo(10, 7.4) + ctx.lineTo(18, 5.8) + ctx.lineTo(18, 15.2) + ctx.stroke() + ctx.beginPath() + ctx.arc(8.2, 16.6, 2.2, 0, Math.PI * 2) + ctx.fill() + ctx.beginPath() + ctx.arc(16.2, 15.2, 2.2, 0, Math.PI * 2) + ctx.fill() + } + + function paintDocument(ctx) { + ctx.lineWidth = 1.5 + ctx.beginPath() + ctx.moveTo(7, 5.5) + ctx.lineTo(13.2, 5.5) + ctx.lineTo(17.5, 10) + ctx.lineTo(17.5, 18.5) + ctx.quadraticCurveTo(17.5, 20, 16, 20) + ctx.lineTo(7, 20) + ctx.quadraticCurveTo(5.5, 20, 5.5, 18.5) + ctx.lineTo(5.5, 7) + ctx.quadraticCurveTo(5.5, 5.5, 7, 5.5) + ctx.closePath() + ctx.stroke() + ctx.beginPath() + ctx.moveTo(13.2, 5.5) + ctx.lineTo(13.2, 10) + ctx.lineTo(17.5, 10) + ctx.stroke() + ctx.beginPath() + ctx.moveTo(8.8, 13.4) + ctx.lineTo(15.2, 13.4) + ctx.moveTo(8.8, 16.2) + ctx.lineTo(13.2, 16.2) + ctx.stroke() + } + + function roundedRect(ctx, x, y, w, h, r) { + const radius = Math.min(r, w / 2, h / 2) + ctx.moveTo(x + radius, y) + ctx.lineTo(x + w - radius, y) + ctx.quadraticCurveTo(x + w, y, x + w, y + radius) + ctx.lineTo(x + w, y + h - radius) + ctx.quadraticCurveTo(x + w, y + h, x + w - radius, y + h) + ctx.lineTo(x + radius, y + h) + ctx.quadraticCurveTo(x, y + h, x, y + h - radius) + ctx.lineTo(x, y + radius) + ctx.quadraticCurveTo(x, y, x + radius, y) + ctx.closePath() + } +} diff --git a/packages/nebula-ui/qml/Nebula/UI/NebulaIconButton.qml b/packages/nebula-ui/qml/Nebula/UI/NebulaIconButton.qml new file mode 100644 index 0000000..c77e679 --- /dev/null +++ b/packages/nebula-ui/qml/Nebula/UI/NebulaIconButton.qml @@ -0,0 +1,66 @@ +import QtQuick + +Item { + id: root + + property string label: "" + property bool active: false + property bool danger: false + readonly property bool hovered: hover.containsMouse + readonly property color iconColor: { + if (danger && hovered) + return Theme.danger + if (active) + return Theme.accentStrong + if (hovered) + return Theme.textPrimary + return Theme.textSecondary + } + + default property alias icon: iconHolder.data + + signal clicked() + + implicitWidth: Theme.controlHeight + implicitHeight: Theme.controlHeight + Accessible.role: Accessible.Button + Accessible.name: root.label + Accessible.onPressAction: root.clicked() + + Rectangle { + anchors.fill: parent + radius: Theme.radiusSmall + color: { + if (root.active) + return Theme.accentSoft + if (hover.containsMouse) + return Theme.hoverFill + return "transparent" + } + + Behavior on color { + ColorAnimation { + duration: Theme.animationFast + } + } + } + + Item { + id: iconHolder + anchors.centerIn: parent + width: 16 + height: 16 + } + + MouseArea { + id: hover + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: root.clicked() + } + + FocusFrame { + radius: Theme.radiusSmall + } +} diff --git a/packages/nebula-ui/qml/Nebula/UI/NebulaTextField.qml b/packages/nebula-ui/qml/Nebula/UI/NebulaTextField.qml new file mode 100644 index 0000000..59566b9 --- /dev/null +++ b/packages/nebula-ui/qml/Nebula/UI/NebulaTextField.qml @@ -0,0 +1,82 @@ +import QtQuick + +Item { + id: root + + property alias text: input.text + property alias placeholderText: placeholder.text + property alias input: input + + signal accepted() + + implicitHeight: Theme.searchHeight + implicitWidth: 200 + Accessible.role: Accessible.EditableText + Accessible.name: placeholder.text + Accessible.editable: true + + Rectangle { + anchors.fill: parent + radius: Theme.radiusMedium + color: Theme.searchFill + border.width: 1 + border.color: input.activeFocus ? Theme.borderStrong : Theme.border + + Behavior on border.color { + ColorAnimation { + duration: Theme.animationFast + } + } + } + + Row { + id: row + anchors.fill: parent + anchors.leftMargin: 12 + anchors.rightMargin: 12 + spacing: 10 + + NebulaIcon { + id: searchIcon + anchors.verticalCenter: parent.verticalCenter + name: "search" + size: 16 + color: input.activeFocus ? Theme.textPrimary : Theme.textSecondary + } + + Item { + width: parent.width - searchIcon.width - row.spacing + height: parent.height + + Text { + id: placeholder + anchors.fill: parent + verticalAlignment: Text.AlignVCenter + text: qsTr("Search") + color: Theme.textTertiary + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSize + visible: input.text.length === 0 && !input.preeditText + } + + TextInput { + id: input + anchors.fill: parent + verticalAlignment: Text.AlignVCenter + color: Theme.textPrimary + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSize + clip: true + selectByMouse: true + activeFocusOnTab: true + onAccepted: root.accepted() + } + } + } + + FocusFrame { + target: root + active: input.activeFocus + radius: Theme.radiusMedium + } +} diff --git a/packages/nebula-ui/qml/Nebula/UI/Theme.qml b/packages/nebula-ui/qml/Nebula/UI/Theme.qml new file mode 100644 index 0000000..648de6e --- /dev/null +++ b/packages/nebula-ui/qml/Nebula/UI/Theme.qml @@ -0,0 +1,55 @@ +pragma Singleton + +import QtQuick + +QtObject { + // Colours from the Nebula Desktop React prototype (tokens.css). + readonly property color background: "#0b0f16" + readonly property color backgroundDeep: "#080b11" + + readonly property color surface: Qt.rgba(20 / 255, 26 / 255, 36 / 255, 0.78) + readonly property color surfaceRaised: Qt.rgba(32 / 255, 40 / 255, 54 / 255, 0.9) + readonly property color topBarBackground: Qt.rgba(10 / 255, 13 / 255, 18 / 255, 0.55) + + readonly property color border: Qt.rgba(232 / 255, 236 / 255, 242 / 255, 0.08) + readonly property color borderStrong: Qt.rgba(232 / 255, 236 / 255, 242 / 255, 0.14) + + readonly property color textPrimary: "#e8ecf2" + readonly property color textSecondary: "#8e99ab" + readonly property color textTertiary: "#6b7587" + + readonly property color accent: "#7ea0c4" + readonly property color accentStrong: "#9bb6d4" + readonly property color accentSoft: Qt.rgba(126 / 255, 160 / 255, 196 / 255, 0.16) + + readonly property color danger: "#c98888" + readonly property color focus: Qt.rgba(126 / 255, 160 / 255, 196 / 255, 0.55) + + readonly property color hoverFill: Qt.rgba(1, 1, 1, 0.06) + readonly property color tileHover: Qt.rgba(1, 1, 1, 0.05) + readonly property color iconWell: Qt.rgba(1, 1, 1, 0.05) + readonly property color iconWellHighlight: Qt.rgba(1, 1, 1, 0.06) + readonly property color searchFill: Qt.rgba(8 / 255, 11 / 255, 17 / 255, 0.45) + readonly property color shadow: Qt.rgba(0, 0, 0, 0.42) + + readonly property int radiusSmall: 6 + readonly property int radiusMedium: 10 + readonly property int radiusLarge: 14 + + readonly property int spacingSmall: 8 + readonly property int spacingMedium: 16 + readonly property int spacingLarge: 24 + + readonly property int animationFast: 120 + readonly property int animationNormal: 200 + + readonly property int topBarHeight: 40 + readonly property int controlHeight: 28 + readonly property int searchHeight: 36 + + readonly property int fontSize: 13 + readonly property int fontSizeApp: 12 + readonly property int fontSizeSmall: 11 + + readonly property string fontFamily: "Ubuntu" +} diff --git a/session/nebula-session b/session/nebula-session index c573233..1b5c773 100644 --- a/session/nebula-session +++ b/session/nebula-session @@ -7,4 +7,5 @@ export XDG_CURRENT_DESKTOP=NebulaOS export XDG_SESSION_DESKTOP=nebula # Shell and services are wired here as they land -# exec nebula-shell-desktop +# Production desktop chrome: shells/desktop/shell (nebula-shell) +# exec nebula-shell diff --git a/shells/desktop/README.md b/shells/desktop/README.md new file mode 100644 index 0000000..f809f9c --- /dev/null +++ b/shells/desktop/README.md @@ -0,0 +1,178 @@ +# Nebula Desktop + +Production shell for the mouse-and-keyboard NebulaOS desktop. + +## Architecture + +Nebula Desktop is a **Qt Quick / QML** Wayland shell. It draws system chrome only. It does not host application or game windows. + +```text +Ubuntu + ↓ +Wayland + ↓ +Sway +temporary compositor + ↓ +Nebula Shell +C++20 + Qt 6 + Qt Quick/QML + LayerShellQt +``` + +Eventual target: + +```text +Ubuntu/Linux + ↓ +Wayland + ↓ +Nebula compositor + ↓ +Nebula Shell +Qt Quick/QML +``` + +Normal applications remain ordinary Wayland / XWayland clients of the compositor: + +```text +Normal Application + ↓ +Wayland / XWayland + ↓ +Compositor + ↓ +GPU +``` + +Nebula chrome: + +```text +QML + ↓ +Qt Quick + ↓ +Wayland Layer Shell + ↓ +Compositor +``` + +Games, browsers, terminals, and other apps never pass through Qt Quick. + +## Production stack + +| Piece | Technology | +|-------|------------| +| Language | C++20 | +| UI | Qt 6, Qt Quick, QML | +| Layer surfaces | LayerShellQt | +| Build | CMake | +| Shared design system | `packages/nebula-ui` (`import Nebula.UI`) | + +The executable is `nebula-shell`. + +QML module URI: `Nebula.Shell`. + +## Surfaces + +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 | + +Stacking: + +```text +OVERLAY + Nebula Launcher + +TOP + Nebula Top Bar + +NORMAL WAYLAND WINDOWS + Firefox, Steam, Blender, games, terminals, … + +BACKGROUND + Nebula Desktop +``` + +## Layout + +```text +shells/desktop/ +├── README.md this file +├── shell/ production Qt/QML shell +│ ├── CMakeLists.txt +│ ├── src/main.cpp +│ └── qml/ +└── prototype-react/ archived design reference only +``` + +Shared visual language lives in `packages/nebula-ui`, not in Desktop-specific chrome. + +## Linux build + +This project will not compile on Windows. LayerShellQt is a Wayland API and is not stubbed. + +On the Ubuntu NebulaOS VM: + +```bash +sudo apt install \ + qt6-base-dev \ + qt6-declarative-dev \ + qt6-wayland \ + liblayershellqtinterface-dev \ + qml6-module-org-kde-layershell \ + qml6-module-qtquick \ + qml6-module-qtquick-window \ + cmake \ + build-essential +``` + +Qt 6.4 or newer is required. `loadFromModule` is used on Qt 6.5+. + +```bash +cd shells/desktop/shell + +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. + +### QML lint + +If the installed Qt provides `qmllint`: + +```bash +qmllint \ + shells/desktop/shell/qml/*.qml \ + shells/desktop/shell/qml/surfaces/*.qml \ + shells/desktop/shell/qml/components/*.qml \ + shells/desktop/shell/qml/mock/*.qml \ + packages/nebula-ui/qml/Nebula/UI/*.qml +``` + +Do not use ESLint or Oxlint on the production Qt shell. + +## 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. + +## Out of scope + +Not implemented in this milestone: + +- PipeWire, NetworkManager, BlueZ, UPower +- D-Bus services +- real `.desktop` discovery or app launching +- Sway IPC, workspaces, notifications +- authentication, greetd, Gamescope +- a custom Nebula compositor +- Nebula Bigscreen + +Those come later. Bigscreen is expected to reuse `Nebula.UI` and shared C++ services with a different interaction model. diff --git a/shells/desktop/native/CMakeLists.txt b/shells/desktop/native/CMakeLists.txt deleted file mode 100644 index 1be3d1c..0000000 --- a/shells/desktop/native/CMakeLists.txt +++ /dev/null @@ -1,32 +0,0 @@ -cmake_minimum_required(VERSION 3.20) - -# Linux-only. GTK4 Layer Shell and WebKitGTK are not built or stubbed on Windows. -project(nebula-shell LANGUAGES CXX) - -set(CMAKE_CXX_STANDARD 20) -set(CMAKE_CXX_STANDARD_REQUIRED ON) -set(CMAKE_CXX_EXTENSIONS OFF) - -find_package(PkgConfig REQUIRED) -pkg_check_modules(GTK4 REQUIRED IMPORTED_TARGET gtk4) -pkg_check_modules(GTK4_LAYER_SHELL REQUIRED IMPORTED_TARGET gtk4-layer-shell-0) -pkg_check_modules(WEBKITGTK REQUIRED IMPORTED_TARGET webkitgtk-6.0) - -add_executable(nebula-shell - src/main.cpp - src/ShellApplication.cpp - src/Surface.cpp - src/DesktopSurface.cpp - src/TopBarSurface.cpp - src/LauncherSurface.cpp -) - -target_link_libraries(nebula-shell PRIVATE - PkgConfig::GTK4 - PkgConfig::GTK4_LAYER_SHELL - PkgConfig::WEBKITGTK -) - -if(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang") - target_compile_options(nebula-shell PRIVATE -Wall -Wextra) -endif() diff --git a/shells/desktop/native/README.md b/shells/desktop/native/README.md deleted file mode 100644 index 2ae06b0..0000000 --- a/shells/desktop/native/README.md +++ /dev/null @@ -1,146 +0,0 @@ -# Nebula Desktop native host - -`nebula-shell` hosts the React-based Nebula Desktop chrome as real Wayland Layer Shell surfaces. - -It is a C++ process that creates **several** GTK4 windows, each backed by WebKitGTK 6.0 and placed with **gtk4-layer-shell**. It does not composite application windows. Those remain ordinary Wayland / XWayland clients of the compositor. - -Stack: - -- C++20 -- GTK4 -- gtk4-layer-shell -- WebKitGTK 6.0 -- CMake -- Wayland - -This host is Linux-only. It will not compile on Windows. - -## Purpose - -Nebula Desktop's React UI is shell chrome only: wallpaper/desktop, top bar, and launcher. `nebula-shell` maps each of those to a Wayland layer surface so they sit in the compositor like a real desktop environment, not like a browser window. - -Normal applications and games never render inside WebKit. - -## Architecture - -```text -Nebula Shell -├── DesktopSurface -│ ├── WebKitGTK -│ ├── ?surface=desktop -│ └── BACKGROUND -│ -├── TopBarSurface -│ ├── WebKitGTK -│ ├── ?surface=topbar -│ └── TOP -│ -└── LauncherSurface - ├── WebKitGTK - ├── ?surface=launcher - └── OVERLAY -``` - -| Surface | Query | Layer Shell layer | Placement | -|-----------|-----------------------|-------------------|-----------| -| Desktop | `?surface=desktop` | `BACKGROUND` | Anchored to all monitor edges | -| Top Bar | `?surface=topbar` | `TOP` | Anchored top/left/right, 40px (matches the React top bar), exclusive zone enabled | -| Launcher | `?surface=launcher` | `OVERLAY` | Created at startup, hidden/unmapped until later toggle work | - -```text -OVERLAY - Nebula Launcher ← created, currently unmapped - notifications - OSDs - -TOP - Nebula Top Bar ← exclusive zone; apps do not go under it - -NORMAL APPLICATION WINDOWS - Firefox, Steam, Blender, games, terminals, … - -BACKGROUND - Nebula Desktop - wallpaper -``` - -React draws **shell chrome only**. Applications and games render through the compositor, not through WebKit: - -```text -App - ↓ -Wayland / XWayland - ↓ -Compositor - ↓ -GPU -``` - -## Development - -The host must not hardcode a Vite host or port. It reads a base origin from the environment and appends the surface query parameter. - -Start the React UI (the port is an example, not part of the native architecture): - -```bash -cd shells/desktop/ui -npm run dev -- --host 127.0.0.1 --port 5174 --strictPort -``` - -Point the shell at that origin: - -```bash -export NEBULA_SHELL_DEV_URL=http://127.0.0.1:5174 -``` - -That loads: - -```text -{NEBULA_SHELL_DEV_URL}/?surface=desktop -{NEBULA_SHELL_DEV_URL}/?surface=topbar -{NEBULA_SHELL_DEV_URL}/?surface=launcher -``` - -Build: - -```bash -cd shells/desktop/native - -cmake -S . -B build -cmake --build build -j$(nproc) -``` - -Run inside a Wayland compositor such as Sway: - -```bash -./build/nebula-shell -``` - -On startup the desktop and top bar are presented. The launcher is created but left hidden. - -A combined browser preview (`/?surface=preview`, also the default) is for Windows UI work only. The native host loads the three surfaces separately. - -### Linux packages - -Build dependencies (install on the Ubuntu machine, not from this tree): - -```text -libgtk-4-dev -libgtk4-layer-shell-dev -libwebkitgtk-6.0-dev -pkg-config -cmake -build-essential -``` - -## Production - -Production loading from bundled `shells/desktop/ui/dist` files is not implemented yet. This milestone is development-URL loading only. - -## Shell bridge - -Not implemented in this milestone. Each WebKit view will later talk to the host through a JavaScript bridge (`shellBridge` in the React UI) so the top bar can map and unmap `LauncherSurface`. - -## Current development compositor - -Sway is the temporary compositor. See `compositor/sway/nebula-sway.conf`. diff --git a/shells/desktop/native/src/DesktopSurface.cpp b/shells/desktop/native/src/DesktopSurface.cpp deleted file mode 100644 index 5c846eb..0000000 --- a/shells/desktop/native/src/DesktopSurface.cpp +++ /dev/null @@ -1,17 +0,0 @@ -#include "DesktopSurface.hpp" - -DesktopSurface::DesktopSurface(GtkApplication* app, const std::string& base_url) - : Surface(app, base_url, Config{ - .layer_namespace = "nebula-desktop", - .surface_name = "desktop", - .layer = GTK_LAYER_SHELL_LAYER_BACKGROUND, - .anchor_top = true, - .anchor_bottom = true, - .anchor_left = true, - .anchor_right = true, - .height_px = -1, - .keyboard_mode = GTK_LAYER_SHELL_KEYBOARD_MODE_NONE, - .auto_exclusive_zone = false, - }) -{ -} diff --git a/shells/desktop/native/src/DesktopSurface.hpp b/shells/desktop/native/src/DesktopSurface.hpp deleted file mode 100644 index df85fd2..0000000 --- a/shells/desktop/native/src/DesktopSurface.hpp +++ /dev/null @@ -1,10 +0,0 @@ -#pragma once - -#include "Surface.hpp" - -#include - -class DesktopSurface : public Surface { -public: - DesktopSurface(GtkApplication* app, const std::string& base_url); -}; diff --git a/shells/desktop/native/src/LauncherSurface.cpp b/shells/desktop/native/src/LauncherSurface.cpp deleted file mode 100644 index b6152fb..0000000 --- a/shells/desktop/native/src/LauncherSurface.cpp +++ /dev/null @@ -1,21 +0,0 @@ -#include "LauncherSurface.hpp" - -LauncherSurface::LauncherSurface(GtkApplication* app, const std::string& base_url) - : Surface(app, base_url, Config{ - .layer_namespace = "nebula-launcher", - .surface_name = "launcher", - .layer = GTK_LAYER_SHELL_LAYER_OVERLAY, - .anchor_top = true, - .anchor_bottom = true, - .anchor_left = true, - .anchor_right = true, - .height_px = -1, - .keyboard_mode = GTK_LAYER_SHELL_KEYBOARD_MODE_ON_DEMAND, - .auto_exclusive_zone = false, - }) -{ - // Created and fully configured, but left unmapped on purpose. - // Later, when shellBridge is wired to WebKitGTK, call present() to show - // the launcher and gtk_widget_set_visible(GTK_WIDGET(window()), FALSE) to hide it. - gtk_widget_set_visible(GTK_WIDGET(window()), FALSE); -} diff --git a/shells/desktop/native/src/LauncherSurface.hpp b/shells/desktop/native/src/LauncherSurface.hpp deleted file mode 100644 index 5164afd..0000000 --- a/shells/desktop/native/src/LauncherSurface.hpp +++ /dev/null @@ -1,10 +0,0 @@ -#pragma once - -#include "Surface.hpp" - -#include - -class LauncherSurface : public Surface { -public: - LauncherSurface(GtkApplication* app, const std::string& base_url); -}; diff --git a/shells/desktop/native/src/ShellApplication.cpp b/shells/desktop/native/src/ShellApplication.cpp deleted file mode 100644 index 5f3270f..0000000 --- a/shells/desktop/native/src/ShellApplication.cpp +++ /dev/null @@ -1,107 +0,0 @@ -#include "ShellApplication.hpp" - -#include - -#include -#include -#include -#include -#include - -#ifndef G_APPLICATION_DEFAULT_FLAGS -#define G_APPLICATION_DEFAULT_FLAGS G_APPLICATION_FLAGS_NONE -#endif - -namespace { - -std::string trim_copy(std::string value) -{ - const auto is_not_space = [](unsigned char c) { return !std::isspace(c); }; - value.erase(value.begin(), std::find_if(value.begin(), value.end(), is_not_space)); - value.erase(std::find_if(value.rbegin(), value.rend(), is_not_space).base(), value.end()); - return value; -} - -std::string read_dev_url() -{ - const char* value = std::getenv("NEBULA_SHELL_DEV_URL"); - if (value == nullptr) { - return {}; - } - return trim_copy(value); -} - -void print_missing_dev_url_error() -{ - std::cerr - << "nebula-shell: NEBULA_SHELL_DEV_URL is not set or empty.\n" - << "Set it to the origin of the Nebula Desktop UI, then run nebula-shell again.\n" - << "Example:\n" - << " export NEBULA_SHELL_DEV_URL=://:\n"; -} - -} // namespace - -void ShellApplication::on_activate(GtkApplication* app, gpointer user_data) -{ - auto* self = static_cast(user_data); - self->activate(app); -} - -void ShellApplication::activate(GtkApplication* app) -{ - if (desktop_ != nullptr) { - return; - } - - if (!gtk_layer_is_supported()) { - std::cerr - << "nebula-shell: this session does not support wlr-layer-shell.\n" - << "Run nebula-shell inside a Wayland compositor such as Sway.\n"; - g_application_quit(G_APPLICATION(app)); - return; - } - - desktop_ = std::make_unique(app, base_url_); - topBar_ = std::make_unique(app, base_url_); - launcher_ = std::make_unique(app, base_url_); - - desktop_->present(); - topBar_->present(); - // launcher_ is created above but not presented; it stays hidden/unmapped. - - started_ = true; -} - -int ShellApplication::run(int argc, char** argv) -{ - base_url_ = read_dev_url(); - if (base_url_.empty()) { - print_missing_dev_url_error(); - return EXIT_FAILURE; - } - - if (base_url_.find("://") == std::string::npos) { - std::cerr - << "nebula-shell: NEBULA_SHELL_DEV_URL must include a URI scheme " - << "(for example http or https).\n"; - return EXIT_FAILURE; - } - - GtkApplication* app = gtk_application_new("org.nebulaos.shell", G_APPLICATION_DEFAULT_FLAGS); - if (app == nullptr) { - std::cerr << "nebula-shell: failed to create the GTK application.\n"; - return EXIT_FAILURE; - } - - g_signal_connect(app, "activate", G_CALLBACK(on_activate), this); - - const int status = g_application_run(G_APPLICATION(app), argc, argv); - g_object_unref(app); - - if (!started_) { - return EXIT_FAILURE; - } - - return status; -} diff --git a/shells/desktop/native/src/ShellApplication.hpp b/shells/desktop/native/src/ShellApplication.hpp deleted file mode 100644 index f8f67de..0000000 --- a/shells/desktop/native/src/ShellApplication.hpp +++ /dev/null @@ -1,26 +0,0 @@ -#pragma once - -#include "DesktopSurface.hpp" -#include "LauncherSurface.hpp" -#include "TopBarSurface.hpp" - -#include - -#include -#include - -class ShellApplication { -public: - int run(int argc, char** argv); - -private: - static void on_activate(GtkApplication* app, gpointer user_data); - void activate(GtkApplication* app); - - std::string base_url_; - bool started_ = false; - - std::unique_ptr desktop_; - std::unique_ptr topBar_; - std::unique_ptr launcher_; -}; diff --git a/shells/desktop/native/src/Surface.cpp b/shells/desktop/native/src/Surface.cpp deleted file mode 100644 index 7d1c835..0000000 --- a/shells/desktop/native/src/Surface.cpp +++ /dev/null @@ -1,115 +0,0 @@ -#include "Surface.hpp" - -#include - -#include - -namespace { - -void apply_transparent_window_css() -{ - static bool applied = false; - if (applied) { - return; - } - applied = true; - - GtkCssProvider* provider = gtk_css_provider_new(); - const char* css = "window.nebula-shell-surface { background-color: transparent; }"; - -#if GTK_CHECK_VERSION(4, 12, 0) - gtk_css_provider_load_from_string(provider, css); -#else - gtk_css_provider_load_from_data(provider, css, -1); -#endif - - GdkDisplay* display = gdk_display_get_default(); - if (display != nullptr) { - gtk_style_context_add_provider_for_display( - display, - GTK_STYLE_PROVIDER(provider), - GTK_STYLE_PROVIDER_PRIORITY_APPLICATION); - } - - g_object_unref(provider); -} - -gboolean on_load_failed( - [[maybe_unused]] WebKitWebView* web_view, - [[maybe_unused]] WebKitLoadEvent load_event, - char* failing_uri, - GError* error, - [[maybe_unused]] gpointer user_data) -{ - const char* uri = failing_uri != nullptr ? failing_uri : "(unknown URI)"; - const char* message = (error != nullptr && error->message != nullptr) - ? error->message - : "unknown error"; - std::cerr << "nebula-shell: failed to load " << uri << ": " << message << '\n'; - return FALSE; -} - -} // namespace - -std::string make_surface_url(std::string_view base_url, std::string_view surface_name) -{ - std::string url{base_url}; - while (!url.empty() && url.back() == '/') { - url.pop_back(); - } - url += "/?surface="; - url += surface_name; - return url; -} - -Surface::Surface(GtkApplication* app, const std::string& base_url, const Config& config) -{ - apply_transparent_window_css(); - - const std::string url = make_surface_url(base_url, config.surface_name); - - window_ = GTK_WINDOW(gtk_application_window_new(app)); - gtk_window_set_decorated(window_, FALSE); - gtk_window_set_title(window_, config.layer_namespace); - gtk_widget_add_css_class(GTK_WIDGET(window_), "nebula-shell-surface"); - - // Layer Shell must be configured before the window is realized or presented. - gtk_layer_init_for_window(window_); - gtk_layer_set_namespace(window_, config.layer_namespace); - gtk_layer_set_layer(window_, config.layer); - gtk_layer_set_anchor(window_, GTK_LAYER_SHELL_EDGE_TOP, config.anchor_top); - gtk_layer_set_anchor(window_, GTK_LAYER_SHELL_EDGE_BOTTOM, config.anchor_bottom); - gtk_layer_set_anchor(window_, GTK_LAYER_SHELL_EDGE_LEFT, config.anchor_left); - gtk_layer_set_anchor(window_, GTK_LAYER_SHELL_EDGE_RIGHT, config.anchor_right); - gtk_layer_set_keyboard_mode(window_, config.keyboard_mode); - - if (config.auto_exclusive_zone) { - gtk_layer_auto_exclusive_zone_enable(window_); - } else { - gtk_layer_set_exclusive_zone(window_, 0); - } - - if (config.height_px > 0) { - gtk_window_set_default_size(window_, 1, config.height_px); - gtk_widget_set_size_request(GTK_WIDGET(window_), -1, config.height_px); - } - - GtkWidget* webview_widget = webkit_web_view_new(); - WebKitWebView* webview = WEBKIT_WEB_VIEW(webview_widget); - - GdkRGBA transparent{0.0f, 0.0f, 0.0f, 0.0f}; - webkit_web_view_set_background_color(webview, &transparent); - - gtk_widget_set_hexpand(webview_widget, TRUE); - gtk_widget_set_vexpand(webview_widget, TRUE); - - g_signal_connect(webview, "load-failed", G_CALLBACK(on_load_failed), nullptr); - - gtk_window_set_child(window_, webview_widget); - webkit_web_view_load_uri(webview, url.c_str()); -} - -void Surface::present() -{ - gtk_window_present(window_); -} diff --git a/shells/desktop/native/src/Surface.hpp b/shells/desktop/native/src/Surface.hpp deleted file mode 100644 index 22739ac..0000000 --- a/shells/desktop/native/src/Surface.hpp +++ /dev/null @@ -1,40 +0,0 @@ -#pragma once - -#include -#include - -#include -#include - -// Shared Layer Shell + WebKitGTK surface. Subclasses only supply layout. -class Surface { -public: - struct Config { - const char* layer_namespace = nullptr; - const char* surface_name = nullptr; - GtkLayerShellLayer layer = GTK_LAYER_SHELL_LAYER_TOP; - bool anchor_top = false; - bool anchor_bottom = false; - bool anchor_left = false; - bool anchor_right = false; - int height_px = -1; - GtkLayerShellKeyboardMode keyboard_mode = GTK_LAYER_SHELL_KEYBOARD_MODE_NONE; - bool auto_exclusive_zone = false; - }; - - Surface(GtkApplication* app, const std::string& base_url, const Config& config); - ~Surface() = default; - - Surface(const Surface&) = delete; - Surface& operator=(const Surface&) = delete; - Surface(Surface&&) = delete; - Surface& operator=(Surface&&) = delete; - - void present(); - GtkWindow* window() const { return window_; } - -private: - GtkWindow* window_ = nullptr; -}; - -std::string make_surface_url(std::string_view base_url, std::string_view surface_name); diff --git a/shells/desktop/native/src/TopBarSurface.cpp b/shells/desktop/native/src/TopBarSurface.cpp deleted file mode 100644 index 4e11088..0000000 --- a/shells/desktop/native/src/TopBarSurface.cpp +++ /dev/null @@ -1,17 +0,0 @@ -#include "TopBarSurface.hpp" - -TopBarSurface::TopBarSurface(GtkApplication* app, const std::string& base_url) - : Surface(app, base_url, Config{ - .layer_namespace = "nebula-topbar", - .surface_name = "topbar", - .layer = GTK_LAYER_SHELL_LAYER_TOP, - .anchor_top = true, - .anchor_bottom = false, - .anchor_left = true, - .anchor_right = true, - .height_px = kTopBarHeightPx, - .keyboard_mode = GTK_LAYER_SHELL_KEYBOARD_MODE_NONE, - .auto_exclusive_zone = true, - }) -{ -} diff --git a/shells/desktop/native/src/TopBarSurface.hpp b/shells/desktop/native/src/TopBarSurface.hpp deleted file mode 100644 index 673bb3f..0000000 --- a/shells/desktop/native/src/TopBarSurface.hpp +++ /dev/null @@ -1,13 +0,0 @@ -#pragma once - -#include "Surface.hpp" - -#include - -// Matches --nebula-topbar-height in shells/desktop/ui/src/styles/tokens.css. -inline constexpr int kTopBarHeightPx = 40; - -class TopBarSurface : public Surface { -public: - TopBarSurface(GtkApplication* app, const std::string& base_url); -}; diff --git a/shells/desktop/native/src/main.cpp b/shells/desktop/native/src/main.cpp deleted file mode 100644 index 1f773ed..0000000 --- a/shells/desktop/native/src/main.cpp +++ /dev/null @@ -1,7 +0,0 @@ -#include "ShellApplication.hpp" - -int main(int argc, char* argv[]) -{ - ShellApplication application; - return application.run(argc, argv); -} diff --git a/shells/desktop/ui/.gitignore b/shells/desktop/prototype-react/.gitignore similarity index 100% rename from shells/desktop/ui/.gitignore rename to shells/desktop/prototype-react/.gitignore diff --git a/shells/desktop/ui/.oxlintrc.json b/shells/desktop/prototype-react/.oxlintrc.json similarity index 100% rename from shells/desktop/ui/.oxlintrc.json rename to shells/desktop/prototype-react/.oxlintrc.json diff --git a/shells/desktop/prototype-react/README.md b/shells/desktop/prototype-react/README.md new file mode 100644 index 0000000..6839903 --- /dev/null +++ b/shells/desktop/prototype-react/README.md @@ -0,0 +1,48 @@ +# Nebula Desktop UI — prototype (archived) + +```text +DESIGN REFERENCE ONLY +NOT THE PRODUCTION NEBULA SHELL +``` + +This directory is the original React + Vite desktop chrome. It remains only as a visual and behavioural reference while the Qt Quick/QML shell catches up. + +**Do not add production functionality here.** + +The production Nebula Desktop shell is: + +```text +shells/desktop/shell +C++20 + Qt 6 + Qt Quick/QML + LayerShellQt +``` + +This prototype must not be launched as the OS shell. It does not talk to Wayland layer-shell, and it is not wired to Nebula Core. + +## What this is for + +- comparing layout, colour, spacing, and motion against `shells/desktop/shell` +- checking launcher search, mock launch, and top-bar treatment +- discarding this tree later, once Qt parity is accepted + +## Surfaces (browser preview only) + +| URL | Renders | +|-----|---------| +| `/?surface=preview` (default) | Combined mockup | +| `/?surface=desktop` | Background only | +| `/?surface=topbar` | Top bar only | +| `/?surface=launcher` | Launcher only | + +## Preview on a development machine + +```powershell +npm install +npm run dev +``` + +```powershell +npm run lint +npm run build +``` + +Oxlint applies to this JavaScript prototype only. It is not used for the Qt shell. diff --git a/shells/desktop/ui/index.html b/shells/desktop/prototype-react/index.html similarity index 100% rename from shells/desktop/ui/index.html rename to shells/desktop/prototype-react/index.html diff --git a/shells/desktop/ui/package-lock.json b/shells/desktop/prototype-react/package-lock.json similarity index 100% rename from shells/desktop/ui/package-lock.json rename to shells/desktop/prototype-react/package-lock.json diff --git a/shells/desktop/ui/package.json b/shells/desktop/prototype-react/package.json similarity index 100% rename from shells/desktop/ui/package.json rename to shells/desktop/prototype-react/package.json diff --git a/shells/desktop/ui/public/favicon.svg b/shells/desktop/prototype-react/public/favicon.svg similarity index 100% rename from shells/desktop/ui/public/favicon.svg rename to shells/desktop/prototype-react/public/favicon.svg diff --git a/shells/desktop/ui/src/App.jsx b/shells/desktop/prototype-react/src/App.jsx similarity index 100% rename from shells/desktop/ui/src/App.jsx rename to shells/desktop/prototype-react/src/App.jsx diff --git a/shells/desktop/ui/src/components/desktop/Desktop.css b/shells/desktop/prototype-react/src/components/desktop/Desktop.css similarity index 100% rename from shells/desktop/ui/src/components/desktop/Desktop.css rename to shells/desktop/prototype-react/src/components/desktop/Desktop.css diff --git a/shells/desktop/ui/src/components/desktop/Desktop.jsx b/shells/desktop/prototype-react/src/components/desktop/Desktop.jsx similarity index 100% rename from shells/desktop/ui/src/components/desktop/Desktop.jsx rename to shells/desktop/prototype-react/src/components/desktop/Desktop.jsx diff --git a/shells/desktop/ui/src/components/icons.jsx b/shells/desktop/prototype-react/src/components/icons.jsx similarity index 100% rename from shells/desktop/ui/src/components/icons.jsx rename to shells/desktop/prototype-react/src/components/icons.jsx diff --git a/shells/desktop/ui/src/components/launcher/AppItem.jsx b/shells/desktop/prototype-react/src/components/launcher/AppItem.jsx similarity index 100% rename from shells/desktop/ui/src/components/launcher/AppItem.jsx rename to shells/desktop/prototype-react/src/components/launcher/AppItem.jsx diff --git a/shells/desktop/ui/src/components/launcher/Launcher.css b/shells/desktop/prototype-react/src/components/launcher/Launcher.css similarity index 100% rename from shells/desktop/ui/src/components/launcher/Launcher.css rename to shells/desktop/prototype-react/src/components/launcher/Launcher.css diff --git a/shells/desktop/ui/src/components/launcher/Launcher.jsx b/shells/desktop/prototype-react/src/components/launcher/Launcher.jsx similarity index 100% rename from shells/desktop/ui/src/components/launcher/Launcher.jsx rename to shells/desktop/prototype-react/src/components/launcher/Launcher.jsx diff --git a/shells/desktop/ui/src/components/system/Clock.jsx b/shells/desktop/prototype-react/src/components/system/Clock.jsx similarity index 100% rename from shells/desktop/ui/src/components/system/Clock.jsx rename to shells/desktop/prototype-react/src/components/system/Clock.jsx diff --git a/shells/desktop/ui/src/components/system/SystemArea.css b/shells/desktop/prototype-react/src/components/system/SystemArea.css similarity index 100% rename from shells/desktop/ui/src/components/system/SystemArea.css rename to shells/desktop/prototype-react/src/components/system/SystemArea.css diff --git a/shells/desktop/ui/src/components/system/SystemArea.jsx b/shells/desktop/prototype-react/src/components/system/SystemArea.jsx similarity index 100% rename from shells/desktop/ui/src/components/system/SystemArea.jsx rename to shells/desktop/prototype-react/src/components/system/SystemArea.jsx diff --git a/shells/desktop/ui/src/components/topbar/TopBar.css b/shells/desktop/prototype-react/src/components/topbar/TopBar.css similarity index 100% rename from shells/desktop/ui/src/components/topbar/TopBar.css rename to shells/desktop/prototype-react/src/components/topbar/TopBar.css diff --git a/shells/desktop/ui/src/components/topbar/TopBar.jsx b/shells/desktop/prototype-react/src/components/topbar/TopBar.jsx similarity index 100% rename from shells/desktop/ui/src/components/topbar/TopBar.jsx rename to shells/desktop/prototype-react/src/components/topbar/TopBar.jsx diff --git a/shells/desktop/ui/src/components/ui/IconButton.jsx b/shells/desktop/prototype-react/src/components/ui/IconButton.jsx similarity index 100% rename from shells/desktop/ui/src/components/ui/IconButton.jsx rename to shells/desktop/prototype-react/src/components/ui/IconButton.jsx diff --git a/shells/desktop/ui/src/components/ui/SearchField.jsx b/shells/desktop/prototype-react/src/components/ui/SearchField.jsx similarity index 100% rename from shells/desktop/ui/src/components/ui/SearchField.jsx rename to shells/desktop/prototype-react/src/components/ui/SearchField.jsx diff --git a/shells/desktop/ui/src/components/ui/controls.css b/shells/desktop/prototype-react/src/components/ui/controls.css similarity index 100% rename from shells/desktop/ui/src/components/ui/controls.css rename to shells/desktop/prototype-react/src/components/ui/controls.css diff --git a/shells/desktop/ui/src/data/mockApps.js b/shells/desktop/prototype-react/src/data/mockApps.js similarity index 100% rename from shells/desktop/ui/src/data/mockApps.js rename to shells/desktop/prototype-react/src/data/mockApps.js diff --git a/shells/desktop/ui/src/lib/nebula.js b/shells/desktop/prototype-react/src/lib/nebula.js similarity index 85% rename from shells/desktop/ui/src/lib/nebula.js rename to shells/desktop/prototype-react/src/lib/nebula.js index 784b4bf..fb88898 100644 --- a/shells/desktop/ui/src/lib/nebula.js +++ b/shells/desktop/prototype-react/src/lib/nebula.js @@ -1,13 +1,13 @@ /** * Nebula JavaScript API * - * Boundary between the React shell UI and the future native Nebula Core: + * Placeholder APIs for the archived React prototype. * - * React UI → nebula.* → native host / Nebula Core + * Archived React prototype → nebula.* placeholders * - * Every method here is a placeholder. Implementations must not call Node - * APIs, spawn processes, or talk to D-Bus, PipeWire, NetworkManager, or - * Wayland. The WebKitGTK host will later inject a real bridge. + * Production system APIs will be C++ services exposed to QML, not this + * JavaScript module. Methods here must not call Node APIs, spawn processes, + * or talk to D-Bus, PipeWire, NetworkManager, or Wayland. * * The shell is chrome only. Applications and games render as compositor * surfaces, not inside this React tree. diff --git a/shells/desktop/ui/src/lib/shellBridge.js b/shells/desktop/prototype-react/src/lib/shellBridge.js similarity index 89% rename from shells/desktop/ui/src/lib/shellBridge.js rename to shells/desktop/prototype-react/src/lib/shellBridge.js index 04d7d12..86eac60 100644 --- a/shells/desktop/ui/src/lib/shellBridge.js +++ b/shells/desktop/prototype-react/src/lib/shellBridge.js @@ -1,9 +1,8 @@ /** * Shell coordination bridge. * - * Top bar, launcher, and desktop will later live in separate WebKitGTK views. - * They must not share React component state. The native host will replace this - * module with an injected C++ bridge. + * In-page mock for coordinating the archived browser preview. + * Production shell state is the QML ShellState singleton, not this bridge. * * This development mock keeps in-page state and broadcasts it with window * CustomEvents. No networking, WebSockets, or Node APIs. diff --git a/shells/desktop/ui/src/lib/surface.js b/shells/desktop/prototype-react/src/lib/surface.js similarity index 80% rename from shells/desktop/ui/src/lib/surface.js rename to shells/desktop/prototype-react/src/lib/surface.js index 7d0d38b..23f314f 100644 --- a/shells/desktop/ui/src/lib/surface.js +++ b/shells/desktop/prototype-react/src/lib/surface.js @@ -1,9 +1,8 @@ /** * Shell surface routing. * - * The native host will load this UI in separate WebKitGTK views, each with a - * surface query parameter. Never hardcode a host or port — the loader decides - * the URL; this module only reads the current location. + * Browser preview routing for the archived React prototype. + * Production surfaces are separate Qt Quick windows, not WebKit views. */ export const SURFACES = { diff --git a/shells/desktop/ui/src/lib/time.js b/shells/desktop/prototype-react/src/lib/time.js similarity index 100% rename from shells/desktop/ui/src/lib/time.js rename to shells/desktop/prototype-react/src/lib/time.js diff --git a/shells/desktop/ui/src/lib/useShellState.js b/shells/desktop/prototype-react/src/lib/useShellState.js similarity index 100% rename from shells/desktop/ui/src/lib/useShellState.js rename to shells/desktop/prototype-react/src/lib/useShellState.js diff --git a/shells/desktop/ui/src/main.jsx b/shells/desktop/prototype-react/src/main.jsx similarity index 100% rename from shells/desktop/ui/src/main.jsx rename to shells/desktop/prototype-react/src/main.jsx diff --git a/shells/desktop/ui/src/styles/globals.css b/shells/desktop/prototype-react/src/styles/globals.css similarity index 100% rename from shells/desktop/ui/src/styles/globals.css rename to shells/desktop/prototype-react/src/styles/globals.css diff --git a/shells/desktop/ui/src/styles/tokens.css b/shells/desktop/prototype-react/src/styles/tokens.css similarity index 100% rename from shells/desktop/ui/src/styles/tokens.css rename to shells/desktop/prototype-react/src/styles/tokens.css diff --git a/shells/desktop/ui/src/surfaces/DesktopSurface.jsx b/shells/desktop/prototype-react/src/surfaces/DesktopSurface.jsx similarity index 100% rename from shells/desktop/ui/src/surfaces/DesktopSurface.jsx rename to shells/desktop/prototype-react/src/surfaces/DesktopSurface.jsx diff --git a/shells/desktop/ui/src/surfaces/LauncherSurface.jsx b/shells/desktop/prototype-react/src/surfaces/LauncherSurface.jsx similarity index 100% rename from shells/desktop/ui/src/surfaces/LauncherSurface.jsx rename to shells/desktop/prototype-react/src/surfaces/LauncherSurface.jsx diff --git a/shells/desktop/ui/src/surfaces/PreviewSurface.css b/shells/desktop/prototype-react/src/surfaces/PreviewSurface.css similarity index 100% rename from shells/desktop/ui/src/surfaces/PreviewSurface.css rename to shells/desktop/prototype-react/src/surfaces/PreviewSurface.css diff --git a/shells/desktop/ui/src/surfaces/PreviewSurface.jsx b/shells/desktop/prototype-react/src/surfaces/PreviewSurface.jsx similarity index 90% rename from shells/desktop/ui/src/surfaces/PreviewSurface.jsx rename to shells/desktop/prototype-react/src/surfaces/PreviewSurface.jsx index 45ceebb..3acae5b 100644 --- a/shells/desktop/ui/src/surfaces/PreviewSurface.jsx +++ b/shells/desktop/prototype-react/src/surfaces/PreviewSurface.jsx @@ -9,7 +9,8 @@ import './surfaces.css'; /** * Browser-only composition of the three shell surfaces. - * Production loads each surface in its own WebKitGTK view. + * Browser-only composition of the three shell surfaces. + * Archived design reference — production chrome is Qt Quick/QML. */ export function PreviewSurface() { const { launcherOpen } = useShellState(); diff --git a/shells/desktop/ui/src/surfaces/TopBarSurface.jsx b/shells/desktop/prototype-react/src/surfaces/TopBarSurface.jsx similarity index 100% rename from shells/desktop/ui/src/surfaces/TopBarSurface.jsx rename to shells/desktop/prototype-react/src/surfaces/TopBarSurface.jsx diff --git a/shells/desktop/ui/src/surfaces/surfaces.css b/shells/desktop/prototype-react/src/surfaces/surfaces.css similarity index 100% rename from shells/desktop/ui/src/surfaces/surfaces.css rename to shells/desktop/prototype-react/src/surfaces/surfaces.css diff --git a/shells/desktop/ui/vite.config.js b/shells/desktop/prototype-react/vite.config.js similarity index 100% rename from shells/desktop/ui/vite.config.js rename to shells/desktop/prototype-react/vite.config.js diff --git a/shells/desktop/shell/CMakeLists.txt b/shells/desktop/shell/CMakeLists.txt new file mode 100644 index 0000000..439e41d --- /dev/null +++ b/shells/desktop/shell/CMakeLists.txt @@ -0,0 +1,98 @@ +cmake_minimum_required(VERSION 3.20) + +if(NOT CMAKE_SYSTEM_NAME STREQUAL "Linux") + message(FATAL_ERROR + "nebula-shell is a Linux Wayland shell using LayerShellQt.\n" + "Build and run it on the Ubuntu NebulaOS VM. Windows is not a supported target, " + "and LayerShellQt is not stubbed with other platform APIs.") +endif() + +project(nebula-shell LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) + +find_package(Qt6 6.4 REQUIRED COMPONENTS Core Gui Qml Quick) +find_package(LayerShellQt REQUIRED) + +qt_standard_project_setup(REQUIRES 6.4) + +set(QT_QML_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/qml") + +add_subdirectory( + "${CMAKE_CURRENT_SOURCE_DIR}/../../../packages/nebula-ui" + "${CMAKE_CURRENT_BINARY_DIR}/nebula-ui" +) + +qt_add_executable(nebula-shell + src/main.cpp +) + +set(NEBULA_SHELL_QML_FILES + qml/Main.qml + qml/ShellState.qml + qml/surfaces/DesktopSurface.qml + qml/surfaces/TopBarSurface.qml + qml/surfaces/LauncherSurface.qml + qml/components/TopBar.qml + qml/components/NebulaButton.qml + qml/components/Clock.qml + qml/components/SystemArea.qml + qml/components/Launcher.qml + qml/components/AppItem.qml + qml/mock/MockApps.qml +) + +foreach(qml_file IN LISTS NEBULA_SHELL_QML_FILES) + get_filename_component(qml_name "${qml_file}" NAME) + set_source_files_properties("${qml_file}" PROPERTIES + QT_RESOURCE_ALIAS "${qml_name}" + ) +endforeach() + +set_source_files_properties(qml/ShellState.qml PROPERTIES + QT_QML_SINGLETON_TYPE TRUE + QT_RESOURCE_ALIAS ShellState.qml +) +set_source_files_properties(qml/mock/MockApps.qml PROPERTIES + QT_QML_SINGLETON_TYPE TRUE + QT_RESOURCE_ALIAS MockApps.qml +) + +qt_add_qml_module(nebula-shell + URI Nebula.Shell + VERSION 1.0 + RESOURCE_PREFIX /qt/qml + QML_FILES ${NEBULA_SHELL_QML_FILES} + DEPENDENCIES + QtQuick + Nebula.UI +) + +target_link_libraries(nebula-shell + PRIVATE + Qt6::Core + Qt6::Gui + Qt6::Qml + Qt6::Quick + LayerShellQt::Interface + NebulaUI +) + +if(TARGET NebulaUIplugin) + target_link_libraries(nebula-shell PRIVATE NebulaUIplugin) +endif() + +set_target_properties(nebula-shell PROPERTIES + OUTPUT_NAME nebula-shell + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}" +) + +if(COMMAND qt_import_qml_plugins) + qt_import_qml_plugins(nebula-shell) +endif() + +if(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang") + target_compile_options(nebula-shell PRIVATE -Wall -Wextra) +endif() diff --git a/shells/desktop/shell/qml/Main.qml b/shells/desktop/shell/qml/Main.qml new file mode 100644 index 0000000..24dee69 --- /dev/null +++ b/shells/desktop/shell/qml/Main.qml @@ -0,0 +1,16 @@ +import QtQuick + +QtObject { + id: shell + + readonly property DesktopSurface desktop: DesktopSurface {} + readonly property TopBarSurface topBar: TopBarSurface {} + readonly property LauncherSurface launcher: LauncherSurface {} + + Shortcut { + sequence: "Escape" + enabled: ShellState.launcherOpen + context: Qt.ApplicationShortcut + onActivated: ShellState.closeLauncher() + } +} diff --git a/shells/desktop/shell/qml/ShellState.qml b/shells/desktop/shell/qml/ShellState.qml new file mode 100644 index 0000000..4041453 --- /dev/null +++ b/shells/desktop/shell/qml/ShellState.qml @@ -0,0 +1,25 @@ +pragma Singleton + +import QtQuick + +QtObject { + property bool launcherOpen: false + property string activeApplication: "Desktop" + + function openLauncher() { + launcherOpen = true + } + + function closeLauncher() { + launcherOpen = false + } + + function toggleLauncher() { + launcherOpen = !launcherOpen + } + + function launch(name) { + activeApplication = name + closeLauncher() + } +} diff --git a/shells/desktop/shell/qml/components/AppItem.qml b/shells/desktop/shell/qml/components/AppItem.qml new file mode 100644 index 0000000..3e2b80e --- /dev/null +++ b/shells/desktop/shell/qml/components/AppItem.qml @@ -0,0 +1,134 @@ +import QtQuick +import Nebula.UI + +Item { + id: root + + property string variant: "tile" + property var item + signal selected(var item) + + readonly property bool rowVariant: variant === "row" + readonly property int iconSize: rowVariant ? 32 : (variant === "recent" ? 32 : 40) + readonly property int glyphSize: rowVariant ? 16 : 18 + + implicitWidth: rowVariant ? parent ? parent.width : 200 : 90 + implicitHeight: rowVariant ? 48 : 78 + Accessible.role: Accessible.Button + Accessible.name: item ? item.name : "" + Accessible.onPressAction: root.selected(item) + + Rectangle { + anchors.fill: parent + radius: Theme.radiusMedium + color: hover.containsMouse ? Theme.tileHover : "transparent" + + Behavior on color { + ColorAnimation { + duration: Theme.animationFast + } + } + } + + Column { + visible: !root.rowVariant + anchors.fill: parent + anchors.topMargin: 10 + anchors.bottomMargin: 8 + anchors.leftMargin: 6 + anchors.rightMargin: 6 + spacing: 8 + + Rectangle { + width: root.iconSize + height: root.iconSize + radius: root.variant === "recent" ? 9 : 11 + anchors.horizontalCenter: parent.horizontalCenter + color: Theme.iconWell + + Rectangle { + anchors.fill: parent + radius: parent.radius + color: "transparent" + border.color: Theme.iconWellHighlight + border.width: 1 + } + + NebulaIcon { + anchors.centerIn: parent + name: item && item.glyph ? item.glyph : "folder" + size: root.glyphSize + color: item && item.tint ? item.tint : Theme.accent + } + } + + Text { + width: parent.width + text: item ? item.name : "" + color: Theme.textPrimary + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeApp + elide: Text.ElideRight + horizontalAlignment: Text.AlignHCenter + } + } + + Row { + visible: root.rowVariant + anchors.fill: parent + anchors.margins: 8 + spacing: 10 + + Rectangle { + width: root.iconSize + height: root.iconSize + radius: 9 + anchors.verticalCenter: parent.verticalCenter + color: Theme.iconWell + + NebulaIcon { + anchors.centerIn: parent + name: item && (item.glyph || item.kind) ? (item.glyph || item.kind) : "folder" + size: root.glyphSize + color: item && item.tint ? item.tint : Theme.accent + } + } + + Column { + anchors.verticalCenter: parent.verticalCenter + width: parent.width - root.iconSize - 10 + spacing: 1 + + Text { + width: parent.width + text: item ? item.name : "" + color: Theme.textPrimary + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeApp + elide: Text.ElideRight + } + + Text { + width: parent.width + visible: item && item.subtitle ? true : false + text: item && item.subtitle ? item.subtitle : "" + color: Theme.textTertiary + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSmall + elide: Text.ElideRight + } + } + } + + MouseArea { + id: hover + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: root.selected(item) + } + + FocusFrame { + radius: Theme.radiusMedium + } +} diff --git a/shells/desktop/shell/qml/components/Clock.qml b/shells/desktop/shell/qml/components/Clock.qml new file mode 100644 index 0000000..47eec5c --- /dev/null +++ b/shells/desktop/shell/qml/components/Clock.qml @@ -0,0 +1,39 @@ +import QtQuick +import QtQml +import Nebula.UI + +Text { + id: root + + property date now: new Date() + + text: formatClock(now) + color: Theme.textPrimary + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSize + verticalAlignment: Text.AlignVCenter + horizontalAlignment: Text.AlignHCenter + leftPadding: 8 + rightPadding: 8 + height: Theme.controlHeight + Accessible.role: Accessible.StaticText + Accessible.name: formatFull(now) + + Timer { + interval: 1000 + running: true + repeat: true + onTriggered: root.now = new Date() + } + + function formatClock(date) { + const locale = Qt.locale() + const weekday = locale.toString(date, "ddd") + const time = locale.toString(date, locale.timeFormat(Locale.ShortFormat)) + return weekday + " " + time + } + + function formatFull(date) { + return Qt.locale().toString(date, Locale.LongFormat) + } +} diff --git a/shells/desktop/shell/qml/components/Launcher.qml b/shells/desktop/shell/qml/components/Launcher.qml new file mode 100644 index 0000000..f3c0eaf --- /dev/null +++ b/shells/desktop/shell/qml/components/Launcher.qml @@ -0,0 +1,295 @@ +import QtQuick +import Nebula.UI + +Item { + id: root + + property real yOffset: 0 + + implicitWidth: 420 + implicitHeight: 14 + searchField.implicitHeight + 14 + bodyColumn.implicitHeight + 14 + footer.height + 14 + transform: Translate { y: root.yOffset } + transformOrigin: Item.TopLeft + Accessible.role: Accessible.Dialog + Accessible.name: qsTr("Launcher") + + readonly property bool searching: searchField.text.trim().length > 0 + readonly property var filteredApps: { + const query = searchField.text.trim().toLowerCase() + const apps = MockApps.apps + if (!query) + return apps + return apps.filter((app) => { + const haystack = (app.name + " " + (app.comment || "")).toLowerCase() + return haystack.indexOf(query) !== -1 + }) + } + + Keys.onEscapePressed: { + event.accepted = true + ShellState.closeLauncher() + } + + Connections { + target: ShellState + function onLauncherOpenChanged() { + if (ShellState.launcherOpen) { + searchField.text = "" + searchField.input.forceActiveFocus() + } else { + searchField.text = "" + } + } + } + + NebulaCard { + id: card + anchors.fill: parent + + MouseArea { + anchors.fill: parent + acceptedButtons: Qt.LeftButton | Qt.RightButton + } + } + + Column { + id: column + anchors.fill: parent + anchors.margins: 14 + spacing: 0 + + NebulaTextField { + id: searchField + width: parent.width + placeholderText: qsTr("Search applications") + } + + Item { + width: 1 + height: 14 + } + + Flickable { + id: body + width: parent.width + height: Math.max(0, column.height - searchField.height - footer.height - 29) + clip: true + contentWidth: width + contentHeight: bodyColumn.implicitHeight + boundsBehavior: Flickable.StopAtBounds + flickableDirection: Flickable.VerticalFlick + + Column { + id: bodyColumn + width: body.width + spacing: 18 + + Column { + width: parent.width + spacing: 8 + visible: !root.searching && MockApps.recents.length > 0 + + Text { + text: qsTr("Recent") + color: Theme.textSecondary + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSmall + font.weight: Font.DemiBold + font.letterSpacing: 0.4 + } + + Grid { + id: recentGrid + width: parent.width + columns: 4 + columnSpacing: 6 + rowSpacing: 6 + + Repeater { + model: MockApps.recents + delegate: AppItem { + required property var modelData + width: (recentGrid.width - 18) / 4 + variant: "recent" + item: modelData + onSelected: root.launchApp(item) + } + } + } + } + + Column { + width: parent.width + spacing: 8 + + Text { + text: root.searching ? qsTr("Results") : qsTr("Applications") + color: Theme.textSecondary + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSmall + font.weight: Font.DemiBold + font.letterSpacing: 0.4 + } + + Grid { + id: appGrid + visible: root.filteredApps.length > 0 + width: parent.width + columns: 4 + columnSpacing: 6 + rowSpacing: 6 + + Repeater { + model: root.filteredApps + delegate: AppItem { + required property var modelData + width: (appGrid.width - 18) / 4 + variant: "tile" + item: modelData + onSelected: root.launchApp(item) + } + } + } + + Text { + visible: root.filteredApps.length === 0 + width: parent.width + topPadding: 8 + text: qsTr("No matching applications") + color: Theme.textTertiary + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSize + } + } + + Column { + width: parent.width + spacing: 8 + visible: !root.searching && MockApps.projects.length > 0 + + Text { + text: qsTr("Recent projects") + color: Theme.textSecondary + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSmall + font.weight: Font.DemiBold + font.letterSpacing: 0.4 + } + + Column { + width: parent.width + spacing: 2 + + Repeater { + model: MockApps.projects + delegate: AppItem { + required property var modelData + width: parent.width + variant: "row" + item: modelData + onSelected: root.openProject(item) + } + } + } + } + } + } + + Item { + width: 1 + height: 14 + } + + Rectangle { + width: parent.width + height: 1 + color: Theme.border + } + + Item { + id: footer + width: parent.width + height: 38 + + Row { + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + spacing: 10 + + Rectangle { + width: 28 + height: 28 + radius: 14 + color: Theme.accentSoft + anchors.verticalCenter: parent.verticalCenter + + Text { + anchors.centerIn: parent + text: MockApps.user.initials + color: Theme.accentStrong + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeApp + font.weight: Font.DemiBold + } + } + + Column { + anchors.verticalCenter: parent.verticalCenter + spacing: 0 + + Text { + text: MockApps.user.name + color: Theme.textPrimary + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSize + font.weight: Font.DemiBold + } + + Text { + text: qsTr("Local session") + color: Theme.textTertiary + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSmall + } + } + } + + Row { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + spacing: 2 + + NebulaIconButton { + id: restartButton + label: qsTr("Restart") + onClicked: console.info("nebula: restart requested") + NebulaIcon { + name: "restart" + size: 16 + color: restartButton.iconColor + } + } + + NebulaIconButton { + id: shutdownButton + label: qsTr("Shut down") + danger: true + onClicked: console.info("nebula: shutdown requested") + NebulaIcon { + name: "power" + size: 16 + color: shutdownButton.iconColor + } + } + } + } + } + + function launchApp(app) { + ShellState.launch(app.name) + } + + function openProject(project) { + ShellState.launch(project.name) + } +} diff --git a/shells/desktop/shell/qml/components/NebulaButton.qml b/shells/desktop/shell/qml/components/NebulaButton.qml new file mode 100644 index 0000000..1b336be --- /dev/null +++ b/shells/desktop/shell/qml/components/NebulaButton.qml @@ -0,0 +1,64 @@ +import QtQuick +import Nebula.UI + +Item { + id: root + + implicitWidth: row.implicitWidth + 18 + implicitHeight: Theme.controlHeight + property bool checked: false + + signal clicked() + + Accessible.role: Accessible.Button + Accessible.name: qsTr("Nebula") + Accessible.checkable: true + Accessible.checked: root.checked + Accessible.onPressAction: root.clicked() + + Rectangle { + anchors.fill: parent + radius: Theme.radiusSmall + color: (hover.containsMouse || root.checked) ? Theme.accentSoft : "transparent" + + Behavior on color { + ColorAnimation { + duration: Theme.animationFast + } + } + } + + Row { + id: row + anchors.centerIn: parent + spacing: 8 + + NebulaIcon { + name: "nebula" + size: 18 + color: root.checked ? Theme.accentStrong : Theme.textPrimary + anchors.verticalCenter: parent.verticalCenter + } + + Text { + text: qsTr("Nebula") + color: root.checked ? Theme.accentStrong : Theme.textPrimary + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSize + font.weight: Font.DemiBold + anchors.verticalCenter: parent.verticalCenter + } + } + + MouseArea { + id: hover + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: root.clicked() + } + + FocusFrame { + radius: Theme.radiusSmall + } +} diff --git a/shells/desktop/shell/qml/components/SystemArea.qml b/shells/desktop/shell/qml/components/SystemArea.qml new file mode 100644 index 0000000..4e0b882 --- /dev/null +++ b/shells/desktop/shell/qml/components/SystemArea.qml @@ -0,0 +1,47 @@ +import QtQuick +import Nebula.UI + +Row { + id: root + + spacing: 2 + + // Placeholder system status until native services exist. + readonly property int wifiStrength: 3 + readonly property int volumePercent: 72 + readonly property int batteryPercent: 84 + + NebulaIconButton { + id: networkButton + label: qsTr("Network connected, Nebula-Net") + NebulaIcon { + name: "wifi" + size: 16 + color: networkButton.iconColor + value: root.wifiStrength + } + } + + NebulaIconButton { + id: volumeButton + label: qsTr("Volume %1%").arg(root.volumePercent) + NebulaIcon { + name: "speaker" + size: 16 + color: volumeButton.iconColor + } + } + + NebulaIconButton { + id: powerButton + label: qsTr("Battery %1%").arg(root.batteryPercent) + NebulaIcon { + name: "battery" + size: 16 + color: powerButton.iconColor + value: root.batteryPercent + } + } + + Clock {} +} diff --git a/shells/desktop/shell/qml/components/TopBar.qml b/shells/desktop/shell/qml/components/TopBar.qml new file mode 100644 index 0000000..904e93c --- /dev/null +++ b/shells/desktop/shell/qml/components/TopBar.qml @@ -0,0 +1,58 @@ +import QtQuick +import Nebula.UI + +Item { + id: root + + implicitHeight: Theme.topBarHeight + + Rectangle { + anchors.fill: parent + color: Theme.topBarBackground + + Rectangle { + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + height: 1 + color: Theme.border + } + } + + Row { + id: left + anchors.left: parent.left + anchors.leftMargin: 6 + anchors.verticalCenter: parent.verticalCenter + spacing: 8 + + NebulaButton { + checked: ShellState.launcherOpen + onClicked: ShellState.toggleLauncher() + } + + Rectangle { + anchors.verticalCenter: parent.verticalCenter + width: 1 + height: 14 + color: Theme.borderStrong + } + + Text { + anchors.verticalCenter: parent.verticalCenter + width: Math.min(implicitWidth, root.width * 0.4) + text: ShellState.activeApplication + color: Theme.textSecondary + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSize + elide: Text.ElideRight + maximumLineCount: 1 + } + } + + SystemArea { + anchors.right: parent.right + anchors.rightMargin: 10 + anchors.verticalCenter: parent.verticalCenter + } +} diff --git a/shells/desktop/shell/qml/mock/MockApps.qml b/shells/desktop/shell/qml/mock/MockApps.qml new file mode 100644 index 0000000..e69c8ad --- /dev/null +++ b/shells/desktop/shell/qml/mock/MockApps.qml @@ -0,0 +1,100 @@ +pragma Singleton + +import QtQuick + +QtObject { + readonly property var apps: [ + { + id: "files", + name: "Files", + comment: "Browse files and folders", + recent: true, + glyph: "folder", + tint: "#c4a574" + }, + { + id: "firefox", + name: "Firefox", + comment: "Web browser", + recent: true, + glyph: "globe", + tint: "#d08a5a" + }, + { + id: "terminal", + name: "Terminal", + comment: "Command line", + recent: true, + glyph: "terminal", + tint: "#7eaa8e" + }, + { + id: "settings", + name: "Settings", + comment: "System preferences", + recent: false, + glyph: "settings", + tint: "#8e99ab" + }, + { + id: "steam", + name: "Steam", + comment: "Games", + recent: true, + glyph: "game", + tint: "#6f8fb8" + }, + { + id: "photos", + name: "Photos", + comment: "Pictures and albums", + recent: false, + glyph: "photo", + tint: "#8aa8c4" + }, + { + id: "music", + name: "Music", + comment: "Listen and organise", + recent: false, + glyph: "music", + tint: "#b48a9a" + }, + { + id: "text-editor", + name: "Text Editor", + comment: "Edit documents", + recent: false, + glyph: "document", + tint: "#9aa7bc" + } + ] + + readonly property var recents: apps.filter((app) => app.recent) + + readonly property var projects: [ + { + id: "nebulaos", + name: "NebulaOS", + subtitle: "Repository", + kind: "folder" + }, + { + id: "website", + name: "Website", + subtitle: "Project", + kind: "folder" + }, + { + id: "session-notes", + name: "Session notes", + subtitle: "Document", + kind: "document" + } + ] + + readonly property var user: ({ + name: "User", + initials: "N" + }) +} diff --git a/shells/desktop/shell/qml/surfaces/DesktopSurface.qml b/shells/desktop/shell/qml/surfaces/DesktopSurface.qml new file mode 100644 index 0000000..233fa98 --- /dev/null +++ b/shells/desktop/shell/qml/surfaces/DesktopSurface.qml @@ -0,0 +1,64 @@ +import QtQuick +import QtQuick.Window +import org.kde.layershell 1.0 as LayerShell +import Nebula.UI + +Window { + id: root + + title: qsTr("Nebula Desktop") + color: "transparent" + flags: Qt.FramelessWindowHint | Qt.WindowDoesNotAcceptFocus + visible: true + width: Screen.width + height: Screen.height + + LayerShell.Window.scope: "nebula-desktop" + LayerShell.Window.layer: LayerShell.Window.LayerBackground + LayerShell.Window.anchors: LayerShell.Window.AnchorTop + | LayerShell.Window.AnchorBottom + | LayerShell.Window.AnchorLeft + | LayerShell.Window.AnchorRight + LayerShell.Window.exclusionZone: 0 + LayerShell.Window.keyboardInteractivity: LayerShell.Window.KeyboardInteractivityNone + + Canvas { + id: backdrop + anchors.fill: parent + onPaint: { + const ctx = getContext("2d") + ctx.reset() + ctx.fillStyle = Theme.background + ctx.fillRect(0, 0, width, height) + + let glow = ctx.createRadialGradient(width * 0.12, height * -0.08, 0, width * 0.12, height * -0.08, width * 0.7) + glow.addColorStop(0, "rgba(110, 140, 180, 0.16)") + glow.addColorStop(0.52, "rgba(110, 140, 180, 0)") + ctx.fillStyle = glow + ctx.fillRect(0, 0, width, height) + + glow = ctx.createRadialGradient(width * 0.88, height * 1.08, 0, width * 0.88, height * 1.08, width * 0.45) + glow.addColorStop(0, "rgba(42, 68, 98, 0.28)") + glow.addColorStop(0.58, "rgba(42, 68, 98, 0)") + ctx.fillStyle = glow + ctx.fillRect(0, 0, width, height) + + glow = ctx.createRadialGradient(width * 0.62, height * 0.38, 0, width * 0.62, height * 0.38, width * 0.28) + glow.addColorStop(0, "rgba(72, 92, 120, 0.08)") + glow.addColorStop(0.7, "rgba(72, 92, 120, 0)") + ctx.fillStyle = glow + ctx.fillRect(0, 0, width, height) + + const veil = ctx.createLinearGradient(0, 0, 0, height) + veil.addColorStop(0, "rgba(8, 11, 17, 0.22)") + veil.addColorStop(0.16, "rgba(8, 11, 17, 0)") + veil.addColorStop(0.78, "rgba(8, 11, 17, 0)") + veil.addColorStop(1, "rgba(8, 11, 17, 0.38)") + ctx.fillStyle = veil + ctx.fillRect(0, 0, width, height) + } + + onWidthChanged: requestPaint() + onHeightChanged: requestPaint() + } +} diff --git a/shells/desktop/shell/qml/surfaces/LauncherSurface.qml b/shells/desktop/shell/qml/surfaces/LauncherSurface.qml new file mode 100644 index 0000000..a0a756f --- /dev/null +++ b/shells/desktop/shell/qml/surfaces/LauncherSurface.qml @@ -0,0 +1,75 @@ +import QtQuick +import QtQuick.Window +import org.kde.layershell 1.0 as LayerShell +import Nebula.UI + +Window { + id: root + + title: qsTr("Nebula Launcher") + color: "transparent" + flags: Qt.FramelessWindowHint + visible: ShellState.launcherOpen || launcher.opacity > 0.01 + width: Screen.width + height: Screen.height - Theme.topBarHeight + + LayerShell.Window.scope: "nebula-launcher" + LayerShell.Window.layer: LayerShell.Window.LayerOverlay + LayerShell.Window.anchors: LayerShell.Window.AnchorTop + | LayerShell.Window.AnchorBottom + | LayerShell.Window.AnchorLeft + | LayerShell.Window.AnchorRight + LayerShell.Window.margins.top: Theme.topBarHeight + LayerShell.Window.exclusionZone: 0 + LayerShell.Window.keyboardInteractivity: ShellState.launcherOpen + ? LayerShell.Window.KeyboardInteractivityExclusive + : LayerShell.Window.KeyboardInteractivityNone + + Item { + anchors.fill: parent + focus: ShellState.launcherOpen + Keys.onEscapePressed: { + event.accepted = true + ShellState.closeLauncher() + } + } + + MouseArea { + anchors.fill: parent + acceptedButtons: Qt.LeftButton | Qt.RightButton + onClicked: ShellState.closeLauncher() + } + + Launcher { + id: launcher + x: 10 + y: 10 + width: Math.min(420, parent.width - 20) + height: Math.min(implicitHeight, Math.min(640, parent.height - 24)) + opacity: ShellState.launcherOpen ? 1 : 0 + yOffset: ShellState.launcherOpen ? 0 : -6 + scale: ShellState.launcherOpen ? 1 : 0.985 + focus: ShellState.launcherOpen + + Behavior on opacity { + NumberAnimation { + duration: Theme.animationNormal + easing.type: Easing.OutCubic + } + } + + Behavior on yOffset { + NumberAnimation { + duration: Theme.animationNormal + easing.type: Easing.OutCubic + } + } + + Behavior on scale { + NumberAnimation { + duration: Theme.animationNormal + easing.type: Easing.OutCubic + } + } + } +} diff --git a/shells/desktop/shell/qml/surfaces/TopBarSurface.qml b/shells/desktop/shell/qml/surfaces/TopBarSurface.qml new file mode 100644 index 0000000..8187f8f --- /dev/null +++ b/shells/desktop/shell/qml/surfaces/TopBarSurface.qml @@ -0,0 +1,27 @@ +import QtQuick +import QtQuick.Window +import org.kde.layershell 1.0 as LayerShell +import Nebula.UI + +Window { + id: root + + title: qsTr("Nebula Top Bar") + color: "transparent" + flags: Qt.FramelessWindowHint | Qt.WindowDoesNotAcceptFocus + visible: true + width: Screen.width + height: Theme.topBarHeight + + LayerShell.Window.scope: "nebula-topbar" + LayerShell.Window.layer: LayerShell.Window.LayerTop + LayerShell.Window.anchors: LayerShell.Window.AnchorTop + | LayerShell.Window.AnchorLeft + | LayerShell.Window.AnchorRight + LayerShell.Window.exclusionZone: Theme.topBarHeight + LayerShell.Window.keyboardInteractivity: LayerShell.Window.KeyboardInteractivityNone + + TopBar { + anchors.fill: parent + } +} diff --git a/shells/desktop/shell/src/main.cpp b/shells/desktop/shell/src/main.cpp new file mode 100644 index 0000000..1525973 --- /dev/null +++ b/shells/desktop/shell/src/main.cpp @@ -0,0 +1,46 @@ +#include + +#include +#include +#include +#include +#include + +int main(int argc, char *argv[]) +{ + LayerShellQt::Shell::useLayerShell(); + + QGuiApplication app(argc, argv); + app.setApplicationName(QStringLiteral("nebula-shell")); + app.setApplicationDisplayName(QStringLiteral("Nebula Desktop")); + app.setOrganizationName(QStringLiteral("NebulaOS")); + app.setDesktopFileName(QStringLiteral("org.nebulaos.shell")); + + QQuickWindow::setDefaultAlphaBuffer(true); + + QQmlApplicationEngine engine; + engine.addImportPath(QStringLiteral("qrc:/qt/qml")); + + QObject::connect( + &engine, + &QQmlApplicationEngine::objectCreationFailed, + &app, + []() { + qCritical("nebula-shell: failed to load the Nebula QML module."); + QCoreApplication::exit(EXIT_FAILURE); + }, + Qt::QueuedConnection); + +#if QT_VERSION >= QT_VERSION_CHECK(6, 5, 0) + engine.loadFromModule(QStringLiteral("Nebula.Shell"), QStringLiteral("Main")); +#else + engine.load(QUrl(QStringLiteral("qrc:/qt/qml/Nebula/Shell/Main.qml"))); +#endif + + if (engine.rootObjects().isEmpty()) { + qCritical("nebula-shell: failed to load the Nebula QML module."); + return EXIT_FAILURE; + } + + return app.exec(); +} diff --git a/shells/desktop/ui/README.md b/shells/desktop/ui/README.md deleted file mode 100644 index 176a033..0000000 --- a/shells/desktop/ui/README.md +++ /dev/null @@ -1,30 +0,0 @@ -# Nebula Desktop UI - -React UI for Nebula Desktop shell chrome. It does not host application or game windows. Those render as Wayland / XWayland surfaces. The native host in `shells/desktop/native` will load this UI in WebKitGTK. - -## Surfaces - -The same codebase can render each Wayland layer on its own: - -| URL | Renders | -|-----|---------| -| `/?surface=preview` (default) | Combined browser mockup | -| `/?surface=desktop` | Background layer only | -| `/?surface=topbar` | Top bar only | -| `/?surface=launcher` | Launcher only | - -The native host chooses the origin. Surface selection is a relative query parameter, not a hardcoded port. - -## Develop - -```powershell -npm install -npm run dev -``` - -```powershell -npm run lint -npm run build -``` - -Oxlint is the linter for this project.