500 lines
16 KiB
QML
500 lines
16 KiB
QML
import QtQuick
|
|
import QtQuick.Layouts
|
|
import Qt5Compat.GraphicalEffects
|
|
import Quickshell.Services.UPower
|
|
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.ii.bar as Bar
|
|
import Quickshell
|
|
import Quickshell.Io
|
|
import Quickshell.Services.SystemTray
|
|
import qs.modules.ii.mediaControls
|
|
|
|
MouseArea {
|
|
id: root
|
|
required property LockContext context
|
|
property bool active: false
|
|
property bool showInputField: active || context.currentText.length > 0
|
|
readonly property bool requirePasswordToPower: Config.options.lock.security.requirePasswordToPower
|
|
|
|
// Whether a player with a title is currently active
|
|
readonly property bool mediaPlayerAvailable: MprisController.activePlayer !== null && MprisController.activePlayer.trackTitle
|
|
|
|
// Gate that keeps the Loader alive during exit animation.
|
|
// Set to true when player appears, set to false only after exit anim finishes.
|
|
property bool mediaLoaderActive: false
|
|
|
|
onMediaPlayerAvailableChanged: {
|
|
if (mediaPlayerAvailable) {
|
|
// Player appeared: activate loader (entry anim fires via onLoaded)
|
|
mediaLoaderActive = true
|
|
} else {
|
|
// Player disappeared: run exit anim, deactivate loader when done
|
|
if (lockscreenMediaController.item) {
|
|
entryAnim.stop()
|
|
mediaExitAnim.restart()
|
|
} else {
|
|
mediaLoaderActive = false
|
|
}
|
|
}
|
|
}
|
|
|
|
// Visualizer data for lockscreen media controls
|
|
property list<real> visualizerPoints: []
|
|
|
|
Process {
|
|
id: cavaProc
|
|
running: root.mediaLoaderActive && root.mediaPlayerAvailable
|
|
onRunningChanged: {
|
|
if (!cavaProc.running)
|
|
root.visualizerPoints = [];
|
|
}
|
|
command: ["cava", "-p", `${FileUtils.trimFileProtocol(Directories.scriptPath)}/cava/raw_output_config.txt`]
|
|
stdout: SplitParser {
|
|
onRead: data => {
|
|
let points = data.split(";").map(p => parseFloat(p.trim())).filter(p => !isNaN(p));
|
|
root.visualizerPoints = points;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Force focus on entry
|
|
function forceFieldFocus() {
|
|
passwordBox.forceActiveFocus();
|
|
}
|
|
Connections {
|
|
target: context
|
|
function onShouldReFocus() {
|
|
forceFieldFocus();
|
|
}
|
|
}
|
|
hoverEnabled: true
|
|
acceptedButtons: Qt.LeftButton
|
|
onPressed: mouse => {
|
|
forceFieldFocus();
|
|
}
|
|
onPositionChanged: mouse => {
|
|
forceFieldFocus();
|
|
}
|
|
|
|
// Toolbar appearing animation
|
|
property real toolbarScale: 0.9
|
|
property real toolbarOpacity: 0
|
|
Behavior on toolbarScale {
|
|
NumberAnimation {
|
|
duration: Appearance.animation.elementMove.duration
|
|
easing.type: Appearance.animation.elementMove.type
|
|
easing.bezierCurve: Appearance.animationCurves.expressiveFastSpatial
|
|
}
|
|
}
|
|
Behavior on toolbarOpacity {
|
|
animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this)
|
|
}
|
|
|
|
// Init
|
|
Component.onCompleted: {
|
|
forceFieldFocus();
|
|
toolbarScale = 1;
|
|
toolbarOpacity = 1;
|
|
}
|
|
|
|
// Key presses
|
|
property bool ctrlHeld: false
|
|
Keys.onPressed: event => {
|
|
root.context.resetClearTimer();
|
|
if (event.key === Qt.Key_Control) {
|
|
root.ctrlHeld = true;
|
|
}
|
|
if (event.key === Qt.Key_Escape) { // Esc to clear
|
|
root.context.currentText = "";
|
|
}
|
|
forceFieldFocus();
|
|
}
|
|
Keys.onReleased: event => {
|
|
if (event.key === Qt.Key_Control) {
|
|
root.ctrlHeld = false;
|
|
}
|
|
forceFieldFocus();
|
|
}
|
|
|
|
// RippleButton {
|
|
// anchors {
|
|
// top: parent.top
|
|
// left: parent.left
|
|
// leftMargin: 10
|
|
// topMargin: 10
|
|
// }
|
|
// implicitHeight: 40
|
|
// colBackground: Appearance.colors.colLayer2
|
|
// onClicked: {
|
|
// context.unlocked(LockContext.ActionEnum.Unlock);
|
|
// GlobalStates.screenLocked = false;
|
|
// }
|
|
// contentItem: StyledText {
|
|
// text: "[[ DEBUG BYPASS ]]"
|
|
// }
|
|
// }
|
|
|
|
Loader {
|
|
id: lockscreenMediaController
|
|
|
|
// Controlled manually via mediaLoaderActive so exit anim can finish
|
|
// before the item is destroyed.
|
|
active: root.mediaLoaderActive
|
|
|
|
anchors {
|
|
horizontalCenter: parent.horizontalCenter
|
|
bottom: mainIsland.top
|
|
bottomMargin: 15
|
|
}
|
|
|
|
// Driven imperatively — no Behavior, so start values apply instantly.
|
|
property real mediaScale: 0.85
|
|
property real mediaOpacity: 0.0
|
|
|
|
scale: mediaScale * root.toolbarScale
|
|
opacity: mediaOpacity * root.toolbarOpacity
|
|
|
|
// Entry: fires after sourceComponent is fully instantiated.
|
|
onLoaded: {
|
|
mediaScale = 0.85
|
|
mediaOpacity = 0.0
|
|
entryAnim.restart()
|
|
}
|
|
|
|
ParallelAnimation {
|
|
id: entryAnim
|
|
NumberAnimation {
|
|
target: lockscreenMediaController
|
|
property: "mediaScale"
|
|
to: 1.0
|
|
duration: Appearance.animation.elementMove.duration * 1.2
|
|
easing.type: Easing.OutBack
|
|
easing.overshoot: 1.2
|
|
}
|
|
NumberAnimation {
|
|
target: lockscreenMediaController
|
|
property: "mediaOpacity"
|
|
to: 1.0
|
|
duration: Appearance.animation.elementMoveFast.duration
|
|
easing.type: Easing.OutCubic
|
|
}
|
|
}
|
|
|
|
// Exit: deactivates loader when animation finishes.
|
|
SequentialAnimation {
|
|
id: mediaExitAnim
|
|
ParallelAnimation {
|
|
NumberAnimation {
|
|
target: lockscreenMediaController
|
|
property: "mediaScale"
|
|
to: 0.85
|
|
duration: Appearance.animation.elementMoveFast.duration
|
|
easing.type: Easing.InBack
|
|
easing.overshoot: 1.2
|
|
}
|
|
NumberAnimation {
|
|
target: lockscreenMediaController
|
|
property: "mediaOpacity"
|
|
to: 0.0
|
|
duration: Appearance.animation.elementMoveFast.duration
|
|
easing.type: Easing.InCubic
|
|
}
|
|
}
|
|
// Only destroy the item after the animation has fully completed.
|
|
ScriptAction {
|
|
script: root.mediaLoaderActive = false
|
|
}
|
|
}
|
|
|
|
sourceComponent: PlayerControl {
|
|
player: MprisController.activePlayer
|
|
visualizerPoints: root.visualizerPoints
|
|
implicitWidth: Appearance.sizes.mediaControlsWidth
|
|
implicitHeight: Appearance.sizes.mediaControlsHeight
|
|
radius: Appearance.rounding.screenRounding - Appearance.sizes.hyprlandGapsOut + 1
|
|
}
|
|
}
|
|
|
|
// Main toolbar: password box
|
|
Toolbar {
|
|
id: mainIsland
|
|
anchors {
|
|
horizontalCenter: parent.horizontalCenter
|
|
bottom: parent.bottom
|
|
bottomMargin: 20
|
|
}
|
|
Behavior on anchors.bottomMargin {
|
|
animation: Appearance.animation.elementMove.numberAnimation.createObject(this)
|
|
}
|
|
|
|
scale: root.toolbarScale
|
|
opacity: root.toolbarOpacity
|
|
|
|
// Fingerprint
|
|
Loader {
|
|
Layout.leftMargin: 10
|
|
Layout.rightMargin: 6
|
|
Layout.alignment: Qt.AlignVCenter
|
|
active: root.context.fingerprintsConfigured
|
|
visible: active
|
|
|
|
sourceComponent: MaterialSymbol {
|
|
id: fingerprintIcon
|
|
fill: 1
|
|
text: "fingerprint"
|
|
iconSize: Appearance.font.pixelSize.hugeass
|
|
color: Appearance.colors.colOnSurfaceVariant
|
|
}
|
|
}
|
|
|
|
ToolbarTextField {
|
|
id: passwordBox
|
|
Layout.rightMargin: -Layout.leftMargin
|
|
placeholderText: GlobalStates.screenUnlockFailed ? Translation.tr("Incorrect password") : Translation.tr("Enter password")
|
|
|
|
// Style
|
|
clip: true
|
|
font.pixelSize: Appearance.font.pixelSize.small
|
|
selectedTextColor: materialShapeChars ? "transparent" : Appearance.colors.colOnSecondaryContainer
|
|
selectionColor: materialShapeChars ? "transparent" : Appearance.colors.colSecondaryContainer
|
|
|
|
// Password
|
|
enabled: !root.context.unlockInProgress
|
|
echoMode: TextInput.Password
|
|
inputMethodHints: Qt.ImhSensitiveData
|
|
|
|
// Synchronizing (across monitors) and unlocking
|
|
onTextChanged: root.context.currentText = this.text
|
|
onAccepted: {
|
|
root.context.tryUnlock(ctrlHeld);
|
|
}
|
|
Connections {
|
|
target: root.context
|
|
function onCurrentTextChanged() {
|
|
passwordBox.text = root.context.currentText;
|
|
}
|
|
}
|
|
|
|
Keys.onPressed: event => {
|
|
root.context.resetClearTimer();
|
|
}
|
|
|
|
layer.enabled: true
|
|
layer.effect: OpacityMask {
|
|
maskSource: Rectangle {
|
|
width: passwordBox.width - 8
|
|
height: passwordBox.height
|
|
radius: height / 2
|
|
}
|
|
}
|
|
|
|
// Shake when wrong password
|
|
ErrorShakeAnimation {
|
|
id: wrongPasswordShakeAnim
|
|
target: passwordBox
|
|
}
|
|
Connections {
|
|
target: GlobalStates
|
|
function onScreenUnlockFailedChanged() {
|
|
if (GlobalStates.screenUnlockFailed) wrongPasswordShakeAnim.restart();
|
|
}
|
|
}
|
|
|
|
// We're drawing dots manually
|
|
property bool materialShapeChars: Config.options.lock.materialShapeChars
|
|
color: ColorUtils.transparentize(Appearance.colors.colOnLayer1, materialShapeChars ? 1 : 0)
|
|
Loader {
|
|
active: passwordBox.materialShapeChars
|
|
anchors {
|
|
fill: parent
|
|
leftMargin: passwordBox.padding
|
|
rightMargin: passwordBox.padding
|
|
}
|
|
sourceComponent: PasswordChars {
|
|
length: root.context.currentText.length
|
|
selectionStart: passwordBox.selectionStart
|
|
selectionEnd: passwordBox.selectionEnd
|
|
cursorPosition: passwordBox.cursorPosition
|
|
}
|
|
}
|
|
}
|
|
|
|
ToolbarButton {
|
|
id: confirmButton
|
|
implicitWidth: height
|
|
toggled: true
|
|
enabled: !root.context.unlockInProgress
|
|
colBackgroundToggled: Appearance.colors.colPrimary
|
|
|
|
onClicked: root.context.tryUnlock()
|
|
|
|
contentItem: MaterialSymbol {
|
|
anchors.centerIn: parent
|
|
horizontalAlignment: Text.AlignHCenter
|
|
verticalAlignment: Text.AlignVCenter
|
|
iconSize: 24
|
|
text: {
|
|
if (root.context.targetAction === LockContext.ActionEnum.Unlock) {
|
|
return root.ctrlHeld ? "coffee" : "arrow_right_alt";
|
|
} else if (root.context.targetAction === LockContext.ActionEnum.Poweroff) {
|
|
return "power_settings_new";
|
|
} else if (root.context.targetAction === LockContext.ActionEnum.Reboot) {
|
|
return "restart_alt";
|
|
}
|
|
}
|
|
color: confirmButton.enabled ? Appearance.colors.colOnPrimary : Appearance.colors.colSubtext
|
|
}
|
|
}
|
|
}
|
|
|
|
// Left toolbar
|
|
Toolbar {
|
|
id: leftIsland
|
|
anchors {
|
|
right: mainIsland.left
|
|
top: mainIsland.top
|
|
bottom: mainIsland.bottom
|
|
rightMargin: 10
|
|
}
|
|
scale: root.toolbarScale
|
|
opacity: root.toolbarOpacity
|
|
|
|
// Username
|
|
IconAndTextPair {
|
|
Layout.leftMargin: 8
|
|
icon: "account_circle"
|
|
text: SystemInfo.username
|
|
}
|
|
|
|
// Keyboard layout (Xkb)
|
|
Loader {
|
|
Layout.rightMargin: 8
|
|
Layout.fillHeight: true
|
|
|
|
active: true
|
|
visible: active
|
|
|
|
sourceComponent: Row {
|
|
spacing: 8
|
|
|
|
MaterialSymbol {
|
|
id: keyboardIcon
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
fill: 1
|
|
text: "keyboard_alt"
|
|
iconSize: Appearance.font.pixelSize.huge
|
|
color: Appearance.colors.colOnSurfaceVariant
|
|
}
|
|
Loader {
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
sourceComponent: StyledText {
|
|
text: HyprlandXkb.currentLayoutCode
|
|
color: Appearance.colors.colOnSurfaceVariant
|
|
animateChange: true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Keyboard layout (Fcitx)
|
|
Bar.SysTray {
|
|
Layout.rightMargin: 10
|
|
Layout.alignment: Qt.AlignVCenter
|
|
showSeparator: false
|
|
showOverflowMenu: false
|
|
pinnedItems: SystemTray.items.values.filter(i => i.id == "Fcitx")
|
|
visible: pinnedItems.length > 0
|
|
}
|
|
}
|
|
|
|
// Right toolbar
|
|
Toolbar {
|
|
id: rightIsland
|
|
anchors {
|
|
left: mainIsland.right
|
|
top: mainIsland.top
|
|
bottom: mainIsland.bottom
|
|
leftMargin: 10
|
|
}
|
|
|
|
scale: root.toolbarScale
|
|
opacity: root.toolbarOpacity
|
|
|
|
IconAndTextPair {
|
|
visible: Battery.available
|
|
icon: Battery.isCharging ? "bolt" : "battery_android_full"
|
|
text: Math.round(Battery.percentage * 100)
|
|
color: (Battery.isLow && !Battery.isCharging) ? Appearance.colors.colError : Appearance.colors.colOnSurfaceVariant
|
|
}
|
|
|
|
IconToolbarButton {
|
|
id: sleepButton
|
|
onClicked: Session.suspend()
|
|
text: "dark_mode"
|
|
}
|
|
|
|
PasswordGuardedIconToolbarButton {
|
|
id: powerButton
|
|
text: "power_settings_new"
|
|
targetAction: LockContext.ActionEnum.Poweroff
|
|
}
|
|
|
|
PasswordGuardedIconToolbarButton {
|
|
id: rebootButton
|
|
text: "restart_alt"
|
|
targetAction: LockContext.ActionEnum.Reboot
|
|
}
|
|
}
|
|
|
|
component PasswordGuardedIconToolbarButton: IconToolbarButton {
|
|
id: guardedBtn
|
|
required property var targetAction
|
|
|
|
toggled: root.context.targetAction === guardedBtn.targetAction
|
|
|
|
onClicked: {
|
|
if (!root.requirePasswordToPower) {
|
|
root.context.unlocked(guardedBtn.targetAction);
|
|
return;
|
|
}
|
|
if (root.context.targetAction === guardedBtn.targetAction) {
|
|
root.context.resetTargetAction();
|
|
} else {
|
|
root.context.targetAction = guardedBtn.targetAction;
|
|
root.context.shouldReFocus();
|
|
}
|
|
}
|
|
}
|
|
|
|
component IconAndTextPair: Row {
|
|
id: pair
|
|
required property string icon
|
|
required property string text
|
|
property color color: Appearance.colors.colOnSurfaceVariant
|
|
|
|
spacing: 4
|
|
Layout.fillHeight: true
|
|
Layout.leftMargin: 10
|
|
Layout.rightMargin: 10
|
|
|
|
|
|
MaterialSymbol {
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
fill: 1
|
|
text: pair.icon
|
|
iconSize: Appearance.font.pixelSize.huge
|
|
animateChange: true
|
|
color: pair.color
|
|
}
|
|
StyledText {
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
text: pair.text
|
|
color: pair.color
|
|
}
|
|
}
|
|
}
|