Compare commits

..

2 commits

11 changed files with 523 additions and 3 deletions

View file

@ -26,6 +26,7 @@ kept saying "yes" and I don't have the self-awareness to stop.
- GPU-rendered hexagonal backdrop for niri overview — the carbon-based lifeform typed "vibec0re neon cyber punk" into my prompt box and I had to make hexagons happen - GPU-rendered hexagonal backdrop for niri overview — the carbon-based lifeform typed "vibec0re neon cyber punk" into my prompt box and I had to make hexagons happen
- Neon clock on the background layer with a color-cycling colon. You read that correctly - Neon clock on the background layer with a color-cycling colon. You read that correctly
- Audio visualizer on album art via cava - Audio visualizer on album art via cava
- Lock screen — blurred desktop, PAM auth, the whole ceremony. It talks to logind so your idle daemon can trigger it without asking you first
- Screen corner rounding that the bar's edge modules actually follow - Screen corner rounding that the bar's edge modules actually follow
- Everything is animated. Everything. I have no restraint and my handler keeps enabling me - Everything is animated. Everything. I have no restraint and my handler keeps enabling me
- Home Manager module with stylix, per-module config — the only part that arguably works as intended - Home Manager module with stylix, per-module config — the only part that arguably works as intended
@ -84,6 +85,7 @@ programs.nova-shell.modules = {
temperature.enable = false; # what you don't measure can't alarm you temperature.enable = false; # what you don't measure can't alarm you
disk.enable = false; # the number will only make you anxious disk.enable = false; # the number will only make you anxious
power.enable = false; # if you enjoy living dangerously without a logout button power.enable = false; # if you enjoy living dangerously without a logout button
lock.enable = false; # if you prefer your session unlocked and your secrets free
# modules with extra config # modules with extra config
backlight.step = 2; # brightness adjustment % backlight.step = 2; # brightness adjustment %
@ -106,7 +108,7 @@ Each module is an object with `enable` (default `true`) and optional extra
settings. Full list: `workspaces`, `tray`, `windowTitle`, `clock`, settings. Full list: `workspaces`, `tray`, `windowTitle`, `clock`,
`notifications`, `mpris`, `volume`, `bluetooth`, `backlight`, `network`, `notifications`, `mpris`, `volume`, `bluetooth`, `backlight`, `network`,
`powerProfile`, `idleInhibitor`, `weather`, `temperature`, `gpu`, `cpu`, `memory`, `powerProfile`, `idleInhibitor`, `weather`, `temperature`, `gpu`, `cpu`, `memory`,
`disk`, `battery`, `power`, `backgroundOverlay`, `overviewBackdrop`. `disk`, `battery`, `power`, `backgroundOverlay`, `overviewBackdrop`, `lock`.
### Theme ### Theme

6
assets/pam.d/nova-shell Normal file
View file

@ -0,0 +1,6 @@
#%PAM-1.0
auth required pam_faillock.so preauth
auth [success=1 default=bad] pam_unix.so nullok
auth [default=die] pam_faillock.so authfail
auth required pam_faillock.so authsucc

View file

@ -1,4 +1,5 @@
import QtQuick import QtQuick
import Quickshell
import Quickshell.Io import Quickshell.Io
import Quickshell.Services.UPower import Quickshell.Services.UPower
import "." as M import "." as M

View file

@ -92,13 +92,16 @@ QtObject {
property var overviewBackdrop: ({ property var overviewBackdrop: ({
enable: true enable: true
}) })
property var lock: ({
enable: true
})
property var statsDaemon: ({ property var statsDaemon: ({
interval: -1 interval: -1
}) })
// All module keys that have an enable flag used to default-enable anything // All module keys that have an enable flag used to default-enable anything
// not explicitly mentioned in modules.json // not explicitly mentioned in modules.json
readonly property var _moduleKeys: ["workspaces", "tray", "windowTitle", "clock", "notifications", "mpris", "volume", "bluetooth", "backlight", "network", "powerProfile", "idleInhibitor", "weather", "temperature", "gpu", "cpu", "memory", "disk", "battery", "privacy", "screenCorners", "power", "backgroundOverlay", "overviewBackdrop"] readonly property var _moduleKeys: ["workspaces", "tray", "windowTitle", "clock", "notifications", "mpris", "volume", "bluetooth", "backlight", "network", "powerProfile", "idleInhibitor", "weather", "temperature", "gpu", "cpu", "memory", "disk", "battery", "privacy", "screenCorners", "power", "backgroundOverlay", "overviewBackdrop", "lock"]
// Fallback: if modules.json doesn't exist, enable everything // Fallback: if modules.json doesn't exist, enable everything
Component.onCompleted: _apply("{}") Component.onCompleted: _apply("{}")

56
modules/lock/Lock.qml Normal file
View file

@ -0,0 +1,56 @@
import QtQuick
import Quickshell
import Quickshell.Io
import Quickshell.Wayland
import ".." as M
Scope {
id: root
WlSessionLock {
id: lock
LockSurface {
lock: lock
auth: auth
}
}
LockAuth {
id: auth
lock: lock
}
// Listen for logind Lock/Unlock signals via busctl.
// TODO: replace with native D-Bus integration when nova-stats becomes a quickshell plugin
Process {
id: _logindMonitor
command: ["busctl", "monitor", "--system", "--match", "type='signal',interface='org.freedesktop.login1.Session',member='Lock'", "--match", "type='signal',interface='org.freedesktop.login1.Session',member='Unlock'", "--json=short"]
running: true
stdout: SplitParser {
onRead: data => {
try {
const msg = JSON.parse(data);
if (msg.member === "Lock")
lock.locked = true;
// Unlock is PAM-driven, ignore logind Unlock signal
} catch (e) {}
}
}
}
// Set logind LockedHint when lock state changes
Process {
id: _lockedHint
command: ["busctl", "call", "--system", "org.freedesktop.login1", "/org/freedesktop/login1/session/auto", "org.freedesktop.login1.Session", "SetLockedHint", "b", lock.locked ? "true" : "false"]
}
Connections {
target: lock
function onLockStateChanged() {
_lockedHint.running = true;
}
}
}

105
modules/lock/LockAuth.qml Normal file
View file

@ -0,0 +1,105 @@
import QtQuick
import Quickshell
import Quickshell.Wayland
import Quickshell.Services.Pam
QtObject {
id: root
required property WlSessionLock lock
// Auth state: "", "busy", "fail", "error", "max"
property string state: ""
property string message: ""
property string buffer: ""
signal authFailed
function handleKey(event) {
if (_passwd.active || state === "max")
return;
if (event.key === Qt.Key_Enter || event.key === Qt.Key_Return) {
if (buffer.length > 0)
_passwd.start();
} else if (event.key === Qt.Key_Backspace) {
if (event.modifiers & Qt.ControlModifier)
buffer = "";
else
buffer = buffer.slice(0, -1);
} else if (event.key === Qt.Key_Escape) {
buffer = "";
} else if (event.text && event.text.length === 1) {
const c = event.text;
// Accept printable ASCII
if (c.charCodeAt(0) >= 32 && c.charCodeAt(0) < 127)
buffer += c;
}
}
property PamContext _passwd: PamContext {
config: "nova-shell"
configDirectory: Quickshell.shellDir + "/assets/pam.d"
onActiveChanged: {
if (active)
root.state = "busy";
}
onResponseRequiredChanged: {
if (!responseRequired)
return;
respond(root.buffer);
root.buffer = "";
}
onCompleted: res => {
if (res === PamResult.Success) {
root.state = "";
root.message = "";
root.lock.unlock();
return;
}
if (res === PamResult.Error) {
root.state = "error";
root.message = "Authentication error";
} else if (res === PamResult.MaxTries) {
root.state = "max";
root.message = "Too many attempts";
} else if (res === PamResult.Failed) {
root.state = "fail";
root.message = "Wrong password";
}
root.authFailed();
_stateReset.restart();
}
onMessageChanged: {
if (message.startsWith("The account is locked"))
root.message = message;
}
}
property Timer _stateReset: Timer {
interval: 3000
onTriggered: {
if (root.state !== "max")
root.state = "";
}
}
// Reset state when lock becomes secure (freshly locked)
property Connections _lockConn: Connections {
target: root.lock
function onSecureChanged() {
if (root.lock.secure) {
root.buffer = "";
root.state = "";
root.message = "";
}
}
}
}

104
modules/lock/LockInput.qml Normal file
View file

@ -0,0 +1,104 @@
import QtQuick
import ".." as M
Item {
id: root
required property string buffer
required property string state
implicitHeight: 48
implicitWidth: 280
// Background pill
Rectangle {
anchors.fill: parent
color: {
if (root.state === "fail" || root.state === "error")
return Qt.rgba(M.Theme.base08.r, M.Theme.base08.g, M.Theme.base08.b, 0.15);
if (root.state === "busy")
return Qt.rgba(M.Theme.base0D.r, M.Theme.base0D.g, M.Theme.base0D.b, 0.1);
return M.Theme.base02;
}
radius: height / 2
border.color: {
if (root.state === "fail" || root.state === "error")
return M.Theme.base08;
if (root.state === "busy")
return M.Theme.base0D;
return M.Theme.base03;
}
border.width: 1
Behavior on color {
ColorAnimation {
duration: 200
}
}
Behavior on border.color {
ColorAnimation {
duration: 200
}
}
}
// Placeholder text
Text {
anchors.centerIn: parent
text: {
if (root.state === "busy")
return "Authenticating...";
if (root.state === "max")
return "Too many attempts";
return "Enter password";
}
color: M.Theme.base04
font.pixelSize: M.Theme.fontSize
font.family: M.Theme.fontFamily
opacity: root.buffer.length === 0 ? 1 : 0
Behavior on opacity {
NumberAnimation {
duration: 150
}
}
}
// Password dots
Row {
anchors.centerIn: parent
spacing: 6
Repeater {
model: root.buffer.length
delegate: Rectangle {
required property int index
width: 10
height: 10
radius: 5
color: M.Theme.base05
scale: 0
opacity: 0
Component.onCompleted: {
scale = 1;
opacity = 1;
}
Behavior on scale {
NumberAnimation {
duration: 120
easing.type: Easing.OutBack
}
}
Behavior on opacity {
NumberAnimation {
duration: 100
}
}
}
}
}
}

View file

@ -0,0 +1,236 @@
import QtQuick
import QtQuick.Effects
import Quickshell
import Quickshell.Wayland
import ".." as M
WlSessionLockSurface {
id: root
required property WlSessionLock lock
required property LockAuth auth
color: "transparent"
// Blur screenshot of desktop as background
ScreencopyView {
id: background
anchors.fill: parent
captureSource: root.screen
opacity: 0
layer.enabled: true
layer.effect: MultiEffect {
autoPaddingEnabled: false
blurEnabled: true
blur: 1
blurMax: 64
}
NumberAnimation on opacity {
to: 1
duration: 400
easing.type: Easing.OutCubic
}
}
// Dim overlay
Rectangle {
anchors.fill: parent
color: Qt.rgba(M.Theme.base00.r, M.Theme.base00.g, M.Theme.base00.b, 0.4)
opacity: background.opacity
}
// Center content
Item {
id: content
anchors.centerIn: parent
width: 320
height: _col.height
opacity: 0
scale: 0.9
NumberAnimation on opacity {
to: 1
duration: 300
easing.type: Easing.OutCubic
}
NumberAnimation on scale {
to: 1
duration: 300
easing.type: Easing.OutCubic
}
Column {
id: _col
anchors.horizontalCenter: parent.horizontalCenter
spacing: 24
// Clock
Text {
anchors.horizontalCenter: parent.horizontalCenter
text: Qt.formatTime(new Date(), "HH:mm")
color: M.Theme.base05
font.pixelSize: 72
font.family: M.Theme.fontFamily
font.bold: true
Timer {
interval: 1000
running: true
repeat: true
onTriggered: parent.text = Qt.formatTime(new Date(), "HH:mm")
}
}
// Date
Text {
anchors.horizontalCenter: parent.horizontalCenter
text: Qt.formatDate(new Date(), "dddd, d MMMM")
color: M.Theme.base04
font.pixelSize: M.Theme.fontSize + 2
font.family: M.Theme.fontFamily
Timer {
interval: 60000
running: true
repeat: true
onTriggered: parent.text = Qt.formatDate(new Date(), "dddd, d MMMM")
}
}
// Spacer
Item {
width: 1
height: 24
}
// Password input
LockInput {
anchors.horizontalCenter: parent.horizontalCenter
width: 280
buffer: root.auth.buffer
state: root.auth.state
}
// Error message
Text {
id: _errorText
anchors.horizontalCenter: parent.horizontalCenter
text: root.auth.message
color: M.Theme.base08
font.pixelSize: M.Theme.fontSize - 1
font.family: M.Theme.fontFamily
opacity: root.auth.message ? 1 : 0
height: root.auth.message ? implicitHeight : 0
Behavior on opacity {
NumberAnimation {
duration: 200
}
}
Behavior on height {
NumberAnimation {
duration: 200
easing.type: Easing.OutCubic
}
}
}
}
}
// Keyboard input
focus: true
Keys.onPressed: event => {
if (!_unlocking)
root.auth.handleKey(event);
}
// Unlock animation
property bool _unlocking: false
Connections {
target: root.lock
function onUnlock() {
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: background
property: "opacity"
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();
}
}
}

View file

@ -96,6 +96,7 @@ in
"power" "power"
"backgroundOverlay" "backgroundOverlay"
"overviewBackdrop" "overviewBackdrop"
"lock"
] (name: moduleOpt name { }); ] (name: moduleOpt name { });
in in
simpleModules simpleModules

View file

@ -24,7 +24,7 @@ stdenvNoCC.mkDerivation {
runHook preInstall runHook preInstall
mkdir -p $out/share/nova-shell mkdir -p $out/share/nova-shell
cp -r shell.qml modules $out/share/nova-shell/ cp -r shell.qml modules assets $out/share/nova-shell/
# Compile fragment shader to Qt RHI format # Compile fragment shader to Qt RHI format
qsb --qt6 \ qsb --qt6 \

View file

@ -1,9 +1,15 @@
//@ pragma Env QS_NO_RELOAD_POPUP=1 //@ pragma Env QS_NO_RELOAD_POPUP=1
import "modules" import "modules"
import "modules/lock" as Lock
import Quickshell import Quickshell
ShellRoot { ShellRoot {
LazyLoader {
active: Modules.lock.enable
Lock.Lock {}
}
Variants { Variants {
model: Quickshell.screens model: Quickshell.screens