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,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
}
}
@@ -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)
}
}
@@ -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)
}
}
@@ -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
}
}
@@ -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 {}
}
@@ -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
}
}