Compare commits

...

3 commits

Author SHA1 Message Date
Damocles
5b901478c7 readme: shorter, meaner feature list 2026-04-13 16:13:21 +02:00
Damocles
889bea3688 update README: notifications, shader 2026-04-13 16:10:42 +02:00
Damocles
29f14a72f0 notification center: scrollable list, configurable maxVisible (default 10) 2026-04-13 16:07:28 +02:00
4 changed files with 258 additions and 229 deletions

View file

@ -11,21 +11,20 @@ architectural decision, which is exactly when you should be most suspicious.
## "Features" ## "Features"
- Bar with workspaces, window title, clock, tray, and a regrettable number of widgets grouped into color-coded sections with glowing borders, because subtlety is dead You didn't ask for most of these. Neither did anyone else.
- Niri IPC integration — workspace indicator, focused window title with app icon, power menu with `niri msg action quit`. All event-driven, no polling
- Interactive hover panels — volume (with per-app mixer and output device switcher), brightness (with slider), media (with album art, transport controls, progress bar). Hover to peek, click to expand, leave to dismiss. The OSD and tooltip in one, because having three separate UI patterns for the same information was too reasonable - Status bar with too many widgets, grouped into glowing color-coded sections
- Context menus for tray icons with submenu support, network chooser (known available WiFi/ethernet), bluetooth device manager (connect/disconnect, battery levels) - Notification center that replaces swaync (whether you wanted that or not)
- Privacy indicators — screenshare and microphone icons pulse red/green when PipeWire detects active video/audio capture streams. Finally, you'll know when your webcam is on, which is more than can be said for most laptop manufacturers - Hover panels for volume, brightness, and media — the robot merged the OSD, tooltip, and mixer into one thing because it couldn't be stopped
- Per-module accent colors that change based on state, with animated transitions. Battery blinks when critical and sends desktop notifications, because the robot cares about your hardware more than you do - Network/bluetooth/tray context menus, power menu, idle inhibitor
- Audio visualizer on album art via cava — because the robot watched too many r/unixporn posts and couldn't help itself - Privacy indicators for when your webcam is silently recording you
- Screen corner rounding — tiny overlay windows with quarter-circle masks, click-transparent, configurable via `screenRadius`. The gradient top border curves to match, because the robot has opinions about pixel alignment - GPU-rendered hexagonal backdrop for niri overview, complete with wave animations and rainbow shimmer, because the robot thinks your desktop should look like a cyberpunk hacker terminal
- Background overlay — clock and date rendered on the background layer, visible behind windows and in niri overview gaps. Always there, never in the way - Neon clock on the background layer with a color-cycling colon. You read that correctly
- Weather via wttrbar with configurable arguments and rich HTML tooltips - Audio visualizer on album art via cava
- Power menu with lock, suspend, logout, reboot, shutdown - Screen corner rounding that the bar's edge modules actually follow
- Event-driven updates for network, bluetooth, and power profiles via dbus-monitor/nmcli monitor — no more 5-second polling lag - Everything is animated. Everything. The robot does not know restraint
- Animated everything: flyout tooltips slide in/out, modules fade on visibility change, icons crossfade on state change, notification count pops. The bar is basically a screensaver at this point - Home Manager module with stylix, per-module config, hot-reload — the only part that arguably works as intended
- Home Manager module with stylix integration, per-module config objects (enable/disable + module-specific settings like polling intervals, thresholds, brightness step), and a theme system that hot-reloads - No documentation beyond this README. Good luck
- treefmt + nixfmt for formatting, because even AI slop deserves consistent indentation
## Installation ## Installation
@ -89,6 +88,9 @@ programs.nova-shell.modules = {
battery.warning = 30; # % for warning notification battery.warning = 30; # % for warning notification
battery.critical = 10; # % for critical blink + notification battery.critical = 10; # % for critical blink + notification
cpu.interval = 2000; # polling interval in ms cpu.interval = 2000; # polling interval in ms
notifications.timeout = 3000; # popup auto-dismiss in ms
notifications.maxPopups = 4; # max simultaneous popups (0 to disable)
notifications.maxVisible = 10; # scrollable history limit in center
}; };
``` ```
@ -96,7 +98,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`, `cpu`, `memory`, `powerProfile`, `idleInhibitor`, `weather`, `temperature`, `cpu`, `memory`,
`disk`, `battery`, `power`. `disk`, `battery`, `power`, `backgroundOverlay`, `overviewBackdrop`.
### Theme ### Theme

View file

@ -22,7 +22,8 @@ QtObject {
property var notifications: ({ property var notifications: ({
enable: true, enable: true,
timeout: 3000, timeout: 3000,
maxPopups: 4 maxPopups: 4,
maxVisible: 10
}) })
property var mpris: ({ property var mpris: ({
enable: true enable: true

View file

@ -131,240 +131,261 @@ M.PopupPanel {
color: M.Theme.base03 color: M.Theme.base03
} }
// Notification list // Notification list (scrollable)
Repeater { Item {
model: M.NotifService.list.slice(0, 20) width: menuWindow.panelWidth
height: Math.min(notifFlick.contentHeight, _maxHeight)
readonly property real _itemHeight: 60
readonly property real _maxHeight: _itemHeight * (M.Modules.notifications.maxVisible || 10)
delegate: Item { Flickable {
id: notifItem id: notifFlick
required property var modelData anchors.fill: parent
required property int index contentWidth: width
contentHeight: notifCol.implicitHeight
width: menuWindow.panelWidth
height: _targetHeight * _heightScale
opacity: 0
clip: true clip: true
boundsBehavior: Flickable.StopAtBounds
readonly property real _targetHeight: notifContent.height + 12
property real _heightScale: 1
property bool _skipDismiss: false
function dismiss() {
_dismissAnim.start();
}
function dismissVisualOnly() {
_skipDismiss = true;
_dismissAnim.start();
}
Component.onCompleted: {
menuWindow._delegates.push(notifItem);
fadeIn.start();
}
Component.onDestruction: {
const idx = menuWindow._delegates.indexOf(notifItem);
if (idx >= 0)
menuWindow._delegates.splice(idx, 1);
}
NumberAnimation {
id: fadeIn
target: notifItem
property: "opacity"
to: 1
duration: 150
easing.type: Easing.OutCubic
}
Rectangle {
anchors.fill: parent
anchors.leftMargin: 4
anchors.rightMargin: 4
color: notifArea.containsMouse ? M.Theme.base02 : "transparent"
radius: M.Theme.radius
}
// Urgency accent
Rectangle {
anchors.left: parent.left
anchors.leftMargin: 4
anchors.top: parent.top
anchors.topMargin: 4
anchors.bottom: parent.bottom
anchors.bottomMargin: 4
width: 2
radius: 1
color: {
const u = notifItem.modelData.urgency;
return u === NotificationUrgency.Critical ? M.Theme.base08 : u === NotificationUrgency.Low ? M.Theme.base04 : M.Theme.base0D;
}
}
Column { Column {
id: notifContent id: notifCol
anchors.left: parent.left width: parent.width
anchors.right: dismissBtn.left
anchors.top: parent.top
anchors.leftMargin: 14
anchors.topMargin: 6
spacing: 1
// App + time Repeater {
Row { model: M.NotifService.list
width: parent.width
Text { delegate: Item {
text: notifItem.modelData.appName || "Notification" id: notifItem
color: M.Theme.base04 required property var modelData
font.pixelSize: M.Theme.fontSize - 2 required property int index
font.family: M.Theme.fontFamily
elide: Text.ElideRight width: menuWindow.panelWidth
width: parent.width - ageText.width - 4 height: _targetHeight * _heightScale
} opacity: 0
Text { clip: true
id: ageText
text: { readonly property real _targetHeight: notifContent.height + 12
const diff = Math.floor((Date.now() - notifItem.modelData.time) / 60000); property real _heightScale: 1
if (diff < 1) property bool _skipDismiss: false
return "now";
if (diff < 60) function dismiss() {
return diff + "m"; _dismissAnim.start();
if (diff < 1440)
return Math.floor(diff / 60) + "h";
return Math.floor(diff / 1440) + "d";
} }
color: M.Theme.base03
font.pixelSize: M.Theme.fontSize - 2
font.family: M.Theme.fontFamily
}
}
Text { function dismissVisualOnly() {
width: parent.width _skipDismiss = true;
text: notifItem.modelData.summary || "" _dismissAnim.start();
color: M.Theme.base05 }
font.pixelSize: M.Theme.fontSize
font.family: M.Theme.fontFamily
font.bold: true
elide: Text.ElideRight
}
Text { Component.onCompleted: {
width: parent.width menuWindow._delegates.push(notifItem);
text: notifItem.modelData.body || "" fadeIn.start();
color: M.Theme.base04 }
font.pixelSize: M.Theme.fontSize - 1 Component.onDestruction: {
font.family: M.Theme.fontFamily const idx = menuWindow._delegates.indexOf(notifItem);
wrapMode: Text.WordWrap if (idx >= 0)
maximumLineCount: 2 menuWindow._delegates.splice(idx, 1);
elide: Text.ElideRight }
visible: text !== ""
}
// Actions NumberAnimation {
Row { id: fadeIn
spacing: 4 target: notifItem
visible: notifItem.modelData.actions && notifItem.modelData.actions.length > 0 property: "opacity"
to: 1
duration: 150
easing.type: Easing.OutCubic
}
Repeater { Rectangle {
model: notifItem.modelData.actions || [] anchors.fill: parent
delegate: Rectangle { anchors.leftMargin: 4
required property var modelData anchors.rightMargin: 4
width: actText.implicitWidth + 10 color: notifArea.containsMouse ? M.Theme.base02 : "transparent"
height: actText.implicitHeight + 4
radius: M.Theme.radius radius: M.Theme.radius
color: actArea.containsMouse ? M.Theme.base02 : "transparent" }
border.color: M.Theme.base03
border.width: 1 // Urgency accent
Rectangle {
anchors.left: parent.left
anchors.leftMargin: 4
anchors.top: parent.top
anchors.topMargin: 4
anchors.bottom: parent.bottom
anchors.bottomMargin: 4
width: 2
radius: 1
color: {
const u = notifItem.modelData.urgency;
return u === NotificationUrgency.Critical ? M.Theme.base08 : u === NotificationUrgency.Low ? M.Theme.base04 : M.Theme.base0D;
}
}
Column {
id: notifContent
anchors.left: parent.left
anchors.right: dismissBtn.left
anchors.top: parent.top
anchors.leftMargin: 14
anchors.topMargin: 6
spacing: 1
// App + time
Row {
width: parent.width
Text {
text: notifItem.modelData.appName || "Notification"
color: M.Theme.base04
font.pixelSize: M.Theme.fontSize - 2
font.family: M.Theme.fontFamily
elide: Text.ElideRight
width: parent.width - ageText.width - 4
}
Text {
id: ageText
text: {
const diff = Math.floor((Date.now() - notifItem.modelData.time) / 60000);
if (diff < 1)
return "now";
if (diff < 60)
return diff + "m";
if (diff < 1440)
return Math.floor(diff / 60) + "h";
return Math.floor(diff / 1440) + "d";
}
color: M.Theme.base03
font.pixelSize: M.Theme.fontSize - 2
font.family: M.Theme.fontFamily
}
}
Text { Text {
id: actText width: parent.width
anchors.centerIn: parent text: notifItem.modelData.summary || ""
text: parent.modelData.text color: M.Theme.base05
color: M.Theme.base0D font.pixelSize: M.Theme.fontSize
font.pixelSize: M.Theme.fontSize - 2
font.family: M.Theme.fontFamily font.family: M.Theme.fontFamily
font.bold: true
elide: Text.ElideRight
} }
MouseArea {
id: actArea Text {
anchors.fill: parent width: parent.width
hoverEnabled: true text: notifItem.modelData.body || ""
cursorShape: Qt.PointingHandCursor color: M.Theme.base04
onClicked: { font.pixelSize: M.Theme.fontSize - 1
parent.modelData.invoke(); font.family: M.Theme.fontFamily
M.NotifService.dismiss(notifItem.modelData.id); wrapMode: Text.WordWrap
maximumLineCount: 2
elide: Text.ElideRight
visible: text !== ""
}
// Actions
Row {
spacing: 4
visible: notifItem.modelData.actions && notifItem.modelData.actions.length > 0
Repeater {
model: notifItem.modelData.actions || []
delegate: Rectangle {
required property var modelData
width: actText.implicitWidth + 10
height: actText.implicitHeight + 4
radius: M.Theme.radius
color: actArea.containsMouse ? M.Theme.base02 : "transparent"
border.color: M.Theme.base03
border.width: 1
Text {
id: actText
anchors.centerIn: parent
text: parent.modelData.text
color: M.Theme.base0D
font.pixelSize: M.Theme.fontSize - 2
font.family: M.Theme.fontFamily
}
MouseArea {
id: actArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
parent.modelData.invoke();
M.NotifService.dismiss(notifItem.modelData.id);
}
}
}
} }
} }
} }
}
}
}
// Dismiss button // Dismiss button
Text { Text {
id: dismissBtn id: dismissBtn
anchors.right: parent.right anchors.right: parent.right
anchors.rightMargin: 10 anchors.rightMargin: 10
anchors.top: parent.top anchors.top: parent.top
anchors.topMargin: 8 anchors.topMargin: 8
text: "\uF00D" text: "\uF00D"
color: dismissArea.containsMouse ? M.Theme.base08 : M.Theme.base03 color: dismissArea.containsMouse ? M.Theme.base08 : M.Theme.base03
font.pixelSize: M.Theme.fontSize - 1 font.pixelSize: M.Theme.fontSize - 1
font.family: M.Theme.iconFontFamily font.family: M.Theme.iconFontFamily
MouseArea { MouseArea {
id: dismissArea id: dismissArea
anchors.fill: parent anchors.fill: parent
anchors.margins: -4 anchors.margins: -4
hoverEnabled: true hoverEnabled: true
cursorShape: Qt.PointingHandCursor cursorShape: Qt.PointingHandCursor
onClicked: _dismissAnim.start() onClicked: _dismissAnim.start()
} }
} }
SequentialAnimation { SequentialAnimation {
id: _dismissAnim id: _dismissAnim
ParallelAnimation { ParallelAnimation {
NumberAnimation { NumberAnimation {
target: notifItem target: notifItem
property: "x" property: "x"
to: menuWindow.panelWidth to: menuWindow.panelWidth
duration: 200 duration: 200
easing.type: Easing.InCubic easing.type: Easing.InCubic
} }
NumberAnimation { NumberAnimation {
target: notifItem target: notifItem
property: "opacity" property: "opacity"
to: 0 to: 0
duration: 200 duration: 200
easing.type: Easing.InCubic easing.type: Easing.InCubic
} }
} }
NumberAnimation { NumberAnimation {
target: notifItem target: notifItem
property: "_heightScale" property: "_heightScale"
to: 0 to: 0
duration: 150 duration: 150
easing.type: Easing.OutCubic easing.type: Easing.OutCubic
} }
ScriptAction { ScriptAction {
script: { script: {
if (!notifItem._skipDismiss) if (!notifItem._skipDismiss)
M.NotifService.dismiss(notifItem.modelData.id); M.NotifService.dismiss(notifItem.modelData.id);
} }
} }
} }
MouseArea { MouseArea {
id: notifArea id: notifArea
anchors.fill: parent anchors.fill: parent
z: -1 z: -1
hoverEnabled: true hoverEnabled: true
acceptedButtons: Qt.RightButton acceptedButtons: Qt.RightButton
onClicked: _dismissAnim.start() onClicked: _dismissAnim.start()
} }
} }
} } // Repeater
} // Column
} // Flickable
} // Item
// Empty state // Empty state
Text { Text {

View file

@ -103,6 +103,11 @@ in
default = 4; default = 4;
description = "Maximum number of notification popups shown simultaneously."; description = "Maximum number of notification popups shown simultaneously.";
}; };
maxVisible = lib.mkOption {
type = lib.types.int;
default = 10;
description = "Maximum visible notifications in the notification center before scrolling.";
};
}; };
bluetooth = moduleOpt "bluetooth" (intervalOpt 5000); bluetooth = moduleOpt "bluetooth" (intervalOpt 5000);
network = moduleOpt "network" (intervalOpt 5000); network = moduleOpt "network" (intervalOpt 5000);