Files
dotfiles/quickshell/lock/LockFace.qml
T
nvrl c36a74e861 feat(quickshell): idle chain, drifting wallpaper, and a few fixes
- Idle is handled in the shell instead of hypridle: the screen dims
  slowly at 4 minutes, locks at 5, powers down at 6, and suspends at 15
  on battery. Idle inhibitors hold every stage off; Super+Shift+I does
  the same by hand and puts a cup on the rail.
- The shell draws the wallpaper (backdrop/), so hyprpaper is gone. The
  picture is drawn a little larger than the screen and drifts with the
  workspace a beat after the windows, and dims a shade while windows are
  open. The path lives in quickshell/wallpaper.
- The lock screen and power menu say "Take your time." instead of the
  NieR slogan.
- hyprpaper is no longer launched twice, the media module keeps the
  player that played last instead of an empty browser tab, and grouped
  windows show thin untitled tabs with their place in the group (2/3)
  in the bar title.
- qt6ct uses Darkly; the package list gains adw-gtk-theme and darkly-bin.
2026-10-04 23:10:19 +02:00

467 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.
Text {
topPadding: Math.round(Theme.padL * surface.uiScale)
leftPadding: Math.round(4 * surface.uiScale)
text: "Take your time."
color: Theme.subtext
font.family: Theme.fontDisplay
font.pixelSize: Math.round(Theme.fsLarge * surface.uiScale)
font.weight: Font.Light
renderType: Text.NativeRendering
}
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
}
}
}
}