lock screen: threat vignette + chromatic aberration on failed auth, heartbeat pulse at 3+ fails

This commit is contained in:
Damocles 2026-04-22 21:28:23 +02:00
parent 94a3fdb86c
commit 8c2f362d04
3 changed files with 251 additions and 119 deletions

View file

@ -12,6 +12,7 @@ QtObject {
property string state: ""
property string message: ""
property string buffer: ""
property int failCount: 0
signal unlockRequested
signal authFailed
@ -58,6 +59,7 @@ QtObject {
root.message = "Wrong password";
}
root.failCount++;
root.authFailed();
_stateReset.restart();
}
@ -85,6 +87,7 @@ QtObject {
root.buffer = "";
root.state = "";
root.message = "";
root.failCount = 0;
}
}
}

View file

@ -15,70 +15,172 @@ WlSessionLockSurface {
property real _unlockFade: 1
// 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
}
// Threat level: 0.0-1.0 based on fail count (5 fails = max)
readonly property real _threat: Math.min(1.0, root.auth.failCount / 5)
property real _heartbeat: 0
// 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
// All visual content wrapped for threat shader
Item {
id: _overlay
id: _visual
anchors.fill: parent
opacity: _unlockFade
property bool _revealed: false
onVisibleChanged: _revealed = false
layer.enabled: !_revealed
layer.enabled: root._threat > 0
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;
});
}
property real uThreat: root._threat
property real uPulse: root._heartbeat
property color uColor: S.Theme.base08
fragmentShader: Quickshell.shellPath("modules/lock_threat.frag.qsb")
}
// Blurred screenshot
// 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
}
layer.enabled: true
layer.effect: MultiEffect {
autoPaddingEnabled: false
blurEnabled: true
blur: 1
blurMax: 64
// 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
}
}
// 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
}
}
@ -112,77 +214,6 @@ WlSessionLockSurface {
}
}
// 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
}
}
}
}
// 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
}
onVisibleChanged: {
if (visible)
_keyInput.forceActiveFocus();
@ -286,6 +317,52 @@ WlSessionLockSurface {
}
}
// Heartbeat pulse - starts at fail 3, accelerates each fail
SequentialAnimation {
id: _heartbeatAnim
loops: Animation.Infinite
running: root.auth.failCount >= 3 && root.lock.secure
// Systole (sharp spike)
NumberAnimation {
target: root
property: "_heartbeat"
from: 0
to: 1
duration: 80
easing.type: Easing.OutQuad
}
NumberAnimation {
target: root
property: "_heartbeat"
to: 0.15
duration: 100
easing.type: Easing.InQuad
}
// Diastole (smaller bump)
NumberAnimation {
target: root
property: "_heartbeat"
to: 0.5
duration: 80
easing.type: Easing.OutQuad
}
NumberAnimation {
target: root
property: "_heartbeat"
to: 0
duration: 120
easing.type: Easing.InQuad
}
// Pause between beats - shorter as fails mount
PauseAnimation {
duration: Math.max(200, 800 - (root.auth.failCount - 3) * 150)
}
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 {

View file

@ -0,0 +1,52 @@
#version 440
layout(location = 0) in vec2 qt_TexCoord0;
layout(location = 0) out vec4 fragColor;
layout(std140, binding = 0) uniform buf {
mat4 qt_Matrix;
float qt_Opacity;
// 0.0 = no threat, 1.0 = max lockout
float uThreat;
// 0.0-1.0 heartbeat pulse (0 when inactive)
float uPulse;
// accent/threat color
vec4 uColor;
};
layout(binding = 1) uniform sampler2D source;
void main() {
vec2 uv = qt_TexCoord0;
float threat = clamp(uThreat, 0.0, 1.0);
float pulse = clamp(uPulse, 0.0, 1.0);
// Combined intensity: base threat + heartbeat spike
float intensity = threat + pulse * threat * 0.4;
// Chromatic aberration - RGB channel split increasing with threat
float aberration = intensity * 0.008;
float r = texture(source, uv + vec2(aberration, 0.0)).r;
float g = texture(source, uv).g;
float b = texture(source, uv - vec2(aberration, 0.0)).b;
float a = texture(source, uv).a;
vec4 tex = vec4(r, g, b, a);
// Vignette - radial darkening from edges, creeping inward with threat
vec2 center = uv - 0.5;
float dist = length(center * vec2(1.0, 0.7)); // wider horizontally
// Inner edge shrinks as threat rises (0.7 -> 0.25)
float vigInner = mix(0.7, 0.25, intensity);
// Outer edge also shrinks (1.0 -> 0.6)
float vigOuter = mix(1.0, 0.6, intensity);
float vig = smoothstep(vigInner, vigOuter, dist);
// Red tint on the vignette area
vec3 threatColor = uColor.rgb;
vec3 tinted = mix(tex.rgb, threatColor * 0.15, vig * intensity);
// Darken edges
tinted *= 1.0 - vig * intensity * 0.7;
fragColor = vec4(tinted, tex.a) * qt_Opacity;
}