442 lines
13 KiB
QML
442 lines
13 KiB
QML
import QtQuick
|
|
import QtQuick.Effects
|
|
import Quickshell
|
|
import Quickshell.Wayland
|
|
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 _unlockFade: 1
|
|
|
|
// Threat level: eased curve so fails 1-2 are subtle, 3+ ramps up.
|
|
// Max ~0.6 at fail 5 (previously that was fail 3).
|
|
readonly property bool _threatEnabled: S.Modules.lock.threatEffect ?? true
|
|
readonly property real _threat: {
|
|
if (!_threatEnabled || root.auth.failCount <= 0)
|
|
return 0;
|
|
const t = Math.min(1.0, root.auth.failCount / 5);
|
|
return t * t * 0.6; // quadratic: 1->0.024, 2->0.096, 3->0.216, 4->0.384, 5->0.6
|
|
}
|
|
property real _heartbeat: 0
|
|
|
|
// All visual content wrapped for threat shader
|
|
Item {
|
|
id: _visual
|
|
anchors.fill: parent
|
|
|
|
layer.enabled: root._threat > 0
|
|
layer.effect: ShaderEffect {
|
|
property real uThreat: root._threat
|
|
property real uPulse: root._heartbeat
|
|
property color uColor: S.Theme.base08
|
|
fragmentShader: Quickshell.shellPath("modules/lock_threat.frag.qsb")
|
|
}
|
|
|
|
// Solid background - WlSessionLockSurface.color can be unreliable with
|
|
// Wayland compositors, so paint an explicit opaque rect to prevent the
|
|
// compositor fallback color (niri red) from bleeding through.
|
|
Rectangle {
|
|
anchors.fill: parent
|
|
color: S.Theme.base00
|
|
}
|
|
|
|
// Clear desktop screenshot from ScreenshotService - visible immediately.
|
|
// Hidden when reducedMotion (power saver) since the reveal shader and hex
|
|
// wave won't animate, leaving an unblurred screenshot visible.
|
|
Image {
|
|
anchors.fill: parent
|
|
source: S.ScreenshotService.get(root.screen?.name ?? "")
|
|
visible: (S.Modules.lock.screenshot ?? true) && source !== "" && !S.Theme.reducedMotion
|
|
opacity: _unlockFade
|
|
fillMode: Image.PreserveAspectCrop
|
|
}
|
|
|
|
// Overlay group: blur + hexes, revealed per-pixel by wave position
|
|
Item {
|
|
id: _overlay
|
|
anchors.fill: parent
|
|
opacity: _unlockFade
|
|
|
|
property bool _revealed: false
|
|
onVisibleChanged: _revealed = false
|
|
|
|
layer.enabled: !_revealed
|
|
layer.effect: ShaderEffect {
|
|
property real uPhase: _hexWave.wavePhase
|
|
property real uWidth: root.width
|
|
fragmentShader: Quickshell.shellPath("modules/reveal_mask.frag.qsb")
|
|
|
|
onUPhaseChanged: {
|
|
if (!_overlay._revealed && uPhase >= _overlay.width)
|
|
Qt.callLater(() => {
|
|
_overlay._revealed = true;
|
|
});
|
|
}
|
|
}
|
|
|
|
// Blurred screenshot
|
|
Image {
|
|
anchors.fill: parent
|
|
source: S.ScreenshotService.get(root.screen?.name ?? "")
|
|
visible: (S.Modules.lock.screenshot ?? true) && source !== "" && !S.Theme.reducedMotion
|
|
fillMode: Image.PreserveAspectCrop
|
|
|
|
layer.enabled: true
|
|
layer.effect: MultiEffect {
|
|
autoPaddingEnabled: false
|
|
blurEnabled: true
|
|
blur: 1
|
|
blurMax: 64
|
|
}
|
|
}
|
|
|
|
// Hex wave
|
|
C.HexWaveBackground {
|
|
id: _hexWave
|
|
anchors.fill: parent
|
|
running: root.lock.secure
|
|
opacity: 0.4
|
|
}
|
|
}
|
|
|
|
// Clock - rotated, left-aligned, flies in with wave
|
|
LockClock {
|
|
id: _clockItem
|
|
anchors.left: parent.left
|
|
anchors.leftMargin: 48
|
|
anchors.top: parent.top
|
|
anchors.bottom: parent.bottom
|
|
screenHeight: root.height
|
|
wavePhase: _hexWave.wavePhase
|
|
unlockFade: root._unlockFade
|
|
}
|
|
|
|
// Center content - password dead center, error below
|
|
Item {
|
|
id: content
|
|
anchors.centerIn: parent
|
|
width: 320
|
|
height: _lockInput.height
|
|
|
|
NumberAnimation on opacity {
|
|
from: 0
|
|
to: 1
|
|
duration: 300
|
|
easing.type: Easing.OutCubic
|
|
}
|
|
NumberAnimation on scale {
|
|
from: 0.9
|
|
to: 1
|
|
duration: 300
|
|
easing.type: Easing.OutCubic
|
|
}
|
|
|
|
// Password input - anchored at center
|
|
LockInput {
|
|
id: _lockInput
|
|
anchors.horizontalCenter: parent.horizontalCenter
|
|
width: 280
|
|
buffer: root.auth.buffer
|
|
state: root.auth.state
|
|
}
|
|
|
|
// Error message - fixed position below password, never shifts layout
|
|
Text {
|
|
anchors.horizontalCenter: parent.horizontalCenter
|
|
anchors.top: _lockInput.bottom
|
|
anchors.topMargin: 16
|
|
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
|
|
|
|
Behavior on opacity {
|
|
NumberAnimation {
|
|
duration: 200
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Fail counter - above password field
|
|
Text {
|
|
anchors.bottom: content.top
|
|
anchors.bottomMargin: 24
|
|
anchors.horizontalCenter: parent.horizontalCenter
|
|
visible: root.auth.failCount > 0
|
|
text: root.auth.failCount + (root.auth.failCount === 1 ? " failed attempt" : " failed attempts")
|
|
color: S.Theme.base08
|
|
opacity: Math.max(0.4, root._threat)
|
|
font.pixelSize: S.Theme.fontSize - 2
|
|
font.family: S.Theme.fontFamily
|
|
}
|
|
|
|
// Right column - widgets, fly in when wave exits screen
|
|
LockWidgets {
|
|
id: _widgetCol
|
|
anchors.right: parent.right
|
|
anchors.rightMargin: 48
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
wavePhase: _hexWave.wavePhase
|
|
screenWidth: root.width
|
|
unlockFade: root._unlockFade
|
|
}
|
|
}
|
|
|
|
// 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();
|
|
}
|
|
}
|
|
|
|
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: 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: "_unlockFade"
|
|
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();
|
|
}
|
|
}
|
|
|
|
// Heartbeat pulse - starts at fail 3, ramps amplitude and speed
|
|
// Amplitude: fail 3 -> 0.4, fail 4 -> 0.7, fail 5+ -> 1.0
|
|
readonly property real _heartbeatAmp: Math.min(1.0, (root.auth.failCount - 2) * 0.3)
|
|
|
|
SequentialAnimation {
|
|
id: _heartbeatAnim
|
|
loops: Animation.Infinite
|
|
running: root._threatEnabled && root.auth.failCount >= 3 && root.lock.secure
|
|
|
|
// Systole (sharp spike)
|
|
NumberAnimation {
|
|
target: root
|
|
property: "_heartbeat"
|
|
from: 0
|
|
to: root._heartbeatAmp
|
|
duration: 80
|
|
easing.type: Easing.OutQuad
|
|
}
|
|
NumberAnimation {
|
|
target: root
|
|
property: "_heartbeat"
|
|
to: root._heartbeatAmp * 0.15
|
|
duration: 100
|
|
easing.type: Easing.InQuad
|
|
}
|
|
// Diastole (smaller bump)
|
|
NumberAnimation {
|
|
target: root
|
|
property: "_heartbeat"
|
|
to: root._heartbeatAmp * 0.5
|
|
duration: 80
|
|
easing.type: Easing.OutQuad
|
|
}
|
|
NumberAnimation {
|
|
target: root
|
|
property: "_heartbeat"
|
|
to: 0
|
|
duration: 120
|
|
easing.type: Easing.InQuad
|
|
}
|
|
// Pause between beats - slower at fail 3, faster at 5+
|
|
PauseAnimation {
|
|
duration: Math.max(300, 1000 - (root.auth.failCount - 3) * 200)
|
|
}
|
|
|
|
onRunningChanged: if (!running)
|
|
root._heartbeat = 0
|
|
}
|
|
|
|
// 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
|
|
}
|
|
}
|