Add Qt/QML desktop shell and Nebula.UI

Introduce a new production desktop shell at `shells/desktop/shell` built with C++20, Qt Quick/QML, and LayerShellQt, including top bar, launcher, desktop surfaces, shell state, and mock app data. Add the shared `packages/nebula-ui` QML module (theme, controls, focus, icons) and wire it into the shell build.

Retire the old GTK/WebKit native host by removing `shells/desktop/native`, move the React shell UI to `shells/desktop/prototype-react` as an archived design reference, and update root/compositor/session documentation to reflect the new Linux-only production architecture and temporary Sway role.
This commit is contained in:
2026-08-26 16:34:40 +12:00
parent 5504d3cfb8
commit befed8ff96
80 changed files with 2128 additions and 609 deletions
@@ -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
}
}
@@ -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
}
}
@@ -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
}
}
@@ -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()
}
}
@@ -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
}
}
@@ -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
}
}
@@ -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"
}