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: 0.0-1.0 based on fail count (5 fails = max) readonly property bool _threatEnabled: S.Modules.lock.threatEffect ?? true readonly property real _threat: _threatEnabled ? Math.min(1.0, root.auth.failCount / 5) : 0 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, accelerates each fail 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: 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 { 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 } }