From 8c2f362d0441c2a83f4105042bf8b506e2842411 Mon Sep 17 00:00:00 2001 From: Damocles Date: Wed, 22 Apr 2026 21:28:23 +0200 Subject: [PATCH] lock screen: threat vignette + chromatic aberration on failed auth, heartbeat pulse at 3+ fails --- shell/lock/LockAuth.qml | 3 + shell/lock/LockSurface.qml | 315 ++++++++++++++++++++------------- shell/modules/lock_threat.frag | 52 ++++++ 3 files changed, 251 insertions(+), 119 deletions(-) create mode 100644 shell/modules/lock_threat.frag diff --git a/shell/lock/LockAuth.qml b/shell/lock/LockAuth.qml index 76bed4b..ca6e210 100644 --- a/shell/lock/LockAuth.qml +++ b/shell/lock/LockAuth.qml @@ -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; } } } diff --git a/shell/lock/LockSurface.qml b/shell/lock/LockSurface.qml index 983a2f5..ef46721 100644 --- a/shell/lock/LockSurface.qml +++ b/shell/lock/LockSurface.qml @@ -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 { diff --git a/shell/modules/lock_threat.frag b/shell/modules/lock_threat.frag new file mode 100644 index 0000000..9f5a39c --- /dev/null +++ b/shell/modules/lock_threat.frag @@ -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; +}