Compare commits

...
8 changed files with 384 additions and 277 deletions

View file

@ -1,15 +1,11 @@
import QtQuick
import Quickshell
import Quickshell.Io
import Quickshell.Wayland
import "../services" as S
Scope {
id: root
readonly property bool _enabled: S.Modules.lock.enable
property string _sessionPath: ""
WlSessionLock {
id: _lock
@ -24,73 +20,30 @@ Scope {
lock: _lock
}
// Resolve the actual logind session object path at startup
Process {
id: _sessionResolver
command: ["busctl", "call", "--system", "org.freedesktop.login1", "/org/freedesktop/login1", "org.freedesktop.login1.Manager", "GetSession", "s", "auto"]
running: root._enabled
Connections {
target: S.LockService
stdout: SplitParser {
onRead: data => {
// Output: o "/org/freedesktop/login1/session/_32"
const match = data.match(/"([^"]+)"/);
if (match)
root._sessionPath = match[1];
}
function onLockRequested() {
if (S.LockService.enabled)
_lock.locked = true;
}
onExited: {
if (root._sessionPath) {
_logindMonitor.running = true;
_lockStateCheck.running = true;
}
function onUnlockRequested() {
if (_lock.locked)
_lock.locked = false;
}
}
// Listen for logind Lock/Unlock signals via gdbus monitor.
// TODO: replace with native D-Bus integration when nova-stats becomes a quickshell plugin
Process {
id: _logindMonitor
command: ["gdbus", "monitor", "--system", "--dest", "org.freedesktop.login1", "--object-path", root._sessionPath]
running: false
stdout: SplitParser {
onRead: data => {
if (data.indexOf(".Lock ()") !== -1 && root._enabled)
_lock.locked = true;
else if (data.indexOf(".Unlock ()") !== -1 && _lock.locked)
_lock.locked = false;
}
function onSessionLocked() {
if (S.LockService.enabled && !_lock.locked)
_lock.locked = true;
}
}
// Check if session is already locked on startup (crash recovery)
Process {
id: _lockStateCheck
running: false
command: ["busctl", "get-property", "--system", "org.freedesktop.login1", root._sessionPath, "org.freedesktop.login1.Session", "LockedHint"]
stdout: SplitParser {
onRead: data => {
// Output: b true/false
if (data.indexOf("true") !== -1 && root._enabled && !_lock.locked)
_lock.locked = true;
}
}
}
// Set logind LockedHint when lock state changes
Process {
id: _lockedHint
command: ["busctl", "call", "--system", "org.freedesktop.login1", root._sessionPath || "/org/freedesktop/login1/session/auto", "org.freedesktop.login1.Session", "SetLockedHint", "b", _lock.locked ? "true" : "false"]
}
Connections {
target: _lock
function onLockStateChanged() {
if (root._sessionPath)
_lockedHint.running = true;
S.LockService.setLockedHint(_lock.locked);
}
}
}

65
shell/lock/LockClock.qml Normal file
View file

@ -0,0 +1,65 @@
import QtQuick
import "../services" as S
Item {
id: root
required property real screenHeight
opacity: 0
property real _slideX: -80
NumberAnimation on opacity {
to: 1
duration: 400
easing.type: Easing.OutCubic
}
NumberAnimation on _slideX {
to: 0
duration: 500
easing.type: Easing.OutCubic
}
transform: Translate {
x: root._slideX
}
Column {
anchors.centerIn: parent
spacing: 8
rotation: -90
transformOrigin: Item.Center
Text {
id: _clockText
text: Qt.formatTime(new Date(), "HH:mm")
color: S.Theme.base05
font.pixelSize: Math.max(48, root.screenHeight * 0.28)
font.family: S.Theme.fontFamily
font.bold: true
Timer {
interval: 1000
running: true
repeat: true
onTriggered: parent.text = Qt.formatTime(new Date(), "HH:mm")
}
}
Text {
text: Qt.formatDate(new Date(), "dddd, d MMMM")
color: S.Theme.base04
font.pixelSize: S.Theme.fontSize + 2
font.family: S.Theme.fontFamily
Timer {
interval: 60000
running: true
repeat: true
onTriggered: parent.text = Qt.formatDate(new Date(), "dddd, d MMMM")
}
}
}
implicitWidth: _clockText.height
}

View file

@ -0,0 +1,88 @@
import QtQuick
import Quickshell
import "../services" as S
Row {
id: root
spacing: 6
visible: (S.Modules.lock.notifications ?? true) && _notifGroups.length > 0
readonly property var _notifGroups: {
const notifs = S.NotifService.list.filter(n => n.state !== "dismissed");
const groups = {};
for (const n of notifs) {
const key = n.appIcon || n.appName || "unknown";
if (!groups[key])
groups[key] = {
icon: n.appIcon,
name: n.appName,
count: 0
};
groups[key].count++;
}
return Object.values(groups);
}
Repeater {
model: root._notifGroups
delegate: Rectangle {
id: _pill
required property var modelData
width: _pillRow.implicitWidth + 12
height: 24
radius: 12
color: Qt.rgba(S.Theme.base01.r, S.Theme.base01.g, S.Theme.base01.b, 0.7)
border.color: Qt.rgba(S.Theme.base03.r, S.Theme.base03.g, S.Theme.base03.b, 0.3)
border.width: 1
HoverHandler {
id: _pillHover
}
// App name tooltip
Text {
anchors.horizontalCenter: parent.horizontalCenter
anchors.bottom: parent.top
anchors.bottomMargin: 4
text: _pill.modelData.name || ""
color: S.Theme.base04
font.pixelSize: S.Theme.fontSize - 2
font.family: S.Theme.fontFamily
visible: _pillHover.hovered && text !== ""
}
Row {
id: _pillRow
anchors.centerIn: parent
spacing: 4
Image {
anchors.verticalCenter: parent.verticalCenter
width: 14
height: 14
source: {
const icon = _pill.modelData.icon;
if (!icon)
return "";
if (icon.startsWith("/"))
return icon;
return Quickshell.iconPath(icon) ?? "";
}
sourceSize: Qt.size(14, 14)
visible: source !== ""
}
Text {
anchors.verticalCenter: parent.verticalCenter
text: _pill.modelData.count > 1 ? _pill.modelData.count.toString() : ""
color: S.Theme.base04
font.pixelSize: S.Theme.fontSize - 2
font.family: S.Theme.fontFamily
visible: _pill.modelData.count > 1
}
}
}
}
}

View file

@ -2,8 +2,6 @@ import QtQuick
import QtQuick.Effects
import Quickshell
import Quickshell.Wayland
import Quickshell.Services.Mpris
import Quickshell.Services.Pipewire
import "../services" as S
import "../applets" as C
@ -47,7 +45,6 @@ WlSessionLockSurface {
// Hex wave overlay
C.HexWaveBackground {
id: hexWave
anchors.fill: parent
running: root.lock.secure
opacity: root._bgOpacity * 0.4
@ -83,74 +80,17 @@ WlSessionLockSurface {
}
}
// Clock - rotated, left-aligned, scaled to screen height
Item {
// Clock - rotated, left-aligned
LockClock {
id: _clockItem
anchors.left: parent.left
anchors.leftMargin: 48
anchors.top: parent.top
anchors.bottom: parent.bottom
width: _clockText.height
opacity: 0
property real _slideX: -80
ParallelAnimation on opacity {
NumberAnimation {
to: 1
duration: 400
easing.type: Easing.OutCubic
}
}
NumberAnimation on _slideX {
to: 0
duration: 500
easing.type: Easing.OutCubic
}
transform: Translate {
x: _clockItem._slideX
}
Column {
id: _clockCol
anchors.centerIn: parent
spacing: 8
rotation: -90
transformOrigin: Item.Center
Text {
id: _clockText
text: Qt.formatTime(new Date(), "HH:mm")
color: S.Theme.base05
font.pixelSize: Math.max(48, root.height * 0.28)
font.family: S.Theme.fontFamily
font.bold: true
Timer {
interval: 1000
running: true
repeat: true
onTriggered: parent.text = Qt.formatTime(new Date(), "HH:mm")
}
}
Text {
text: Qt.formatDate(new Date(), "dddd, d MMMM")
color: S.Theme.base04
font.pixelSize: S.Theme.fontSize + 2
font.family: S.Theme.fontFamily
Timer {
interval: 60000
running: true
repeat: true
onTriggered: parent.text = Qt.formatDate(new Date(), "dddd, d MMMM")
}
}
}
screenHeight: root.height
}
// Center content
// Center content - password and notifications
Item {
id: content
anchors.centerIn: parent
@ -175,89 +115,8 @@ WlSessionLockSurface {
anchors.horizontalCenter: parent.horizontalCenter
spacing: 24
// Notification pills
Row {
LockNotifPills {
anchors.horizontalCenter: parent.horizontalCenter
spacing: 6
visible: (S.Modules.lock.notifications ?? true) && _notifGroups.length > 0
readonly property var _notifGroups: {
const notifs = S.NotifService.list.filter(n => n.state !== "dismissed");
const groups = {};
for (const n of notifs) {
const key = n.appIcon || n.appName || "unknown";
if (!groups[key])
groups[key] = {
icon: n.appIcon,
name: n.appName,
count: 0
};
groups[key].count++;
}
return Object.values(groups);
}
Repeater {
model: parent._notifGroups
delegate: Rectangle {
id: _pill
required property var modelData
width: _pillRow.implicitWidth + 12
height: 24
radius: 12
color: Qt.rgba(S.Theme.base01.r, S.Theme.base01.g, S.Theme.base01.b, 0.7)
border.color: Qt.rgba(S.Theme.base03.r, S.Theme.base03.g, S.Theme.base03.b, 0.3)
border.width: 1
HoverHandler {
id: _pillHover
}
// App name tooltip
Text {
anchors.horizontalCenter: parent.horizontalCenter
anchors.bottom: parent.top
anchors.bottomMargin: 4
text: _pill.modelData.name || ""
color: S.Theme.base04
font.pixelSize: S.Theme.fontSize - 2
font.family: S.Theme.fontFamily
visible: _pillHover.hovered && text !== ""
}
Row {
id: _pillRow
anchors.centerIn: parent
spacing: 4
Image {
anchors.verticalCenter: parent.verticalCenter
width: 14
height: 14
source: {
const icon = _pill.modelData.icon;
if (!icon)
return "";
if (icon.startsWith("/"))
return icon;
return Quickshell.iconPath(icon) ?? "";
}
sourceSize: Qt.size(14, 14)
visible: source !== ""
}
Text {
anchors.verticalCenter: parent.verticalCenter
text: _pill.modelData.count > 1 ? _pill.modelData.count.toString() : ""
color: S.Theme.base04
font.pixelSize: S.Theme.fontSize - 2
font.family: S.Theme.fontFamily
visible: _pill.modelData.count > 1
}
}
}
}
}
// Spacer
@ -276,7 +135,6 @@ WlSessionLockSurface {
// Error message
Text {
id: _errorText
anchors.horizontalCenter: parent.horizontalCenter
text: root.auth.message
color: S.Theme.base08
@ -297,80 +155,17 @@ WlSessionLockSurface {
}
}
}
// Spacer before widgets
Item {
width: 1
height: 8
visible: _mprisCard.visible || _volumeCard.visible
}
// Media widget
Rectangle {
id: _mprisCard
anchors.horizontalCenter: parent.horizontalCenter
width: 280
height: _mprisContent.implicitHeight + 16
radius: S.Theme.radius + 2
color: Qt.rgba(S.Theme.base01.r, S.Theme.base01.g, S.Theme.base01.b, 0.7)
border.color: Qt.rgba(S.Theme.base03.r, S.Theme.base03.g, S.Theme.base03.b, 0.3)
border.width: 1
visible: (S.Modules.lock.mpris ?? true) && _mprisPlayer !== null
readonly property var _mprisPlayers: (Mpris.players.values ?? []).filter(p => p.trackTitle || p.playbackState === MprisPlaybackState.Playing || p.playbackState === MprisPlaybackState.Paused)
property int _playerIdx: 0
readonly property var _mprisPlayer: _mprisPlayers[_playerIdx] ?? _mprisPlayers[0] ?? null
readonly property bool _playing: _mprisPlayer?.playbackState === MprisPlaybackState.Playing
C.MprisApplet {
id: _mprisContent
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.topMargin: 8
player: _mprisCard._mprisPlayer
players: _mprisCard._mprisPlayers
playing: _mprisCard._playing
playerIdx: _mprisCard._playerIdx
accentColor: S.Theme.base0D
cachedArt: _mprisCard._mprisPlayer?.trackArtUrl ?? ""
onPlayerSwitched: idx => {
_mprisCard._playerIdx = idx;
}
}
}
// Volume widget
Rectangle {
id: _volumeCard
anchors.horizontalCenter: parent.horizontalCenter
width: 280
height: _volumeContent.implicitHeight + 16
radius: S.Theme.radius + 2
color: Qt.rgba(S.Theme.base01.r, S.Theme.base01.g, S.Theme.base01.b, 0.7)
border.color: Qt.rgba(S.Theme.base03.r, S.Theme.base03.g, S.Theme.base03.b, 0.3)
border.width: 1
visible: (S.Modules.lock.volume ?? true) && Pipewire.defaultAudioSink !== null
PwObjectTracker {
objects: [Pipewire.defaultAudioSink]
}
C.VolumeApplet {
id: _volumeContent
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.topMargin: 8
sink: Pipewire.defaultAudioSink
sinkList: []
streamList: []
accentColor: S.Theme.base0E
}
}
}
}
// Right column - widgets
LockWidgets {
id: _widgetCol
anchors.right: parent.right
anchors.rightMargin: 48
anchors.verticalCenter: parent.verticalCenter
}
onVisibleChanged: {
if (visible)
_keyInput.forceActiveFocus();
@ -430,6 +225,20 @@ WlSessionLockSurface {
duration: 200
easing.type: Easing.InCubic
}
NumberAnimation {
target: _widgetCol
property: "opacity"
to: 0
duration: 200
easing.type: Easing.InCubic
}
NumberAnimation {
target: _widgetCol
property: "_slideX"
to: 80
duration: 200
easing.type: Easing.InCubic
}
NumberAnimation {
target: root
property: "_bgOpacity"

100
shell/lock/LockWidgets.qml Normal file
View file

@ -0,0 +1,100 @@
import QtQuick
import Quickshell.Services.Mpris
import Quickshell.Services.Pipewire
import "../services" as S
import "../applets" as C
Item {
id: root
width: 280
opacity: 0
property real _slideX: 80
NumberAnimation on opacity {
to: 1
duration: 400
easing.type: Easing.OutCubic
}
NumberAnimation on _slideX {
to: 0
duration: 500
easing.type: Easing.OutCubic
}
transform: Translate {
x: root._slideX
}
implicitHeight: _widgetContent.implicitHeight
visible: _mprisCard.visible || _volumeCard.visible
Column {
id: _widgetContent
width: parent.width
spacing: 12
// Media widget
Rectangle {
id: _mprisCard
width: parent.width
height: _mprisContent.implicitHeight + 16
radius: S.Theme.radius + 2
color: Qt.rgba(S.Theme.base01.r, S.Theme.base01.g, S.Theme.base01.b, 0.7)
border.color: Qt.rgba(S.Theme.base03.r, S.Theme.base03.g, S.Theme.base03.b, 0.3)
border.width: 1
visible: (S.Modules.lock.mpris ?? true) && _mprisPlayer !== null
readonly property var _mprisPlayers: (Mpris.players.values ?? []).filter(p => p.trackTitle || p.playbackState === MprisPlaybackState.Playing || p.playbackState === MprisPlaybackState.Paused)
property int _playerIdx: 0
readonly property var _mprisPlayer: _mprisPlayers[_playerIdx] ?? _mprisPlayers[0] ?? null
readonly property bool _playing: _mprisPlayer?.playbackState === MprisPlaybackState.Playing
C.MprisApplet {
id: _mprisContent
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.topMargin: 8
player: _mprisCard._mprisPlayer
players: _mprisCard._mprisPlayers
playing: _mprisCard._playing
playerIdx: _mprisCard._playerIdx
accentColor: S.Theme.base0D
cachedArt: _mprisCard._mprisPlayer?.trackArtUrl ?? ""
onPlayerSwitched: idx => {
_mprisCard._playerIdx = idx;
}
}
}
// Volume widget
Rectangle {
id: _volumeCard
width: parent.width
height: _volumeContent.implicitHeight + 16
radius: S.Theme.radius + 2
color: Qt.rgba(S.Theme.base01.r, S.Theme.base01.g, S.Theme.base01.b, 0.7)
border.color: Qt.rgba(S.Theme.base03.r, S.Theme.base03.g, S.Theme.base03.b, 0.3)
border.width: 1
visible: (S.Modules.lock.volume ?? true) && Pipewire.defaultAudioSink !== null
PwObjectTracker {
objects: [Pipewire.defaultAudioSink]
}
C.VolumeApplet {
id: _volumeContent
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.topMargin: 8
sink: Pipewire.defaultAudioSink
sinkList: []
streamList: []
accentColor: S.Theme.base0E
}
}
}
}

View file

@ -1,4 +1,7 @@
Lock 1.0 Lock.qml
LockAuth 1.0 LockAuth.qml
LockClock 1.0 LockClock.qml
LockInput 1.0 LockInput.qml
LockNotifPills 1.0 LockNotifPills.qml
LockSurface 1.0 LockSurface.qml
LockWidgets 1.0 LockWidgets.qml

View file

@ -0,0 +1,88 @@
pragma Singleton
import QtQuick
import Quickshell.Io
import "." as S
QtObject {
id: root
readonly property bool enabled: S.Modules.lock.enable
property string sessionPath: ""
// Lock/unlock requests from logind
signal lockRequested
signal unlockRequested
// Set logind LockedHint
function setLockedHint(locked) {
if (!sessionPath)
return;
_lockedHint._locked = locked;
_lockedHint.running = true;
}
// Check if session is currently locked (crash recovery)
function checkLockState() {
if (sessionPath)
_lockStateCheck.running = true;
}
signal sessionLocked // fired if LockedHint is true on startup
// Resolve the actual logind session object path at startup
property Process _sessionResolver: Process {
command: ["busctl", "call", "--system", "org.freedesktop.login1", "/org/freedesktop/login1", "org.freedesktop.login1.Manager", "GetSession", "s", "auto"]
running: root.enabled
stdout: SplitParser {
onRead: data => {
const match = data.match(/"([^"]+)"/);
if (match)
root.sessionPath = match[1];
}
}
onExited: {
if (root.sessionPath) {
_logindMonitor.running = true;
root.checkLockState();
}
}
}
// Listen for logind Lock/Unlock signals via gdbus monitor.
// TODO: replace with native D-Bus integration when nova-stats becomes a quickshell plugin
property Process _logindMonitor: Process {
command: ["gdbus", "monitor", "--system", "--dest", "org.freedesktop.login1", "--object-path", root.sessionPath]
running: false
stdout: SplitParser {
onRead: data => {
if (data.indexOf(".Lock ()") !== -1)
root.lockRequested();
else if (data.indexOf(".Unlock ()") !== -1)
root.unlockRequested();
}
}
}
// Check if session is already locked on startup (crash recovery)
property Process _lockStateCheck: Process {
running: false
command: ["busctl", "get-property", "--system", "org.freedesktop.login1", root.sessionPath, "org.freedesktop.login1.Session", "LockedHint"]
stdout: SplitParser {
onRead: data => {
if (data.indexOf("true") !== -1)
root.sessionLocked();
}
}
}
// Set logind LockedHint
property Process _lockedHint: Process {
property bool _locked: false
command: ["busctl", "call", "--system", "org.freedesktop.login1", root.sessionPath || "/org/freedesktop/login1/session/auto", "org.freedesktop.login1.Session", "SetLockedHint", "b", _locked ? "true" : "false"]
}
}

View file

@ -6,3 +6,4 @@ singleton NiriIpc 1.0 NiriIpc.qml
singleton PowerProfileService 1.0 PowerProfileService.qml
singleton NotifService 1.0 NotifService.qml
NotifItem 1.0 NotifItem.qml
singleton LockService 1.0 LockService.qml