feat: initial noctalia-greeter release
QuickShell QML login greeter for greetd, styled after noctalia-shell lockscreen. Runs under niri compositor as _greeter user. Theme is live-synced from noctalia config via runit service. - shell.qml: QuickShell greeter UI + greetd auth flow - sync/: inotifywait theme sync daemon + runit service - greetd/: niri compositor config + wrapper scripts - install.sh: deployment helper - test/check-syntax.sh: 19 syntax/structural checks (0 failures) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
383
shell.qml
Normal file
383
shell.qml
Normal file
@@ -0,0 +1,383 @@
|
||||
pragma Singleton
|
||||
import Quickshell
|
||||
import Quickshell.Services.Greetd
|
||||
import Quickshell.Io
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
import QtQuick.Effects
|
||||
|
||||
ShellRoot {
|
||||
id: root
|
||||
|
||||
// ── Theme (synced by noctalia-greeter-sync service) ──────────────────────
|
||||
|
||||
FileView {
|
||||
id: colorsFile
|
||||
path: "/var/lib/noctalia-greeter-theme/colors.json"
|
||||
blockLoading: true
|
||||
}
|
||||
|
||||
readonly property var nc: {
|
||||
try {
|
||||
return JSON.parse(colorsFile.text())
|
||||
} catch (e) {
|
||||
return {
|
||||
mPrimary: "#76daa2", mOnPrimary: "#003920",
|
||||
mSurface: "#111412", mOnSurface: "#e1e3de",
|
||||
mSurfaceVariant: "#1d201e", mOnSurfaceVariant: "#c0c9c0",
|
||||
mOutline: "#404942", mShadow: "#000000",
|
||||
mError: "#ffb4ab", mOnError: "#690005",
|
||||
mTertiary: "#a3cddc", mOnTertiary: "#043541"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
readonly property string wallpaperPath: "/var/lib/noctalia-greeter-theme/wallpaper.jpg"
|
||||
|
||||
// ── Auth state ────────────────────────────────────────────────────────────
|
||||
|
||||
property string password: ""
|
||||
property string statusMsg: ""
|
||||
property bool statusErr: false
|
||||
property bool waitingForPw: false
|
||||
property bool launching: false
|
||||
|
||||
Component.onCompleted: {
|
||||
Greetd.createSession("moze")
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: Greetd
|
||||
|
||||
function onAuthMessage(message, isError, responseRequired, echoResponse) {
|
||||
if (responseRequired) {
|
||||
root.waitingForPw = true
|
||||
if (!root.statusErr)
|
||||
root.statusMsg = ""
|
||||
} else {
|
||||
root.statusMsg = message || ""
|
||||
root.statusErr = false
|
||||
}
|
||||
}
|
||||
|
||||
function onReadyToLaunch() {
|
||||
root.launching = true
|
||||
root.statusMsg = "Welcome back!"
|
||||
root.statusErr = false
|
||||
Greetd.launch(
|
||||
["/usr/sbin/dbus-run-session", "/usr/sbin/niri", "--session"],
|
||||
[],
|
||||
true
|
||||
)
|
||||
}
|
||||
|
||||
function onAuthFailure(message) {
|
||||
root.statusMsg = message || "Authentication failed"
|
||||
root.statusErr = true
|
||||
root.password = ""
|
||||
root.waitingForPw = false
|
||||
restartTimer.start()
|
||||
}
|
||||
|
||||
function onError(error) {
|
||||
root.statusMsg = "Session error: " + error
|
||||
root.statusErr = true
|
||||
restartTimer.start()
|
||||
}
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: restartTimer
|
||||
interval: 2000
|
||||
repeat: false
|
||||
onTriggered: {
|
||||
root.statusMsg = ""
|
||||
root.statusErr = false
|
||||
Greetd.createSession("moze")
|
||||
}
|
||||
}
|
||||
|
||||
function submitPassword() {
|
||||
if (!root.waitingForPw || root.password.length === 0) return
|
||||
Greetd.respond(root.password)
|
||||
root.password = ""
|
||||
root.waitingForPw = false
|
||||
root.statusMsg = "Verifying…"
|
||||
root.statusErr = false
|
||||
}
|
||||
|
||||
// ── Clocks ────────────────────────────────────────────────────────────────
|
||||
|
||||
property date clockNow: new Date()
|
||||
property date clockDate: new Date()
|
||||
|
||||
Timer { interval: 1000; running: true; repeat: true; onTriggered: root.clockNow = new Date() }
|
||||
Timer { interval: 60000; running: true; repeat: true; onTriggered: root.clockDate = new Date() }
|
||||
|
||||
// ── Greeter window ────────────────────────────────────────────────────────
|
||||
|
||||
Variants {
|
||||
model: Quickshell.screens
|
||||
|
||||
PanelWindow {
|
||||
required property var modelData
|
||||
screen: modelData
|
||||
exclusiveZone: -1
|
||||
anchors { top: true; bottom: true; left: true; right: true }
|
||||
color: "transparent"
|
||||
keyboardFocus: KeyboardFocus.Exclusive
|
||||
|
||||
// ── Background ────────────────────────────────────────────────────
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
color: "#000000"
|
||||
}
|
||||
|
||||
Image {
|
||||
anchors.fill: parent
|
||||
source: root.wallpaperPath
|
||||
fillMode: Image.PreserveAspectCrop
|
||||
smooth: true
|
||||
mipmap: true
|
||||
}
|
||||
|
||||
// Gradient overlay (matches noctalia LockScreenBackground)
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
gradient: Gradient {
|
||||
GradientStop { position: 0.0; color: Qt.alpha(root.nc.mShadow, 0.45) }
|
||||
GradientStop { position: 0.3; color: Qt.alpha(root.nc.mShadow, 0.22) }
|
||||
GradientStop { position: 0.7; color: Qt.alpha(root.nc.mShadow, 0.27) }
|
||||
GradientStop { position: 1.0; color: Qt.alpha(root.nc.mShadow, 0.55) }
|
||||
}
|
||||
}
|
||||
|
||||
// ── Header (mirrors LockScreenHeader) ────────────────────────────
|
||||
|
||||
Rectangle {
|
||||
id: header
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
anchors.top: parent.top
|
||||
anchors.topMargin: 100
|
||||
width: Math.max(520, headerRow.implicitWidth + 40)
|
||||
height: headerRow.implicitHeight + 32
|
||||
radius: 14
|
||||
color: Qt.alpha(root.nc.mSurface, 0.88)
|
||||
border.color: Qt.alpha(root.nc.mOutline, 0.22)
|
||||
border.width: 1
|
||||
|
||||
RowLayout {
|
||||
id: headerRow
|
||||
anchors.fill: parent
|
||||
anchors.margins: 18
|
||||
spacing: 22
|
||||
|
||||
// Avatar
|
||||
Rectangle {
|
||||
Layout.preferredWidth: 70
|
||||
Layout.preferredHeight: 70
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
radius: width / 2
|
||||
color: "transparent"
|
||||
border.color: Qt.alpha(root.nc.mPrimary, 0.85)
|
||||
border.width: 2
|
||||
|
||||
// Fallback person icon
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
text: ""
|
||||
font.pixelSize: 32
|
||||
font.family: "Symbols Nerd Font"
|
||||
color: root.nc.mOnSurfaceVariant
|
||||
}
|
||||
}
|
||||
|
||||
// Welcome + date
|
||||
ColumnLayout {
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
spacing: 4
|
||||
|
||||
Text {
|
||||
text: "Welcome back, Moze!"
|
||||
font.pixelSize: 20
|
||||
font.weight: Font.Medium
|
||||
color: root.nc.mOnSurface
|
||||
}
|
||||
|
||||
Text {
|
||||
text: {
|
||||
const s = Qt.locale().toString(root.clockDate, "dddd, MMMM d")
|
||||
return s.charAt(0).toUpperCase() + s.slice(1)
|
||||
}
|
||||
font.pixelSize: 15
|
||||
color: root.nc.mOnSurfaceVariant
|
||||
}
|
||||
}
|
||||
|
||||
Item { Layout.fillWidth: true }
|
||||
|
||||
// Stacked clock (hh / mm, matches clockStyle: "custom")
|
||||
ColumnLayout {
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
spacing: -6
|
||||
|
||||
Repeater {
|
||||
model: Qt.locale().toString(root.clockNow, "hh\nmm").split("\n")
|
||||
Text {
|
||||
required property string modelData
|
||||
text: modelData
|
||||
font.pixelSize: 32
|
||||
font.weight: Font.Bold
|
||||
color: root.nc.mOnSurface
|
||||
Layout.alignment: Qt.AlignHCenter
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Password panel ────────────────────────────────────────────────
|
||||
|
||||
Column {
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
anchors.top: header.bottom
|
||||
anchors.topMargin: parent.height * 0.10
|
||||
spacing: 10
|
||||
|
||||
Rectangle {
|
||||
width: 380
|
||||
height: 64
|
||||
radius: 14
|
||||
color: Qt.alpha(root.nc.mSurfaceVariant, 0.92)
|
||||
border.color: {
|
||||
if (root.statusErr) return Qt.alpha(root.nc.mError, 0.75)
|
||||
if (root.waitingForPw) return Qt.alpha(root.nc.mPrimary, 0.85)
|
||||
return Qt.alpha(root.nc.mOutline, 0.35)
|
||||
}
|
||||
border.width: 1.5
|
||||
Behavior on border.color { ColorAnimation { duration: 180 } }
|
||||
|
||||
RowLayout {
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: 18
|
||||
anchors.rightMargin: 18
|
||||
spacing: 12
|
||||
|
||||
Text {
|
||||
text: root.statusErr ? "" : (root.waitingForPw ? "" : "")
|
||||
font.pixelSize: 18
|
||||
font.family: "Symbols Nerd Font"
|
||||
color: root.statusErr
|
||||
? root.nc.mError
|
||||
: root.nc.mOnSurfaceVariant
|
||||
}
|
||||
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
text: {
|
||||
if (root.launching) return "Logging in…"
|
||||
if (root.password.length) return "●".repeat(root.password.length)
|
||||
if (root.waitingForPw) return "Enter password…"
|
||||
return "Please wait…"
|
||||
}
|
||||
font.pixelSize: root.password.length ? 22 : 15
|
||||
letterSpacing: root.password.length ? 6 : 0
|
||||
color: root.password.length
|
||||
? root.nc.mOnSurface
|
||||
: root.nc.mOnSurfaceVariant
|
||||
elide: Text.ElideRight
|
||||
Behavior on font.pixelSize { NumberAnimation { duration: 80 } }
|
||||
}
|
||||
|
||||
// Blinking cursor when idle waiting for input
|
||||
Rectangle {
|
||||
width: 2; height: 28
|
||||
color: root.nc.mPrimary
|
||||
visible: root.waitingForPw && root.password.length === 0
|
||||
property real op: 1.0
|
||||
opacity: op
|
||||
SequentialAnimation on op {
|
||||
running: root.waitingForPw && root.password.length === 0
|
||||
loops: Animation.Infinite
|
||||
NumberAnimation { to: 0; duration: 500; easing.type: Easing.InOutQuad }
|
||||
NumberAnimation { to: 1; duration: 500; easing.type: Easing.InOutQuad }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Status / error pill
|
||||
Rectangle {
|
||||
width: 380
|
||||
height: 38
|
||||
radius: 10
|
||||
visible: root.statusMsg !== ""
|
||||
opacity: visible ? 1.0 : 0.0
|
||||
Behavior on opacity { NumberAnimation { duration: 200 } }
|
||||
color: root.statusErr
|
||||
? Qt.alpha(root.nc.mError, 0.13)
|
||||
: Qt.alpha(root.nc.mTertiary, 0.13)
|
||||
border.color: root.statusErr
|
||||
? Qt.alpha(root.nc.mError, 0.4)
|
||||
: Qt.alpha(root.nc.mTertiary, 0.35)
|
||||
border.width: 1
|
||||
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
width: parent.width - 24
|
||||
text: root.statusMsg
|
||||
font.pixelSize: 13
|
||||
color: root.statusErr ? root.nc.mError : root.nc.mOnSurface
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Keyboard capture (invisible, full-screen) ─────────────────────
|
||||
|
||||
TextInput {
|
||||
id: pwInput
|
||||
anchors.fill: parent
|
||||
opacity: 0
|
||||
echoMode: TextInput.Password
|
||||
passwordMaskDelay: 0
|
||||
enabled: !root.launching
|
||||
|
||||
onTextChanged: {
|
||||
if (root.password !== text)
|
||||
root.password = text
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: root
|
||||
function onPasswordChanged() {
|
||||
if (pwInput.text !== root.password)
|
||||
pwInput.text = root.password
|
||||
}
|
||||
}
|
||||
|
||||
Keys.onPressed: function(event) {
|
||||
if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) {
|
||||
root.submitPassword()
|
||||
event.accepted = true
|
||||
} else if (event.key === Qt.Key_Escape) {
|
||||
root.password = ""
|
||||
event.accepted = true
|
||||
}
|
||||
}
|
||||
|
||||
Component.onCompleted: forceActiveFocus()
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
acceptedButtons: Qt.AllButtons
|
||||
onClicked: pwInput.forceActiveFocus()
|
||||
z: -1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user