lock screen: threat vignette + chromatic aberration on failed auth, heartbeat pulse at 3+ fails
This commit is contained in:
parent
94a3fdb86c
commit
8c2f362d04
3 changed files with 251 additions and 119 deletions
|
|
@ -12,6 +12,7 @@ QtObject {
|
||||||
property string state: ""
|
property string state: ""
|
||||||
property string message: ""
|
property string message: ""
|
||||||
property string buffer: ""
|
property string buffer: ""
|
||||||
|
property int failCount: 0
|
||||||
|
|
||||||
signal unlockRequested
|
signal unlockRequested
|
||||||
signal authFailed
|
signal authFailed
|
||||||
|
|
@ -58,6 +59,7 @@ QtObject {
|
||||||
root.message = "Wrong password";
|
root.message = "Wrong password";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
root.failCount++;
|
||||||
root.authFailed();
|
root.authFailed();
|
||||||
_stateReset.restart();
|
_stateReset.restart();
|
||||||
}
|
}
|
||||||
|
|
@ -85,6 +87,7 @@ QtObject {
|
||||||
root.buffer = "";
|
root.buffer = "";
|
||||||
root.state = "";
|
root.state = "";
|
||||||
root.message = "";
|
root.message = "";
|
||||||
|
root.failCount = 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,70 +15,172 @@ WlSessionLockSurface {
|
||||||
|
|
||||||
property real _unlockFade: 1
|
property real _unlockFade: 1
|
||||||
|
|
||||||
// Solid background - WlSessionLockSurface.color can be unreliable with
|
// Threat level: 0.0-1.0 based on fail count (5 fails = max)
|
||||||
// Wayland compositors, so paint an explicit opaque rect to prevent the
|
readonly property real _threat: Math.min(1.0, root.auth.failCount / 5)
|
||||||
// compositor fallback color (niri red) from bleeding through.
|
property real _heartbeat: 0
|
||||||
Rectangle {
|
|
||||||
anchors.fill: parent
|
|
||||||
color: S.Theme.base00
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear desktop screenshot from ScreenshotService - visible immediately.
|
// All visual content wrapped for threat shader
|
||||||
// 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 {
|
Item {
|
||||||
id: _overlay
|
id: _visual
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
opacity: _unlockFade
|
|
||||||
|
|
||||||
property bool _revealed: false
|
layer.enabled: root._threat > 0
|
||||||
onVisibleChanged: _revealed = false
|
|
||||||
|
|
||||||
layer.enabled: !_revealed
|
|
||||||
layer.effect: ShaderEffect {
|
layer.effect: ShaderEffect {
|
||||||
property real uPhase: _hexWave.wavePhase
|
property real uThreat: root._threat
|
||||||
property real uWidth: root.width
|
property real uPulse: root._heartbeat
|
||||||
fragmentShader: Quickshell.shellPath("modules/reveal_mask.frag.qsb")
|
property color uColor: S.Theme.base08
|
||||||
|
fragmentShader: Quickshell.shellPath("modules/lock_threat.frag.qsb")
|
||||||
onUPhaseChanged: {
|
|
||||||
if (!_overlay._revealed && uPhase >= _overlay.width)
|
|
||||||
Qt.callLater(() => {
|
|
||||||
_overlay._revealed = true;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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 {
|
Image {
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
source: S.ScreenshotService.get(root.screen?.name ?? "")
|
source: S.ScreenshotService.get(root.screen?.name ?? "")
|
||||||
visible: (S.Modules.lock.screenshot ?? true) && source !== "" && !S.Theme.reducedMotion
|
visible: (S.Modules.lock.screenshot ?? true) && source !== "" && !S.Theme.reducedMotion
|
||||||
|
opacity: _unlockFade
|
||||||
fillMode: Image.PreserveAspectCrop
|
fillMode: Image.PreserveAspectCrop
|
||||||
|
}
|
||||||
|
|
||||||
layer.enabled: true
|
// Overlay group: blur + hexes, revealed per-pixel by wave position
|
||||||
layer.effect: MultiEffect {
|
Item {
|
||||||
autoPaddingEnabled: false
|
id: _overlay
|
||||||
blurEnabled: true
|
anchors.fill: parent
|
||||||
blur: 1
|
opacity: _unlockFade
|
||||||
blurMax: 64
|
|
||||||
|
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
|
// Clock - rotated, left-aligned, flies in with wave
|
||||||
C.HexWaveBackground {
|
LockClock {
|
||||||
id: _hexWave
|
id: _clockItem
|
||||||
anchors.fill: parent
|
anchors.left: parent.left
|
||||||
running: root.lock.secure
|
anchors.leftMargin: 48
|
||||||
opacity: 0.4
|
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: {
|
onVisibleChanged: {
|
||||||
if (visible)
|
if (visible)
|
||||||
_keyInput.forceActiveFocus();
|
_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,
|
// Screen corners - session lock surfaces render above all layer-shell,
|
||||||
// so the bar's ScreenCorners aren't visible. Draw our own.
|
// so the bar's ScreenCorners aren't visible. Draw our own.
|
||||||
component LockCorner: Canvas {
|
component LockCorner: Canvas {
|
||||||
|
|
|
||||||
52
shell/modules/lock_threat.frag
Normal file
52
shell/modules/lock_threat.frag
Normal 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;
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue