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>
384 lines
14 KiB
QML
384 lines
14 KiB
QML
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
|
|
}
|
|
}
|
|
}
|
|
}
|