Compare commits

...

3 commits

9 changed files with 125 additions and 127 deletions

View file

@ -131,7 +131,15 @@ programs.nova-shell.modules = {
``` ```
Each module is an object with `enable` (default `true`) and optional extra Each module is an object with `enable` (default `true`) and optional extra
settings. Full list: `workspaces`, `tray`, `windowTitle`, `clock`, settings. For a full list of all options with types and defaults, build the docs:
```bash
nix build .#docs
# markdown: result/options.md
# html: result/index.html (open in browser)
```
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`, `lock`. `disk`, `battery`, `power`, `backgroundOverlay`, `overviewBackdrop`, `lock`.

View file

@ -126,6 +126,11 @@ in
default = true; default = true;
description = "Show weather summary on the lock screen."; description = "Show weather summary on the lock screen.";
}; };
threatEffect = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Show red vignette and chromatic aberration on failed password attempts.";
};
}; };
notifications = moduleOpt "notifications" { notifications = moduleOpt "notifications" {
timeout = lib.mkOption { timeout = lib.mkOption {

View file

@ -1,81 +1,26 @@
import QtQuick import QtQuick
import Quickshell import Quickshell
import "." as M
import "../services" as S import "../services" as S
import "../modules" as M
M.HoverPanel { Column {
id: menuWindow id: root
popupMode: true required property color accentColor
contentWidth: 350 required property int contentWidth
// Header: title + clear all + DND toggle function cascadeDismiss() {
Item { _cascadeDismiss();
width: menuWindow.contentWidth
height: 32
Text {
anchors.left: parent.left
anchors.leftMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: "Notifications"
color: S.Theme.base05
font.pixelSize: S.Theme.fontSize + 1
font.family: S.Theme.fontFamily
font.bold: true
}
Row {
anchors.right: parent.right
anchors.rightMargin: 8
anchors.verticalCenter: parent.verticalCenter
spacing: 8
// DND toggle
Text {
text: S.NotifService.dnd ? "\uDB82\uDE93" : "\uDB80\uDC9C"
color: S.NotifService.dnd ? S.Theme.base09 : S.Theme.base04
font.pixelSize: S.Theme.fontSize
font.family: S.Theme.iconFontFamily
anchors.verticalCenter: parent.verticalCenter
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: S.NotifService.toggleDnd()
}
}
// Clear all
Text {
text: "\uF1F8"
color: clearArea.containsMouse ? S.Theme.base08 : S.Theme.base04
font.pixelSize: S.Theme.fontSize
font.family: S.Theme.iconFontFamily
anchors.verticalCenter: parent.verticalCenter
visible: S.NotifService.count > 0
MouseArea {
id: clearArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: menuWindow._cascadeDismiss()
}
}
}
} }
// Cascade dismiss logic
property var _pendingDismissIds: [] property var _pendingDismissIds: []
// Collapsed groups set reassign to trigger reactivity
property var _collapsedGroups: ({}) property var _collapsedGroups: ({})
property real _savedScrollY: 0 property real _savedScrollY: 0
property bool _restoringScroll: false property bool _restoringScroll: false
function _toggleCollapse(appName) { function _toggleCollapse(appName) {
_savedScrollY = notifList.contentY; _savedScrollY = _notifList.contentY;
_restoringScroll = true; _restoringScroll = true;
const next = Object.assign({}, _collapsedGroups); const next = Object.assign({}, _collapsedGroups);
if (next[appName]) if (next[appName])
@ -85,7 +30,6 @@ M.HoverPanel {
_collapsedGroups = next; _collapsedGroups = next;
} }
// Group notifications by appName, sorted by max urgency desc then most recent time desc
readonly property var _groups: { readonly property var _groups: {
const map = {}; const map = {};
for (const n of S.NotifService.list) { for (const n of S.NotifService.list) {
@ -111,7 +55,6 @@ M.HoverPanel {
}); });
} }
// Flat model: group header followed by its notifications (omitted when collapsed)
readonly property var _flatModel: { readonly property var _flatModel: {
const arr = []; const arr = [];
for (const g of _groups) { for (const g of _groups) {
@ -135,7 +78,6 @@ M.HoverPanel {
return arr; return arr;
} }
// Collect visible (non-dismissing) notif delegates, optionally filtered by appName
function _getVisibleNotifDelegates(appName) { function _getVisibleNotifDelegates(appName) {
const result = []; const result = [];
for (let i = 0; i < _flatModel.length; i++) { for (let i = 0; i < _flatModel.length; i++) {
@ -144,7 +86,7 @@ M.HoverPanel {
continue; continue;
if (appName !== undefined && item.data.appName !== appName) if (appName !== undefined && item.data.appName !== appName)
continue; continue;
const d = notifList.itemAtIndex(i); const d = _notifList.itemAtIndex(i);
if (d && d._type === "notif" && d._notif?.state !== "dismissing") if (d && d._type === "notif" && d._notif?.state !== "dismissing")
result.push(d); result.push(d);
} }
@ -158,7 +100,7 @@ M.HoverPanel {
return; return;
} }
for (let i = 0; i < visibles.length; i++) { for (let i = 0; i < visibles.length; i++) {
_cascadeTimer.createObject(menuWindow, { _cascadeTimer.createObject(root, {
_target: visibles[i], _target: visibles[i],
_delay: i * 60, _delay: i * 60,
_isLast: i === visibles.length - 1 _isLast: i === visibles.length - 1
@ -198,7 +140,7 @@ M.HoverPanel {
if (_target && _target.dismissVisualOnly) if (_target && _target.dismissVisualOnly)
_target.dismissVisualOnly(); _target.dismissVisualOnly();
if (_isLast) if (_isLast)
_bulkTimer.createObject(menuWindow, {}); root._bulkTimer.createObject(root, {});
destroy(); destroy();
} }
} }
@ -206,36 +148,28 @@ M.HoverPanel {
property Component _bulkTimer: Component { property Component _bulkTimer: Component {
Timer { Timer {
interval: 400 // swipe (200) + collapse (150) + margin interval: 400
running: true running: true
onTriggered: { onTriggered: {
menuWindow._finishCascade(); root._finishCascade();
destroy(); destroy();
} }
} }
} }
// Separator // Notification list
Rectangle {
width: menuWindow.contentWidth - 16
height: 1
anchors.horizontalCenter: parent.horizontalCenter
color: S.Theme.base03
}
// Notification list (scrollable)
ListView { ListView {
id: notifList id: _notifList
width: menuWindow.contentWidth width: root.contentWidth
height: Math.min(contentHeight, 60 * (S.Modules.notifications.maxVisible || 10)) height: Math.min(contentHeight, 60 * (S.Modules.notifications.maxVisible || 10))
clip: true clip: true
boundsBehavior: Flickable.StopAtBounds boundsBehavior: Flickable.StopAtBounds
model: menuWindow._flatModel model: root._flatModel
onContentHeightChanged: { onContentHeightChanged: {
if (menuWindow._restoringScroll) { if (root._restoringScroll) {
contentY = Math.min(menuWindow._savedScrollY, Math.max(0, contentHeight - height)); contentY = Math.min(root._savedScrollY, Math.max(0, contentHeight - height));
menuWindow._restoringScroll = false; root._restoringScroll = false;
} }
} }
@ -247,7 +181,7 @@ M.HoverPanel {
readonly property string _type: modelData.type readonly property string _type: modelData.type
readonly property var _notif: _type === "notif" ? modelData.data : null readonly property var _notif: _type === "notif" ? modelData.data : null
width: menuWindow.contentWidth width: root.contentWidth
height: _displayTargetHeight * _heightScale height: _displayTargetHeight * _heightScale
clip: true clip: true
opacity: 0 opacity: 0
@ -258,7 +192,6 @@ M.HoverPanel {
return _notifCard.implicitHeight; return _notifCard.implicitHeight;
} }
// Animated version of _targetHeight smoothly transitions header height on collapse
property real _displayTargetHeight: _targetHeight property real _displayTargetHeight: _targetHeight
Behavior on _displayTargetHeight { Behavior on _displayTargetHeight {
NumberAnimation { NumberAnimation {
@ -305,7 +238,6 @@ M.HoverPanel {
id: _headerHover id: _headerHover
} }
// Tap target for collapse covers header row only, excludes dismiss button
Item { Item {
anchors.left: parent.left anchors.left: parent.left
anchors.right: _groupDismissBtn.left anchors.right: _groupDismissBtn.left
@ -316,7 +248,7 @@ M.HoverPanel {
cursorShape: Qt.PointingHandCursor cursorShape: Qt.PointingHandCursor
} }
TapHandler { TapHandler {
onTapped: menuWindow._toggleCollapse(notifDelegate.modelData.appName) onTapped: root._toggleCollapse(notifDelegate.modelData.appName)
} }
} }
@ -342,7 +274,6 @@ M.HoverPanel {
asynchronous: true asynchronous: true
} }
// Collapse chevron
Text { Text {
id: _chevron id: _chevron
anchors.right: _groupDismissBtn.left anchors.right: _groupDismissBtn.left
@ -357,7 +288,6 @@ M.HoverPanel {
opacity: _headerHover.hovered ? 1 : 0 opacity: _headerHover.hovered ? 1 : 0
} }
// App name
Text { Text {
anchors.left: _headerIcon.visible ? _headerIcon.right : parent.left anchors.left: _headerIcon.visible ? _headerIcon.right : parent.left
anchors.leftMargin: _headerIcon.visible ? 6 : 10 anchors.leftMargin: _headerIcon.visible ? 6 : 10
@ -374,7 +304,6 @@ M.HoverPanel {
elide: Text.ElideRight elide: Text.ElideRight
} }
// Dismiss button opacity-hidden when header not hovered
Text { Text {
id: _groupDismissBtn id: _groupDismissBtn
anchors.right: parent.right anchors.right: parent.right
@ -396,12 +325,11 @@ M.HoverPanel {
TapHandler { TapHandler {
onTapped: { onTapped: {
if (notifDelegate._type === "header") if (notifDelegate._type === "header")
menuWindow._cascadeGroupDismiss(notifDelegate.modelData.appName); root._cascadeGroupDismiss(notifDelegate.modelData.appName);
} }
} }
} }
// Collapsed preview: one line per notification summary
Repeater { Repeater {
model: (notifDelegate._type === "header" && notifDelegate.modelData.collapsed) ? notifDelegate.modelData.summaries : [] model: (notifDelegate._type === "header" && notifDelegate.modelData.collapsed) ? notifDelegate.modelData.summaries : []
@ -425,7 +353,6 @@ M.HoverPanel {
} }
// ---- Individual notification ---- // ---- Individual notification ----
M.NotifCard { M.NotifCard {
id: _notifCard id: _notifCard
visible: notifDelegate._type === "notif" visible: notifDelegate._type === "notif"
@ -445,7 +372,7 @@ M.HoverPanel {
NumberAnimation { NumberAnimation {
target: notifDelegate target: notifDelegate
property: "x" property: "x"
to: menuWindow.contentWidth to: root.contentWidth
duration: 200 duration: 200
easing.type: Easing.InCubic easing.type: Easing.InCubic
} }
@ -477,7 +404,7 @@ M.HoverPanel {
// Empty state // Empty state
Text { Text {
visible: S.NotifService.count === 0 visible: S.NotifService.count === 0
width: menuWindow.contentWidth width: root.contentWidth
height: 48 height: 48
horizontalAlignment: Text.AlignHCenter horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter verticalAlignment: Text.AlignVCenter

View file

@ -10,6 +10,7 @@ HexWaveBackground 1.0 HexWaveBackground.qml
MemoryApplet 1.0 MemoryApplet.qml MemoryApplet 1.0 MemoryApplet.qml
MprisApplet 1.0 MprisApplet.qml MprisApplet 1.0 MprisApplet.qml
NetworkApplet 1.0 NetworkApplet.qml NetworkApplet 1.0 NetworkApplet.qml
NotifApplet 1.0 NotifApplet.qml
TemperatureApplet 1.0 TemperatureApplet.qml TemperatureApplet 1.0 TemperatureApplet.qml
VolumeApplet 1.0 VolumeApplet.qml VolumeApplet 1.0 VolumeApplet.qml
WeatherApplet 1.0 WeatherApplet.qml WeatherApplet 1.0 WeatherApplet.qml

View file

@ -16,7 +16,8 @@ WlSessionLockSurface {
property real _unlockFade: 1 property real _unlockFade: 1
// Threat level: 0.0-1.0 based on fail count (5 fails = max) // Threat level: 0.0-1.0 based on fail count (5 fails = max)
readonly property real _threat: Math.min(1.0, root.auth.failCount / 5) readonly property bool _threatEnabled: S.Modules.lock.threatEffect ?? true
readonly property real _threat: _threatEnabled ? Math.min(1.0, root.auth.failCount / 5) : 0
property real _heartbeat: 0 property real _heartbeat: 0
// All visual content wrapped for threat shader // All visual content wrapped for threat shader
@ -321,7 +322,7 @@ WlSessionLockSurface {
SequentialAnimation { SequentialAnimation {
id: _heartbeatAnim id: _heartbeatAnim
loops: Animation.Infinite loops: Animation.Infinite
running: root.auth.failCount >= 3 && root.lock.secure running: root._threatEnabled && root.auth.failCount >= 3 && root.lock.secure
// Systole (sharp spike) // Systole (sharp spike)
NumberAnimation { NumberAnimation {

View file

@ -114,7 +114,6 @@ PanelWindow {
visible: S.Modules.clock.enable visible: S.Modules.clock.enable
} }
M.NotificationsModule { M.NotificationsModule {
bar: bar
visible: S.Modules.notifications.enable visible: S.Modules.notifications.enable
} }
} }

View file

@ -3,21 +3,32 @@ import Quickshell
import Quickshell.Services.Notifications import Quickshell.Services.Notifications
import "." as M import "." as M
import "../services" as S import "../services" as S
import "../applets" as C
M.BarSection { M.BarSection {
id: root id: root
spacing: S.Theme.moduleSpacing spacing: S.Theme.moduleSpacing
tooltip: { tooltip: ""
const parts = [S.NotifService.count + " notification" + (S.NotifService.count !== 1 ? "s" : "")];
if (S.NotifService.dnd)
parts.push("Do not disturb");
return parts.join("\n");
}
required property var bar
readonly property bool hasUrgent: S.NotifService.list.some(n => n.urgency === NotificationUrgency.Critical && n.state !== "dismissed") readonly property bool hasUrgent: S.NotifService.list.some(n => n.urgency === NotificationUrgency.Critical && n.state !== "dismissed")
property bool _pinned: false
readonly property bool _anyHover: root._hovered || hoverPanel.panelHovered
readonly property bool _showPanel: _anyHover || _pinned
on_AnyHoverChanged: {
if (_anyHover)
_unpinTimer.stop();
else if (_pinned)
_unpinTimer.start();
}
Timer {
id: _unpinTimer
interval: 500
onTriggered: root._pinned = false
}
M.BarIcon { M.BarIcon {
icon: { icon: {
if (S.NotifService.dnd) if (S.NotifService.dnd)
@ -26,12 +37,18 @@ M.BarSection {
} }
color: S.NotifService.dnd ? S.Theme.base04 : root.accentColor color: S.NotifService.dnd ? S.Theme.base04 : root.accentColor
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
TapHandler {
onTapped: root._pinned = !root._pinned
}
} }
M.BarLabel { M.BarLabel {
id: countLabel id: countLabel
label: S.NotifService.count > 0 ? String(S.NotifService.count) + (root.hasUrgent ? "!" : "") : "" label: S.NotifService.count > 0 ? String(S.NotifService.count) + (root.hasUrgent ? "!" : "") : ""
color: root.hasUrgent ? S.Theme.base08 : root.accentColor color: root.hasUrgent ? S.Theme.base08 : root.accentColor
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
TapHandler {
onTapped: root._pinned = !root._pinned
}
transform: Scale { transform: Scale {
id: countScale id: countScale
@ -68,26 +85,66 @@ M.BarSection {
} }
} }
TapHandler { // Right-click DND quick toggle
acceptedButtons: Qt.LeftButton
onTapped: {
centerLoader.active = !centerLoader.active;
M.FlyoutState.visible = false;
}
}
TapHandler { TapHandler {
acceptedButtons: Qt.RightButton acceptedButtons: Qt.RightButton
onTapped: S.NotifService.toggleDnd() onTapped: S.NotifService.toggleDnd()
} }
LazyLoader { M.HoverPanel {
id: centerLoader id: hoverPanel
active: false showPanel: root._showPanel
M.NotifCenter { screen: QsWindow.window?.screen ?? null
anchorItem: root
accentColor: root.accentColor
panelNamespace: "nova-notifications"
panelTitle: "Notifications"
contentWidth: 350
titleActionsComponent: Component {
Row {
spacing: 8
// DND toggle
Text {
text: S.NotifService.dnd ? "\uDB82\uDE93" : "\uDB80\uDC9C"
color: S.NotifService.dnd ? S.Theme.base09 : S.Theme.base04
font.pixelSize: S.Theme.fontSize
font.family: S.Theme.iconFontFamily
anchors.verticalCenter: parent.verticalCenter
HoverHandler {
cursorShape: Qt.PointingHandCursor
}
TapHandler {
onTapped: S.NotifService.toggleDnd()
}
}
// Clear all
Text {
text: "\uF1F8"
color: _clearHover.hovered ? S.Theme.base08 : S.Theme.base04
font.pixelSize: S.Theme.fontSize
font.family: S.Theme.iconFontFamily
anchors.verticalCenter: parent.verticalCenter
visible: S.NotifService.count > 0
HoverHandler {
id: _clearHover
cursorShape: Qt.PointingHandCursor
}
TapHandler {
onTapped: _notifApplet.cascadeDismiss()
}
}
}
}
C.NotifApplet {
id: _notifApplet
width: hoverPanel.contentWidth
contentWidth: hoverPanel.contentWidth
accentColor: root.accentColor accentColor: root.accentColor
screen: root.bar.screen
anchorX: root.mapToGlobal(root.width / 2, 0).x - (QsWindow.window?.screen?.x ?? 0)
onDismissed: centerLoader.active = false
} }
} }
} }

View file

@ -20,7 +20,6 @@ MemoryModule 1.0 MemoryModule.qml
MprisModule 1.0 MprisModule.qml MprisModule 1.0 MprisModule.qml
NetworkModule 1.0 NetworkModule.qml NetworkModule 1.0 NetworkModule.qml
NotifCard 1.0 NotifCard.qml NotifCard 1.0 NotifCard.qml
NotifCenter 1.0 NotifCenter.qml
NotifPopup 1.0 NotifPopup.qml NotifPopup 1.0 NotifPopup.qml
NotificationsModule 1.0 NotificationsModule.qml NotificationsModule 1.0 NotificationsModule.qml
OverviewBackdrop 1.0 OverviewBackdrop.qml OverviewBackdrop 1.0 OverviewBackdrop.qml

View file

@ -99,7 +99,8 @@ QtObject {
notifications: true, notifications: true,
mpris: true, mpris: true,
volume: true, volume: true,
weather: true weather: true,
threatEffect: true
}) })
property var statsDaemon: ({ property var statsDaemon: ({
interval: -1 interval: -1