Wallpaper grid in the settings app applying through the shell's wallpapers IPC. Lock pad now tracks the finger during the swipe and settles on release instead of snapping at a threshold.
387 lines
14 KiB
QML
387 lines
14 KiB
QML
// Souveraine addition: touch-first lock surface for the phone.
|
|
//
|
|
// A PIN keypad instead of ii's keyboard-driven LockSurface — on the phone
|
|
// the OSK is a layershell surface and can never appear above the session
|
|
// lock, so the lock surface must carry its own input. Auth goes through the
|
|
// same LockContext/PAM machinery as the desktop surface; hardware keyboards
|
|
// still work via the Keys handlers. Ambient glance content is supplied by the
|
|
// Souveraine-owned LockSurfaceHost; ii Background remains only a temporary
|
|
// compatibility layer while the shell is progressively brought in-tree.
|
|
//
|
|
// Sized for 540x1080 logical (1080x2160 @ scale 2).
|
|
import QtQuick
|
|
import QtQuick.Layouts
|
|
import qs
|
|
import qs.services
|
|
import qs.modules.common
|
|
import qs.modules.common.widgets
|
|
import qs.modules.common.functions
|
|
import qs.modules.common.panels.lock
|
|
import qs.modules.souveraine.lock
|
|
import Quickshell
|
|
import Quickshell.Services.UPower
|
|
|
|
MouseArea {
|
|
id: root
|
|
required property LockContext context
|
|
readonly property bool requirePasswordToPower: Config.options.lock.security.requirePasswordToPower
|
|
readonly property bool allowPowerFromLock: Config.options.lock.security.allowPowerFromLock
|
|
|
|
readonly property int keySize: 96
|
|
readonly property int keySpacing: 18
|
|
|
|
// Two-stage lock: glance (ambient cards + swipe hint) and PIN. The pad
|
|
// is not always up — a swipe up (or any hardware key) reveals it, and it
|
|
// retreats after sitting idle with nothing typed. Credentials machinery
|
|
// is untouched; this is purely which stage is presented.
|
|
property bool pinRevealed: false
|
|
property real pressY: 0
|
|
// Live drag: the pad follows the finger during the swipe instead of
|
|
// snapping at a threshold. dragOffset is px of upward travel; release
|
|
// past commitDistance commits the reveal, anything less springs back.
|
|
property bool dragging: false
|
|
property real dragOffset: 0
|
|
readonly property real commitDistance: 120
|
|
|
|
function forceFieldFocus() {
|
|
root.forceActiveFocus();
|
|
}
|
|
// Note: shouldReFocus does NOT reveal the pad — hypridle's after_sleep_cmd
|
|
// fires it on every wake to fix Hyprland's keyboard-focus loss, and wake
|
|
// must land on glance, not the keypad. Typing reveals via Keys below.
|
|
Connections {
|
|
target: root.context
|
|
function onShouldReFocus() {
|
|
forceFieldFocus();
|
|
}
|
|
}
|
|
Component.onCompleted: forceFieldFocus()
|
|
onPressed: mouse => {
|
|
root.pressY = mouse.y;
|
|
forceFieldFocus();
|
|
}
|
|
onPositionChanged: mouse => {
|
|
if (!root.pinRevealed) {
|
|
const d = root.pressY - mouse.y;
|
|
if (d > 8) {
|
|
root.dragging = true;
|
|
root.dragOffset = Math.max(0, d);
|
|
}
|
|
} else if (mouse.y - root.pressY > 80
|
|
&& root.context.currentText.length === 0
|
|
&& !root.context.unlockInProgress) {
|
|
root.pinRevealed = false;
|
|
}
|
|
}
|
|
onReleased: {
|
|
// Order matters: dragging must drop first so the settle (up on
|
|
// commit, back down on abort) animates from the finger's position.
|
|
const commit = !root.pinRevealed && root.dragOffset > root.commitDistance;
|
|
root.dragging = false;
|
|
if (commit) root.pinRevealed = true;
|
|
root.dragOffset = 0;
|
|
}
|
|
onCanceled: {
|
|
root.dragging = false;
|
|
root.dragOffset = 0;
|
|
}
|
|
|
|
// Retreat to glance when the pad sits unused and empty.
|
|
Timer {
|
|
interval: 25000
|
|
running: root.pinRevealed && root.context.currentText.length === 0
|
|
&& !root.context.unlockInProgress
|
|
onTriggered: root.pinRevealed = false
|
|
}
|
|
|
|
// Souveraine-owned ambient content. Credentials stay below this host, so
|
|
// phone, laptop, and desktop can rearrange the same cards later without
|
|
// inventing separate lock/session behavior.
|
|
LockSurfaceHost {
|
|
anchors {
|
|
top: parent.top
|
|
topMargin: 56
|
|
horizontalCenter: parent.horizontalCenter
|
|
}
|
|
width: Math.max(0, parent.width - 40)
|
|
z: 1
|
|
}
|
|
|
|
function pressDigit(d) {
|
|
root.context.resetClearTimer();
|
|
root.context.currentText += d;
|
|
}
|
|
|
|
// Hardware keyboard entry (USB-C keyboard); mirrors the desktop surface.
|
|
// Any key is also a reveal gesture, so typing works from glance.
|
|
focus: true
|
|
Keys.onPressed: event => {
|
|
root.pinRevealed = true;
|
|
root.context.resetClearTimer();
|
|
if (event.key === Qt.Key_Backspace) {
|
|
root.context.currentText = (event.modifiers & Qt.ControlModifier)
|
|
? "" : root.context.currentText.slice(0, -1);
|
|
} else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) {
|
|
if (root.context.currentText.length > 0) root.context.tryUnlock();
|
|
} else if (event.key === Qt.Key_Escape) {
|
|
root.context.currentText = "";
|
|
} else if (event.text.length === 1 && event.text >= " ") {
|
|
root.context.currentText += event.text;
|
|
}
|
|
}
|
|
|
|
// Glance-stage hint; tapping it is an alternative to the swipe.
|
|
ColumnLayout {
|
|
id: swipeHint
|
|
anchors {
|
|
horizontalCenter: parent.horizontalCenter
|
|
bottom: parent.bottom
|
|
bottomMargin: 64
|
|
}
|
|
z: 2
|
|
spacing: 2
|
|
visible: opacity > 0
|
|
opacity: root.pinRevealed ? 0
|
|
: 1 - Math.min(1, root.dragOffset / root.commitDistance)
|
|
Behavior on opacity {
|
|
enabled: !root.dragging
|
|
NumberAnimation { duration: 180 }
|
|
}
|
|
|
|
MaterialSymbol {
|
|
Layout.alignment: Qt.AlignHCenter
|
|
text: "keyboard_arrow_up"
|
|
iconSize: 34
|
|
color: Appearance.colors.colOnSurfaceVariant
|
|
}
|
|
StyledText {
|
|
Layout.alignment: Qt.AlignHCenter
|
|
text: Translation.tr("Swipe up to unlock")
|
|
color: Appearance.colors.colSubtext
|
|
font.pixelSize: Appearance.font.pixelSize.normal
|
|
}
|
|
|
|
TapHandler {
|
|
onTapped: root.pinRevealed = true
|
|
}
|
|
}
|
|
|
|
ColumnLayout {
|
|
id: padColumn
|
|
anchors {
|
|
horizontalCenter: parent.horizontalCenter
|
|
bottom: parent.bottom
|
|
bottomMargin: 48
|
|
}
|
|
z: 2
|
|
spacing: 20
|
|
visible: opacity > 0
|
|
opacity: root.pinRevealed ? 1
|
|
: Math.min(1, root.dragOffset / root.commitDistance)
|
|
Behavior on opacity {
|
|
enabled: !root.dragging
|
|
NumberAnimation { duration: 200 }
|
|
}
|
|
// Slides with the finger during the drag; on release the Behavior
|
|
// takes over and settles it (fully up on commit, back down on abort).
|
|
transform: Translate {
|
|
y: root.pinRevealed ? 0
|
|
: Math.max(0, (padColumn.height + 48) - root.dragOffset)
|
|
Behavior on y {
|
|
enabled: !root.dragging
|
|
NumberAnimation {
|
|
duration: 260
|
|
easing.type: Easing.OutCubic
|
|
}
|
|
}
|
|
}
|
|
|
|
// Entered-PIN dots, with the empty-state hint behind them.
|
|
// In a ColumnLayout so ErrorShakeAnimation's Layout.leftMargin works.
|
|
Item {
|
|
id: dotsArea
|
|
Layout.alignment: Qt.AlignHCenter
|
|
implicitWidth: Math.max(dotsRow.width, hintText.width, 1)
|
|
implicitHeight: 36
|
|
opacity: root.context.unlockInProgress ? 0.5 : 1
|
|
|
|
Row {
|
|
id: dotsRow
|
|
anchors.centerIn: parent
|
|
spacing: 13
|
|
Repeater {
|
|
model: Math.min(root.context.currentText.length, 14)
|
|
Rectangle {
|
|
width: 13
|
|
height: 13
|
|
radius: 6.5
|
|
color: GlobalStates.screenUnlockFailed
|
|
? Appearance.colors.colError : Appearance.colors.colOnLayer1
|
|
}
|
|
}
|
|
}
|
|
StyledText {
|
|
id: hintText
|
|
anchors.centerIn: parent
|
|
visible: root.context.currentText.length === 0
|
|
text: GlobalStates.screenUnlockFailed
|
|
? Translation.tr("Incorrect PIN") : Translation.tr("Enter PIN")
|
|
color: GlobalStates.screenUnlockFailed
|
|
? Appearance.colors.colError : Appearance.colors.colSubtext
|
|
font.pixelSize: Appearance.font.pixelSize.normal
|
|
}
|
|
|
|
ErrorShakeAnimation {
|
|
id: wrongPinShakeAnim
|
|
target: dotsArea
|
|
}
|
|
Connections {
|
|
target: GlobalStates
|
|
function onScreenUnlockFailedChanged() {
|
|
if (GlobalStates.screenUnlockFailed) wrongPinShakeAnim.restart();
|
|
}
|
|
}
|
|
}
|
|
|
|
GridLayout {
|
|
Layout.alignment: Qt.AlignHCenter
|
|
columns: 3
|
|
columnSpacing: root.keySpacing
|
|
rowSpacing: root.keySpacing
|
|
|
|
Repeater {
|
|
model: ["1", "2", "3", "4", "5", "6", "7", "8", "9"]
|
|
KeypadButton {
|
|
id: digitKey
|
|
required property string modelData
|
|
onClicked: root.pressDigit(digitKey.modelData)
|
|
contentItem: StyledText {
|
|
horizontalAlignment: Text.AlignHCenter
|
|
verticalAlignment: Text.AlignVCenter
|
|
text: digitKey.modelData
|
|
font.pixelSize: 30
|
|
color: Appearance.colors.colOnLayer1
|
|
}
|
|
}
|
|
}
|
|
|
|
KeypadButton {
|
|
enabled: root.context.currentText.length > 0 && !root.context.unlockInProgress
|
|
colBackground: "transparent"
|
|
onClicked: {
|
|
root.context.resetClearTimer();
|
|
root.context.currentText = root.context.currentText.slice(0, -1);
|
|
}
|
|
onPressAndHold: root.context.currentText = ""
|
|
contentItem: KeypadIcon {
|
|
text: "backspace"
|
|
color: Appearance.colors.colOnLayer1
|
|
}
|
|
}
|
|
|
|
KeypadButton {
|
|
onClicked: root.pressDigit("0")
|
|
contentItem: StyledText {
|
|
horizontalAlignment: Text.AlignHCenter
|
|
verticalAlignment: Text.AlignVCenter
|
|
text: "0"
|
|
font.pixelSize: 30
|
|
color: Appearance.colors.colOnLayer1
|
|
}
|
|
}
|
|
|
|
KeypadButton {
|
|
id: confirmKey
|
|
enabled: root.context.currentText.length > 0 && !root.context.unlockInProgress
|
|
toggled: true
|
|
onClicked: root.context.tryUnlock()
|
|
contentItem: KeypadIcon {
|
|
text: "arrow_right_alt"
|
|
color: confirmKey.enabled ? Appearance.colors.colOnPrimary : Appearance.colors.colSubtext
|
|
}
|
|
}
|
|
}
|
|
|
|
// Utility row: power / battery / reboot, kept small and away from digits
|
|
RowLayout {
|
|
Layout.alignment: Qt.AlignHCenter
|
|
Layout.topMargin: 6
|
|
spacing: 36
|
|
|
|
UtilityButton {
|
|
visible: root.allowPowerFromLock
|
|
iconName: "power_settings_new"
|
|
targetAction: LockContext.ActionEnum.Poweroff
|
|
}
|
|
|
|
RowLayout {
|
|
spacing: 5
|
|
visible: Battery.available
|
|
MaterialSymbol {
|
|
fill: 1
|
|
text: Battery.isCharging ? "bolt" : "battery_android_full"
|
|
iconSize: Appearance.font.pixelSize.huge
|
|
color: (Battery.isLow && !Battery.isCharging)
|
|
? Appearance.colors.colError : Appearance.colors.colOnSurfaceVariant
|
|
}
|
|
StyledText {
|
|
text: Math.round(Battery.percentage * 100) + "%"
|
|
color: Appearance.colors.colOnSurfaceVariant
|
|
}
|
|
}
|
|
|
|
UtilityButton {
|
|
visible: root.allowPowerFromLock
|
|
iconName: "restart_alt"
|
|
targetAction: LockContext.ActionEnum.Reboot
|
|
}
|
|
}
|
|
}
|
|
|
|
component KeypadButton: RippleButton {
|
|
implicitWidth: root.keySize
|
|
implicitHeight: root.keySize
|
|
buttonRadius: root.keySize / 2
|
|
enabled: !root.context.unlockInProgress
|
|
colBackground: ColorUtils.transparentize(Appearance.colors.colLayer1, 0.4)
|
|
}
|
|
|
|
component KeypadIcon: MaterialSymbol {
|
|
horizontalAlignment: Text.AlignHCenter
|
|
verticalAlignment: Text.AlignVCenter
|
|
iconSize: 30
|
|
}
|
|
|
|
// Same semantics as the desktop surface's password-guarded power buttons:
|
|
// with requirePasswordToPower the action arms and the PIN confirms it,
|
|
// otherwise it fires immediately.
|
|
component UtilityButton: RippleButton {
|
|
id: utilBtn
|
|
required property string iconName
|
|
required property var targetAction
|
|
|
|
implicitWidth: 56
|
|
implicitHeight: 56
|
|
buttonRadius: 28
|
|
toggled: root.context.targetAction === utilBtn.targetAction
|
|
colBackground: "transparent"
|
|
|
|
onClicked: {
|
|
if (!root.requirePasswordToPower) {
|
|
root.context.unlocked(utilBtn.targetAction);
|
|
return;
|
|
}
|
|
if (root.context.targetAction === utilBtn.targetAction) {
|
|
root.context.resetTargetAction();
|
|
} else {
|
|
root.context.targetAction = utilBtn.targetAction;
|
|
root.context.shouldReFocus();
|
|
}
|
|
}
|
|
|
|
contentItem: KeypadIcon {
|
|
text: utilBtn.iconName
|
|
color: utilBtn.toggled ? Appearance.colors.colOnPrimary : Appearance.colors.colOnSurfaceVariant
|
|
}
|
|
}
|
|
}
|