Files
dotfiles/quickshell/lock/LockFace.qml
T
nvrl e9735d1129 feat(quickshell): "Take your time." glows and flickers
A new GlowText component lights a line from inside: a soft two-layer
halo that breathes slowly, and every 4-14 seconds a short, uneven
stutter like a tube catching. Only opacity and the halo move, so the
glyphs stay on the pixel grid. The lock screen and the power menu use
it; the power menu's stops while it is closed.
2026-10-04 23:12:50 +02:00

466 lines
17 KiB
QML

import QtQuick
import QtQuick.Effects
import Quickshell
import Quickshell.Services.Pam
import "root:/config"
import "root:/components"
import "root:/services"
import "root:/lock"
// The lock screen's whole face, kept separate from WlSessionLockSurface so it
// can be instantiated in an ordinary window for development without actually
// locking the session.
//
// Quiet on purpose. The wallpaper stays almost whole — the porthole, the
// planet, the girl looking out — and the face is one small group of type set
// in the dark of the porthole: the mark, the time, the date, a short status
// log, and a rounded field to type into. Nothing sweeps, splits or shakes;
// the group resolves in, and a wrong password nudges it once and turns its
// dots to rust.
Item {
id: surface
// Emitted once PAM has accepted the password.
signal unlocked()
property string entry: ""
property string status: ""
property bool failed: false
property bool busy: pam.active
readonly property real uiScale: Math.max(0.72, Math.min(1.3,
Math.min(width / 1920, height / 1080)))
Rectangle {
anchors.fill: parent
color: Theme.ink
}
// ------------------------------------------------------- background
// Whatever hyprpaper is actually displaying, not a path written down once
// and left to rot. `screenName` lets a multi-monitor lock show each screen
// its own picture; it falls back to the first wallpaper hyprpaper reports.
property string screenName: ""
Image {
id: wall
anchors.fill: parent
source: surface.screenName !== ""
? Wallpaper.forMonitor(surface.screenName)
: Wallpaper.source
fillMode: Image.PreserveAspectCrop
asynchronous: false
cache: true
opacity: 0
// Barely softened: enough that the scene steps back behind the type,
// not so much that it stops being a place.
layer.enabled: true
layer.effect: MultiEffect {
blurEnabled: true
blur: 0.2
blurMax: 32
saturation: -0.1
brightness: -0.18
}
}
// Shade deepening toward the left, where the type sits in the porthole's
// dark, and a breath of it along the foot.
Rectangle {
anchors.fill: parent
gradient: Gradient {
orientation: Gradient.Horizontal
GradientStop { position: 0.0; color: Theme.alpha(Theme.ink, 0.72) }
GradientStop { position: 0.45; color: Theme.alpha(Theme.ink, 0.28) }
GradientStop { position: 1.0; color: Theme.alpha(Theme.ink, 0.18) }
}
}
Rectangle {
anchors.fill: parent
gradient: Gradient {
GradientStop { position: 0.6; color: "transparent" }
GradientStop { position: 1.0; color: Theme.alpha(Theme.ink, 0.5) }
}
}
// ------------------------------------------------------------- face
SystemClock {
id: clock
precision: SystemClock.Minutes
}
Column {
id: face
// Sized by uiScale rather than drawn small and stretched with `scale`:
// a scale transform blurs native text, and every label here is native.
// Whole pixels, too, or the glyphs land between them.
x: Math.round(parent.width * 0.14)
y: Math.round(parent.height * 0.2)
width: Math.round(380 * surface.uiScale)
opacity: 0
spacing: 0
transform: Translate { id: drift }
// The house mark, the same porthole the bar carries far left.
Porthole {
size: Math.round(26 * surface.uiScale)
}
Text {
topPadding: Math.round(Theme.padM * surface.uiScale)
text: Qt.formatDateTime(clock.date, "HH:mm")
color: Theme.accent
font.family: Theme.fontDisplay
font.pixelSize: Math.round(Theme.fsMega * 1.35 * surface.uiScale)
font.weight: Font.Light
font.letterSpacing: 2 * surface.uiScale
// Display size, where curves beat hinted bitmaps.
renderType: Text.CurveRendering
}
Text {
leftPadding: Math.round(4 * surface.uiScale)
text: Qt.formatDateTime(clock.date, "dddd · dd MMMM").toUpperCase()
color: Theme.subtext
font.family: Theme.fontDisplay
font.pixelSize: Math.round(Theme.fsBody * surface.uiScale)
font.weight: Theme.weightDisplay
font.letterSpacing: 5 * surface.uiScale
renderType: Text.NativeRendering
}
// The one line the face says to you. Nothing here is in a hurry.
Item { width: 1; height: Math.round(Theme.padL * surface.uiScale) }
GlowText {
x: Math.round(4 * surface.uiScale)
text: "Take your time."
color: Theme.subtext
glowColor: Theme.accent
pixelSize: Math.round(Theme.fsLarge * surface.uiScale)
}
Item { width: 1; height: Math.round(Theme.padXL * surface.uiScale) }
// ------------------------------------------------------ status log
Column {
width: parent.width
leftPadding: Math.round(4 * surface.uiScale)
StatusRow {
width: parent.width - parent.leftPadding
uiScale: surface.uiScale
visible: Battery.available
label: "Battery"
detail: Math.round(Battery.percent) + "%"
+ (Battery.timeLabel ? " " + Battery.timeLabel : "")
tone: Battery.tint
}
StatusRow {
width: parent.width - parent.leftPadding
uiScale: surface.uiScale
label: "Network"
detail: Sys.netUp ? Net.label : "no link"
tone: Sys.netUp ? Theme.accentSoft : Theme.overlay
}
StatusRow {
width: parent.width - parent.leftPadding
uiScale: surface.uiScale
visible: Media.hasPlayer
label: Media.playing ? "Playing" : "Paused"
detail: Media.artist ? Media.title + " — " + Media.artist : Media.title
tone: Media.playing ? Theme.accent : Theme.primaryDim
}
StatusRow {
width: parent.width - parent.leftPadding
uiScale: surface.uiScale
visible: Notifs.count > 0
label: "Notices"
detail: Notifs.count + (Notifs.count === 1 ? " message" : " messages")
tone: Theme.accentSoft
}
}
Item { width: 1; height: Math.round(Theme.padXL * surface.uiScale) }
// ------------------------------------------------------ the field
Item {
id: authBlock
width: parent.width
height: field.height + hint.height + hint.anchors.topMargin
transform: Translate { id: nudge }
Rectangle {
id: field
width: parent.width
height: Math.round(48 * surface.uiScale)
radius: height / 2
color: Theme.alpha(Theme.hull, 0.86)
border.width: 1
border.color: surface.failed
? Theme.alarm
: (input.activeFocus && surface.entry.length > 0 ? Theme.outline : Theme.rim)
Behavior on border.color {
ColorAnimation { duration: Theme.durBase }
}
Icon {
id: lockGlyph
anchors.left: parent.left
anchors.leftMargin: Math.round(Theme.padL * surface.uiScale)
anchors.verticalCenter: parent.verticalCenter
text: "󰌾"
color: surface.failed ? Theme.alarm : Theme.muted
font.pixelSize: Math.round(Theme.fsLarge * surface.uiScale)
}
Text {
anchors.left: lockGlyph.right
anchors.leftMargin: Math.round(Theme.padM * surface.uiScale)
anchors.verticalCenter: parent.verticalCenter
visible: surface.entry.length === 0
text: "PASSWORD"
color: Theme.muted
font.family: Theme.fontDisplay
font.pixelSize: Math.round(Theme.fsSmall * surface.uiScale)
font.letterSpacing: Theme.tracking * 2 * surface.uiScale
renderType: Text.NativeRendering
}
// One dot per character, each resolving in as it is typed.
// They breathe while PAM is checking.
Row {
id: dots
anchors.left: lockGlyph.right
anchors.leftMargin: Math.round(Theme.padM * surface.uiScale)
anchors.right: enter.left
anchors.rightMargin: Math.round(Theme.padM * surface.uiScale)
anchors.verticalCenter: parent.verticalCenter
spacing: Math.round(7 * surface.uiScale)
clip: true
Repeater {
model: Math.min(surface.entry.length, 28)
Rectangle {
id: pip
required property int index
anchors.verticalCenter: parent.verticalCenter
width: Math.round(7 * surface.uiScale)
height: width
radius: width / 2
color: surface.failed ? Theme.alarm : Theme.accent
opacity: 0
scale: 0.4
Component.onCompleted: arrive.start()
ParallelAnimation {
id: arrive
NumberAnimation {
target: pip; property: "opacity"
to: 1; duration: Theme.durBase; easing.type: Easing.OutCubic
}
NumberAnimation {
target: pip; property: "scale"
to: 1; duration: Theme.durBase; easing.type: Easing.OutCubic
}
}
}
}
SequentialAnimation on opacity {
running: surface.busy
loops: Animation.Infinite
alwaysRunToEnd: true
NumberAnimation { to: 0.35; duration: 520; easing.type: Easing.InOutSine }
NumberAnimation { to: 1; duration: 520; easing.type: Easing.InOutSine }
}
}
// Submit: a disc at the field's end that lights once there is
// something to send.
Rectangle {
id: enter
readonly property bool ready: surface.entry.length > 0 && !surface.busy
anchors.right: parent.right
anchors.rightMargin: Math.round(8 * surface.uiScale)
anchors.verticalCenter: parent.verticalCenter
width: Math.round(32 * surface.uiScale)
height: width
radius: width / 2
color: enter.ready ? Theme.accent : "transparent"
border.width: enter.ready ? 0 : 1
border.color: Theme.rim
Behavior on color {
ColorAnimation { duration: Theme.durBase }
}
Icon {
anchors.centerIn: parent
text: "󰁔"
color: enter.ready ? Theme.ink : Theme.overlay
font.pixelSize: Math.round(Theme.fsLarge * surface.uiScale)
}
TapHandler {
onTapped: surface.submit()
}
}
// The real input, invisible but focused.
TextInput {
id: input
anchors.fill: parent
opacity: 0
focus: true
echoMode: TextInput.Password
enabled: !surface.busy
activeFocusOnTab: true
onTextChanged: {
surface.entry = text;
if (surface.failed && text.length > 0) surface.failed = false;
}
onAccepted: surface.submit()
Keys.onEscapePressed: {
input.text = "";
surface.failed = false;
}
}
}
// Under the field: who is locked, what is happening, or what went
// wrong — one line, never more.
Row {
id: hint
anchors.top: field.bottom
anchors.topMargin: Math.round(Theme.padS * surface.uiScale)
anchors.left: parent.left
anchors.leftMargin: Math.round(Theme.padL * surface.uiScale)
spacing: Math.round(Theme.padS * surface.uiScale)
Rectangle {
anchors.verticalCenter: parent.verticalCenter
width: Math.round(4 * surface.uiScale)
height: width
radius: width / 2
color: surface.failed ? Theme.alarm : Theme.overlay
}
Text {
anchors.verticalCenter: parent.verticalCenter
text: surface.failed
? (surface.status || "Access denied").toUpperCase()
: (surface.busy ? "VERIFYING" : (Quickshell.env("USER") || "user"))
color: surface.failed ? Theme.alarm : Theme.muted
font.family: Theme.fontMono
font.pixelSize: Math.round(Theme.fsMicro * surface.uiScale)
font.letterSpacing: 2 * surface.uiScale
renderType: Text.NativeRendering
}
}
}
}
// Keep focus on the field no matter what.
Timer {
interval: 250
running: true
repeat: true
onTriggered: if (!input.activeFocus && !surface.busy) input.forceActiveFocus()
}
function submit() {
if (surface.busy || surface.entry.length === 0) return;
surface.failed = false;
surface.status = "";
pam.start();
}
PamContext {
id: pam
config: "hyprlock"
user: Quickshell.env("USER") || "USER"
onPamMessage: {
if (pam.responseRequired) pam.respond(surface.entry);
else if (pam.message) surface.status = pam.message;
}
onCompleted: result => {
if (result === PamResult.Success) {
surface.unlocked();
input.text = "";
surface.entry = "";
surface.failed = false;
} else {
input.text = "";
surface.entry = "";
surface.failed = true;
if (surface.status === "") surface.status = "Access denied";
refuse.restart();
}
}
onError: err => {
input.text = "";
surface.entry = "";
surface.failed = true;
surface.status = "PAM error";
refuse.restart();
}
}
// A wrong password: the field leans away once and comes back, a head
// shaken slowly rather than a buzzer.
SequentialAnimation {
id: refuse
NumberAnimation {
target: nudge; property: "x"
to: -7; duration: 90; easing.type: Easing.OutSine
}
NumberAnimation {
target: nudge; property: "x"
to: 5; duration: 140; easing.type: Easing.InOutSine
}
NumberAnimation {
target: nudge; property: "x"
to: 0; duration: 220; easing.type: Easing.OutCubic
}
}
// The lock comes up the way the scene would if you opened your eyes: the
// picture first, then the type settling a few pixels into place.
Component.onCompleted: intro.start()
SequentialAnimation {
id: intro
NumberAnimation {
target: wall; property: "opacity"
from: 0; to: 1; duration: Theme.durLazy; easing.type: Easing.OutCubic
}
ParallelAnimation {
NumberAnimation {
target: face; property: "opacity"
from: 0; to: 1; duration: Theme.durLazy; easing.type: Easing.OutCubic
}
NumberAnimation {
target: drift; property: "y"
from: 14; to: 0; duration: Theme.durLazy; easing.type: Easing.OutQuint
}
}
}
}