nova-shell/shell/lock/LockSurface.qml

557 lines
17 KiB
QML

import QtQuick
import QtQuick.Effects
import Quickshell
import Quickshell.Wayland
import Quickshell.Services.Mpris
import Quickshell.Services.Pipewire
import "../services" as S
import "../applets" as C
WlSessionLockSurface {
id: root
required property WlSessionLock lock
required property LockAuth auth
color: S.Theme.base00
property real _bgOpacity: 0
NumberAnimation on _bgOpacity {
to: 1
duration: 400
easing.type: Easing.OutCubic
}
// Blur screenshot of desktop as background
ScreencopyView {
anchors.fill: parent
captureSource: root.screen
opacity: root._bgOpacity
visible: S.Modules.lock.screenshot ?? true
layer.enabled: true
layer.effect: MultiEffect {
autoPaddingEnabled: false
blurEnabled: true
blur: 1
blurMax: 64
}
}
// Dim overlay
Rectangle {
anchors.fill: parent
color: Qt.rgba(S.Theme.base00.r, S.Theme.base00.g, S.Theme.base00.b, 0.4)
opacity: root._bgOpacity
}
// Hex wave overlay
C.HexWaveBackground {
id: hexWave
anchors.fill: parent
running: root.lock.secure
opacity: root._bgOpacity * 0.4
}
// Keyboard input via TextInput - engages Qt's full input pipeline including
// text-input protocol, which is more reliable than Keys on a plain Item in
// layer-shell/lock surfaces where raw wl_keyboard delivery can be flaky.
// Declared before content so it's below in z-order - content TapHandlers
// receive mouse events, while keyboard activeFocus is independent of stacking.
TextInput {
id: _keyInput
anchors.fill: parent
focus: true
color: "transparent"
selectionColor: "transparent"
selectedTextColor: "transparent"
cursorVisible: false
enabled: !root._unlocking && root.auth.state !== "max" && root.auth.state !== "busy"
onTextChanged: if (root.auth)
root.auth.buffer = text
Keys.onReturnPressed: root.auth.submit()
Keys.onEnterPressed: root.auth.submit()
Keys.onEscapePressed: {
text = "";
}
onActiveFocusChanged: {
if (!activeFocus)
forceActiveFocus();
}
}
// Clock - rotated, left-aligned, scaled to screen height
Item {
id: _clockItem
anchors.left: parent.left
anchors.leftMargin: 48
anchors.top: parent.top
anchors.bottom: parent.bottom
width: _clockText.height
opacity: 0
property real _slideX: -80
ParallelAnimation on opacity {
NumberAnimation {
to: 1
duration: 400
easing.type: Easing.OutCubic
}
}
NumberAnimation on _slideX {
to: 0
duration: 500
easing.type: Easing.OutCubic
}
transform: Translate {
x: _clockItem._slideX
}
Column {
id: _clockCol
anchors.centerIn: parent
spacing: 8
rotation: -90
transformOrigin: Item.Center
Text {
id: _clockText
text: Qt.formatTime(new Date(), "HH:mm")
color: S.Theme.base05
font.pixelSize: Math.max(48, root.height * 0.28)
font.family: S.Theme.fontFamily
font.bold: true
Timer {
interval: 1000
running: true
repeat: true
onTriggered: parent.text = Qt.formatTime(new Date(), "HH:mm")
}
}
Text {
text: Qt.formatDate(new Date(), "dddd, d MMMM")
color: S.Theme.base04
font.pixelSize: S.Theme.fontSize + 2
font.family: S.Theme.fontFamily
Timer {
interval: 60000
running: true
repeat: true
onTriggered: parent.text = Qt.formatDate(new Date(), "dddd, d MMMM")
}
}
}
}
// Center content
Item {
id: content
anchors.centerIn: parent
width: 320
height: _col.height
opacity: 0
scale: 0.9
NumberAnimation on opacity {
to: 1
duration: 300
easing.type: Easing.OutCubic
}
NumberAnimation on scale {
to: 1
duration: 300
easing.type: Easing.OutCubic
}
Column {
id: _col
anchors.horizontalCenter: parent.horizontalCenter
spacing: 24
// Notification pills
Row {
anchors.horizontalCenter: parent.horizontalCenter
spacing: 6
visible: (S.Modules.lock.notifications ?? true) && _notifGroups.length > 0
readonly property var _notifGroups: {
const notifs = S.NotifService.list.filter(n => n.state !== "dismissed");
const groups = {};
for (const n of notifs) {
const key = n.appIcon || n.appName || "unknown";
if (!groups[key])
groups[key] = {
icon: n.appIcon,
name: n.appName,
count: 0
};
groups[key].count++;
}
return Object.values(groups);
}
Repeater {
model: parent._notifGroups
delegate: Rectangle {
id: _pill
required property var modelData
width: _pillRow.implicitWidth + 12
height: 24
radius: 12
color: Qt.rgba(S.Theme.base01.r, S.Theme.base01.g, S.Theme.base01.b, 0.7)
border.color: Qt.rgba(S.Theme.base03.r, S.Theme.base03.g, S.Theme.base03.b, 0.3)
border.width: 1
HoverHandler {
id: _pillHover
}
// App name tooltip
Text {
anchors.horizontalCenter: parent.horizontalCenter
anchors.bottom: parent.top
anchors.bottomMargin: 4
text: _pill.modelData.name || ""
color: S.Theme.base04
font.pixelSize: S.Theme.fontSize - 2
font.family: S.Theme.fontFamily
visible: _pillHover.hovered && text !== ""
}
Row {
id: _pillRow
anchors.centerIn: parent
spacing: 4
Image {
anchors.verticalCenter: parent.verticalCenter
width: 14
height: 14
source: {
const icon = _pill.modelData.icon;
if (!icon)
return "";
if (icon.startsWith("/"))
return icon;
return Quickshell.iconPath(icon) ?? "";
}
sourceSize: Qt.size(14, 14)
visible: source !== ""
}
Text {
anchors.verticalCenter: parent.verticalCenter
text: _pill.modelData.count > 1 ? _pill.modelData.count.toString() : ""
color: S.Theme.base04
font.pixelSize: S.Theme.fontSize - 2
font.family: S.Theme.fontFamily
visible: _pill.modelData.count > 1
}
}
}
}
}
// Spacer
Item {
width: 1
height: 24
}
// Password input
LockInput {
anchors.horizontalCenter: parent.horizontalCenter
width: 280
buffer: root.auth.buffer
state: root.auth.state
}
// Error message
Text {
id: _errorText
anchors.horizontalCenter: parent.horizontalCenter
text: root.auth.message
color: S.Theme.base08
font.pixelSize: S.Theme.fontSize - 1
font.family: S.Theme.fontFamily
opacity: root.auth.message ? 1 : 0
height: root.auth.message ? implicitHeight : 0
Behavior on opacity {
NumberAnimation {
duration: 200
}
}
Behavior on height {
NumberAnimation {
duration: 200
easing.type: Easing.OutCubic
}
}
}
// Spacer before widgets
Item {
width: 1
height: 8
visible: _mprisCard.visible || _volumeCard.visible
}
// Media widget
Rectangle {
id: _mprisCard
anchors.horizontalCenter: parent.horizontalCenter
width: 280
height: _mprisContent.implicitHeight + 16
radius: S.Theme.radius + 2
color: Qt.rgba(S.Theme.base01.r, S.Theme.base01.g, S.Theme.base01.b, 0.7)
border.color: Qt.rgba(S.Theme.base03.r, S.Theme.base03.g, S.Theme.base03.b, 0.3)
border.width: 1
visible: (S.Modules.lock.mpris ?? true) && _mprisPlayer !== null
readonly property var _mprisPlayers: (Mpris.players.values ?? []).filter(p => p.trackTitle || p.playbackState === MprisPlaybackState.Playing || p.playbackState === MprisPlaybackState.Paused)
property int _playerIdx: 0
readonly property var _mprisPlayer: _mprisPlayers[_playerIdx] ?? _mprisPlayers[0] ?? null
readonly property bool _playing: _mprisPlayer?.playbackState === MprisPlaybackState.Playing
C.MprisApplet {
id: _mprisContent
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.topMargin: 8
player: _mprisCard._mprisPlayer
players: _mprisCard._mprisPlayers
playing: _mprisCard._playing
playerIdx: _mprisCard._playerIdx
accentColor: S.Theme.base0D
cachedArt: _mprisCard._mprisPlayer?.trackArtUrl ?? ""
onPlayerSwitched: idx => {
_mprisCard._playerIdx = idx;
}
}
}
// Volume widget
Rectangle {
id: _volumeCard
anchors.horizontalCenter: parent.horizontalCenter
width: 280
height: _volumeContent.implicitHeight + 16
radius: S.Theme.radius + 2
color: Qt.rgba(S.Theme.base01.r, S.Theme.base01.g, S.Theme.base01.b, 0.7)
border.color: Qt.rgba(S.Theme.base03.r, S.Theme.base03.g, S.Theme.base03.b, 0.3)
border.width: 1
visible: (S.Modules.lock.volume ?? true) && Pipewire.defaultAudioSink !== null
PwObjectTracker {
objects: [Pipewire.defaultAudioSink]
}
C.VolumeApplet {
id: _volumeContent
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.topMargin: 8
sink: Pipewire.defaultAudioSink
sinkList: []
streamList: []
accentColor: S.Theme.base0E
}
}
}
}
onVisibleChanged: {
if (visible)
_keyInput.forceActiveFocus();
}
// Sync TextInput when auth clears buffer externally (PAM submit, lock reset)
Connections {
target: root.auth
function onBufferChanged() {
if (_keyInput.text !== root.auth.buffer)
_keyInput.text = root.auth.buffer;
}
}
// Unlock animation
property bool _unlocking: false
Connections {
target: root.auth
function onUnlockRequested() {
root._unlocking = true;
_unlockAnim.start();
}
}
SequentialAnimation {
id: _unlockAnim
ParallelAnimation {
NumberAnimation {
target: _clockItem
property: "opacity"
to: 0
duration: 200
easing.type: Easing.InCubic
}
NumberAnimation {
target: _clockItem
property: "_slideX"
to: -80
duration: 200
easing.type: Easing.InCubic
}
NumberAnimation {
target: content
property: "opacity"
to: 0
duration: 200
easing.type: Easing.InCubic
}
NumberAnimation {
target: content
property: "scale"
to: 0.9
duration: 200
easing.type: Easing.InCubic
}
NumberAnimation {
target: root
property: "_bgOpacity"
to: 0
duration: 300
easing.type: Easing.InCubic
}
}
PropertyAction {
target: root.lock
property: "locked"
value: false
}
}
// Shake animation on auth failure
SequentialAnimation {
id: _shakeAnim
NumberAnimation {
target: content
property: "anchors.horizontalCenterOffset"
to: 12
duration: 50
}
NumberAnimation {
target: content
property: "anchors.horizontalCenterOffset"
to: -12
duration: 50
}
NumberAnimation {
target: content
property: "anchors.horizontalCenterOffset"
to: 8
duration: 50
}
NumberAnimation {
target: content
property: "anchors.horizontalCenterOffset"
to: -8
duration: 50
}
NumberAnimation {
target: content
property: "anchors.horizontalCenterOffset"
to: 0
duration: 50
}
}
Connections {
target: root.auth
function onAuthFailed() {
_shakeAnim.restart();
}
}
// Screen corners - session lock surfaces render above all layer-shell,
// so the bar's ScreenCorners aren't visible. Draw our own.
component LockCorner: Canvas {
property int corner: 0
readonly property int _r: S.Theme.screenRadius
visible: _r > 0 && S.Modules.screenCorners.enable
width: _r
height: _r
z: 999
onPaint: {
const r = _r;
const ctx = getContext("2d");
ctx.clearRect(0, 0, r, r);
ctx.fillStyle = "black";
ctx.beginPath();
switch (corner) {
case 0:
ctx.moveTo(0, 0);
ctx.lineTo(r, 0);
ctx.arc(r, r, r, -Math.PI / 2, Math.PI, true);
ctx.closePath();
break;
case 1:
ctx.moveTo(r, 0);
ctx.lineTo(0, 0);
ctx.arc(0, r, r, -Math.PI / 2, 0, false);
ctx.closePath();
break;
case 2:
ctx.moveTo(0, r);
ctx.lineTo(0, 0);
ctx.arc(r, 0, r, Math.PI, Math.PI / 2, true);
ctx.closePath();
break;
case 3:
ctx.moveTo(r, r);
ctx.lineTo(r, 0);
ctx.arc(0, 0, r, 0, Math.PI / 2, false);
ctx.closePath();
break;
}
ctx.fill();
}
}
LockCorner {
corner: 0
anchors.top: parent.top
anchors.left: parent.left
}
LockCorner {
corner: 1
anchors.top: parent.top
anchors.right: parent.right
}
LockCorner {
corner: 2
anchors.bottom: parent.bottom
anchors.left: parent.left
}
LockCorner {
corner: 3
anchors.bottom: parent.bottom
anchors.right: parent.right
}
}