Compare commits

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

View file

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

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 QtQuick.Effects
import Quickshell import Quickshell
import Quickshell.Wayland import Quickshell.Wayland
import Quickshell.Services.Mpris
import Quickshell.Services.Pipewire
import "../services" as S import "../services" as S
import "../applets" as C import "../applets" as C
@ -47,7 +45,6 @@ WlSessionLockSurface {
// Hex wave overlay // Hex wave overlay
C.HexWaveBackground { C.HexWaveBackground {
id: hexWave
anchors.fill: parent anchors.fill: parent
running: root.lock.secure running: root.lock.secure
opacity: root._bgOpacity * 0.4 opacity: root._bgOpacity * 0.4
@ -83,74 +80,17 @@ WlSessionLockSurface {
} }
} }
// Clock - rotated, left-aligned, scaled to screen height // Clock - rotated, left-aligned
Item { LockClock {
id: _clockItem id: _clockItem
anchors.left: parent.left anchors.left: parent.left
anchors.leftMargin: 48 anchors.leftMargin: 48
anchors.top: parent.top anchors.top: parent.top
anchors.bottom: parent.bottom anchors.bottom: parent.bottom
width: _clockText.height screenHeight: root.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")
}
}
}
} }
// Center content // Center content - password and notifications
Item { Item {
id: content id: content
anchors.centerIn: parent anchors.centerIn: parent
@ -175,89 +115,8 @@ WlSessionLockSurface {
anchors.horizontalCenter: parent.horizontalCenter anchors.horizontalCenter: parent.horizontalCenter
spacing: 24 spacing: 24
// Notification pills LockNotifPills {
Row {
anchors.horizontalCenter: parent.horizontalCenter 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 // Spacer
@ -276,7 +135,6 @@ WlSessionLockSurface {
// Error message // Error message
Text { Text {
id: _errorText
anchors.horizontalCenter: parent.horizontalCenter anchors.horizontalCenter: parent.horizontalCenter
text: root.auth.message text: root.auth.message
color: S.Theme.base08 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: { onVisibleChanged: {
if (visible) if (visible)
_keyInput.forceActiveFocus(); _keyInput.forceActiveFocus();
@ -430,6 +225,20 @@ WlSessionLockSurface {
duration: 200 duration: 200
easing.type: Easing.InCubic 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 { NumberAnimation {
target: root target: root
property: "_bgOpacity" 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 Lock 1.0 Lock.qml
LockAuth 1.0 LockAuth.qml LockAuth 1.0 LockAuth.qml
LockClock 1.0 LockClock.qml
LockInput 1.0 LockInput.qml LockInput 1.0 LockInput.qml
LockNotifPills 1.0 LockNotifPills.qml
LockSurface 1.0 LockSurface.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 PowerProfileService 1.0 PowerProfileService.qml
singleton NotifService 1.0 NotifService.qml singleton NotifService 1.0 NotifService.qml
NotifItem 1.0 NotifItem.qml NotifItem 1.0 NotifItem.qml
singleton LockService 1.0 LockService.qml