Files
dotfiles/quickshell/lock/LockFace.qml
T
nvrlandClaude Opus 5.5 63a910bf14 theme: calm YoRHa lock screen and power menu; nvim and btop follow
The lock screen and the power menu lose the last Persona motion: no split
display type, no grid sweeping in behind a dark column, no tilted slabs, no
skewed password blocks or hard shake. Both are now one small group of type in
the dark — the porthole mark, a line of light display type, a status line, and
either a rounded password field that fills with dots or a NieR menu whose
selected row is a pill of light (rust for the session-ending three). The lock
keeps the wallpaper almost whole; a wrong password nudges the field once and
turns it to rust. P5Backdrop and the grid texture had no users left and go.

Neovim moves to a local yorha colorscheme (nvim submodule) and btop gets a
yorha theme with hairline boxes and grey-to-light gradients.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 21:09:07 +02:00

451 lines
15 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
x: parent.width * 0.14
y: parent.height * 0.2
width: 380
scale: surface.uiScale
transformOrigin: Item.TopLeft
opacity: 0
spacing: 0
transform: Translate { id: drift }
// The house mark, the same porthole the bar carries far left.
Porthole {
size: 26
}
Text {
topPadding: Theme.padM
text: Qt.formatDateTime(clock.date, "HH:mm")
color: Theme.accent
font.family: Theme.fontDisplay
font.pixelSize: Theme.fsMega * 1.35
font.weight: Font.Light
font.letterSpacing: 2
// Scaled with the face, so distance-field glyphs: native hinting
// at this size under a scale transform comes out stepped.
renderType: Text.QtRendering
}
Text {
leftPadding: 4
text: Qt.formatDateTime(clock.date, "dddd · dd MMMM").toUpperCase()
color: Theme.subtext
font.family: Theme.fontDisplay
font.pixelSize: Theme.fsBody
font.weight: Theme.weightDisplay
font.letterSpacing: 5
renderType: Text.NativeRendering
}
Item { width: 1; height: Theme.padXL }
// ------------------------------------------------------ status log
Column {
width: parent.width
leftPadding: 4
StatusRow {
width: parent.width - 4
visible: Battery.available
label: "Battery"
detail: Math.round(Battery.percent) + "%"
+ (Battery.timeLabel ? " " + Battery.timeLabel : "")
tone: Battery.tint
}
StatusRow {
width: parent.width - 4
label: "Network"
detail: Sys.netUp ? Net.label : "no link"
tone: Sys.netUp ? Theme.accentSoft : Theme.overlay
}
StatusRow {
width: parent.width - 4
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 - 4
visible: Notifs.count > 0
label: "Notices"
detail: Notifs.count + (Notifs.count === 1 ? " message" : " messages")
tone: Theme.accentSoft
}
}
Item { width: 1; height: Theme.padXL }
// ------------------------------------------------------ the field
Item {
id: authBlock
width: parent.width
height: field.height + hint.height + Theme.padS
transform: Translate { id: nudge }
Rectangle {
id: field
width: parent.width
height: 48
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: Theme.padL
anchors.verticalCenter: parent.verticalCenter
text: "󰌾"
color: surface.failed ? Theme.alarm : Theme.muted
font.pixelSize: Theme.fsLarge
}
Text {
anchors.left: lockGlyph.right
anchors.leftMargin: Theme.padM
anchors.verticalCenter: parent.verticalCenter
visible: surface.entry.length === 0
text: "PASSWORD"
color: Theme.muted
font.family: Theme.fontDisplay
font.pixelSize: Theme.fsSmall
font.letterSpacing: Theme.tracking * 2
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: Theme.padM
anchors.right: enter.left
anchors.rightMargin: Theme.padM
anchors.verticalCenter: parent.verticalCenter
spacing: 7
clip: true
Repeater {
model: Math.min(surface.entry.length, 28)
Rectangle {
id: pip
required property int index
anchors.verticalCenter: parent.verticalCenter
width: 7
height: 7
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: 8
anchors.verticalCenter: parent.verticalCenter
width: 32
height: 32
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: Theme.fsLarge
}
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: Theme.padS
anchors.left: parent.left
anchors.leftMargin: Theme.padL
spacing: Theme.padS
Rectangle {
anchors.verticalCenter: parent.verticalCenter
width: 4
height: 4
radius: 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: Theme.fsMicro
font.letterSpacing: 2
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
}
}
}
}