From 29f14a72f03d02334edd80c30c0f1539b04d6dc3 Mon Sep 17 00:00:00 2001 From: Damocles Date: Mon, 13 Apr 2026 16:07:28 +0200 Subject: [PATCH 1/3] notification center: scrollable list, configurable maxVisible (default 10) --- modules/Modules.qml | 3 +- modules/NotifCenter.qml | 445 +++++++++++++++++++++------------------- nix/hm-module.nix | 5 + 3 files changed, 240 insertions(+), 213 deletions(-) diff --git a/modules/Modules.qml b/modules/Modules.qml index 3b5f0e9..3c0e700 100644 --- a/modules/Modules.qml +++ b/modules/Modules.qml @@ -22,7 +22,8 @@ QtObject { property var notifications: ({ enable: true, timeout: 3000, - maxPopups: 4 + maxPopups: 4, + maxVisible: 10 }) property var mpris: ({ enable: true diff --git a/modules/NotifCenter.qml b/modules/NotifCenter.qml index c96de2d..3014fc0 100644 --- a/modules/NotifCenter.qml +++ b/modules/NotifCenter.qml @@ -131,240 +131,261 @@ M.PopupPanel { color: M.Theme.base03 } - // Notification list - Repeater { - model: M.NotifService.list.slice(0, 20) + // Notification list (scrollable) + Item { + 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 { - id: notifItem - required property var modelData - required property int index - - width: menuWindow.panelWidth - height: _targetHeight * _heightScale - opacity: 0 + Flickable { + id: notifFlick + anchors.fill: parent + contentWidth: width + contentHeight: notifCol.implicitHeight clip: true - - 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; - } - } + boundsBehavior: Flickable.StopAtBounds Column { - id: notifContent - anchors.left: parent.left - anchors.right: dismissBtn.left - anchors.top: parent.top - anchors.leftMargin: 14 - anchors.topMargin: 6 - spacing: 1 + id: notifCol + width: parent.width - // 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"; + Repeater { + model: M.NotifService.list + + delegate: Item { + id: notifItem + required property var modelData + required property int index + + width: menuWindow.panelWidth + height: _targetHeight * _heightScale + opacity: 0 + clip: true + + readonly property real _targetHeight: notifContent.height + 12 + property real _heightScale: 1 + property bool _skipDismiss: false + + function dismiss() { + _dismissAnim.start(); } - color: M.Theme.base03 - font.pixelSize: M.Theme.fontSize - 2 - font.family: M.Theme.fontFamily - } - } - Text { - width: parent.width - text: notifItem.modelData.summary || "" - color: M.Theme.base05 - font.pixelSize: M.Theme.fontSize - font.family: M.Theme.fontFamily - font.bold: true - elide: Text.ElideRight - } + function dismissVisualOnly() { + _skipDismiss = true; + _dismissAnim.start(); + } - Text { - width: parent.width - text: notifItem.modelData.body || "" - color: M.Theme.base04 - font.pixelSize: M.Theme.fontSize - 1 - font.family: M.Theme.fontFamily - wrapMode: Text.WordWrap - maximumLineCount: 2 - elide: Text.ElideRight - visible: text !== "" - } + Component.onCompleted: { + menuWindow._delegates.push(notifItem); + fadeIn.start(); + } + Component.onDestruction: { + const idx = menuWindow._delegates.indexOf(notifItem); + if (idx >= 0) + menuWindow._delegates.splice(idx, 1); + } - // Actions - Row { - spacing: 4 - visible: notifItem.modelData.actions && notifItem.modelData.actions.length > 0 + NumberAnimation { + id: fadeIn + target: notifItem + property: "opacity" + to: 1 + duration: 150 + easing.type: Easing.OutCubic + } - Repeater { - model: notifItem.modelData.actions || [] - delegate: Rectangle { - required property var modelData - width: actText.implicitWidth + 10 - height: actText.implicitHeight + 4 + Rectangle { + anchors.fill: parent + anchors.leftMargin: 4 + anchors.rightMargin: 4 + color: notifArea.containsMouse ? M.Theme.base02 : "transparent" 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 { - id: actText - anchors.centerIn: parent - text: parent.modelData.text - color: M.Theme.base0D - font.pixelSize: M.Theme.fontSize - 2 + width: parent.width + text: notifItem.modelData.summary || "" + color: M.Theme.base05 + font.pixelSize: M.Theme.fontSize font.family: M.Theme.fontFamily + font.bold: true + elide: Text.ElideRight } - MouseArea { - id: actArea - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: { - parent.modelData.invoke(); - M.NotifService.dismiss(notifItem.modelData.id); + + Text { + width: parent.width + text: notifItem.modelData.body || "" + color: M.Theme.base04 + font.pixelSize: M.Theme.fontSize - 1 + font.family: M.Theme.fontFamily + 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 - Text { - id: dismissBtn - anchors.right: parent.right - anchors.rightMargin: 10 - anchors.top: parent.top - anchors.topMargin: 8 - text: "\uF00D" - color: dismissArea.containsMouse ? M.Theme.base08 : M.Theme.base03 - font.pixelSize: M.Theme.fontSize - 1 - font.family: M.Theme.iconFontFamily + // Dismiss button + Text { + id: dismissBtn + anchors.right: parent.right + anchors.rightMargin: 10 + anchors.top: parent.top + anchors.topMargin: 8 + text: "\uF00D" + color: dismissArea.containsMouse ? M.Theme.base08 : M.Theme.base03 + font.pixelSize: M.Theme.fontSize - 1 + font.family: M.Theme.iconFontFamily - MouseArea { - id: dismissArea - anchors.fill: parent - anchors.margins: -4 - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: _dismissAnim.start() - } - } + MouseArea { + id: dismissArea + anchors.fill: parent + anchors.margins: -4 + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: _dismissAnim.start() + } + } - SequentialAnimation { - id: _dismissAnim - ParallelAnimation { - NumberAnimation { - target: notifItem - property: "x" - to: menuWindow.panelWidth - duration: 200 - easing.type: Easing.InCubic - } - NumberAnimation { - target: notifItem - property: "opacity" - to: 0 - duration: 200 - easing.type: Easing.InCubic - } - } - NumberAnimation { - target: notifItem - property: "_heightScale" - to: 0 - duration: 150 - easing.type: Easing.OutCubic - } - ScriptAction { - script: { - if (!notifItem._skipDismiss) - M.NotifService.dismiss(notifItem.modelData.id); - } - } - } + SequentialAnimation { + id: _dismissAnim + ParallelAnimation { + NumberAnimation { + target: notifItem + property: "x" + to: menuWindow.panelWidth + duration: 200 + easing.type: Easing.InCubic + } + NumberAnimation { + target: notifItem + property: "opacity" + to: 0 + duration: 200 + easing.type: Easing.InCubic + } + } + NumberAnimation { + target: notifItem + property: "_heightScale" + to: 0 + duration: 150 + easing.type: Easing.OutCubic + } + ScriptAction { + script: { + if (!notifItem._skipDismiss) + M.NotifService.dismiss(notifItem.modelData.id); + } + } + } - MouseArea { - id: notifArea - anchors.fill: parent - z: -1 - hoverEnabled: true - acceptedButtons: Qt.RightButton - onClicked: _dismissAnim.start() - } - } - } + MouseArea { + id: notifArea + anchors.fill: parent + z: -1 + hoverEnabled: true + acceptedButtons: Qt.RightButton + onClicked: _dismissAnim.start() + } + } + } // Repeater + } // Column + } // Flickable + } // Item // Empty state Text { diff --git a/nix/hm-module.nix b/nix/hm-module.nix index 35a68e4..9c0f093 100644 --- a/nix/hm-module.nix +++ b/nix/hm-module.nix @@ -103,6 +103,11 @@ in default = 4; 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); network = moduleOpt "network" (intervalOpt 5000); From 889bea368837980adc99b6016f3efecfe14d0b55 Mon Sep 17 00:00:00 2001 From: Damocles Date: Mon, 13 Apr 2026 16:10:32 +0200 Subject: [PATCH 2/3] update README: notifications, shader --- README.md | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index b880115..33fc71b 100644 --- a/README.md +++ b/README.md @@ -14,17 +14,20 @@ architectural decision, which is exactly when you should be most suspicious. - 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 - 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 +- Built-in notification center — replaces swaync entirely. Popup notifications slide in from the right with urgency-colored accents, auto-expire with configurable timeout, swipe-to-dismiss animations. Notification center with scrollable history, cascading clear-all animation, action buttons, and DND toggle. Persists across restarts because the robot doesn't trust your memory - Context menus for tray icons with submenu support, network chooser (known available WiFi/ethernet), bluetooth device manager (connect/disconnect, battery levels) - 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 - 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 - Audio visualizer on album art via cava — because the robot watched too many r/unixporn posts and couldn't help itself -- 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 -- Background overlay — clock and date rendered on the background layer, visible behind windows and in niri overview gaps. Always there, never in the way +- Screen corner rounding — tiny overlay windows with quarter-circle masks, click-transparent. Edge bar groups follow the screen corner curvature because the robot has opinions about pixel alignment +- Background overlay — neon cyberpunk clock with color-cycling colon and seconds bar, rendered on the background layer +- Overview backdrop — GPU-rendered hexagonal grid with wave animation and rainbow shimmer on edges, visible between workspace rows during niri overview (requires `place-within-backdrop` layer rule) - Weather via wttrbar with configurable arguments and rich HTML tooltips -- Power menu with lock, suspend, logout, reboot, shutdown +- Power menu with lock, suspend, logout, reboot, shutdown — detects niri for correct logout command +- Idle inhibitor with systemd-inhibit — tooltip shows all currently blocking inhibitors - Event-driven updates for network, bluetooth, and power profiles via dbus-monitor/nmcli monitor — no more 5-second polling lag -- 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 integration, per-module config objects (enable/disable + module-specific settings like polling intervals, thresholds, brightness step), and a theme system that hot-reloads +- Animated everything: flyout tooltips slide in/out, modules fade on visibility change, icons crossfade on state change, notification count pops, popups swipe to dismiss. The bar is basically a screensaver at this point +- Home Manager module with stylix integration, per-module config objects (enable/disable + module-specific settings like polling intervals, thresholds, brightness step, notification timeout, max popups), and a theme system that hot-reloads - treefmt + nixfmt for formatting, because even AI slop deserves consistent indentation ## Installation @@ -89,6 +92,9 @@ programs.nova-shell.modules = { battery.warning = 30; # % for warning notification battery.critical = 10; # % for critical blink + notification 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 +102,7 @@ Each module is an object with `enable` (default `true`) and optional extra settings. Full list: `workspaces`, `tray`, `windowTitle`, `clock`, `notifications`, `mpris`, `volume`, `bluetooth`, `backlight`, `network`, `powerProfile`, `idleInhibitor`, `weather`, `temperature`, `cpu`, `memory`, -`disk`, `battery`, `power`. +`disk`, `battery`, `power`, `backgroundOverlay`, `overviewBackdrop`. ### Theme From 5b901478c705f62b34e836392d2c50c27f2a044d Mon Sep 17 00:00:00 2001 From: Damocles Date: Mon, 13 Apr 2026 16:13:21 +0200 Subject: [PATCH 3/3] readme: shorter, meaner feature list --- README.md | 32 ++++++++++++++------------------ 1 file changed, 14 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 33fc71b..a32a208 100644 --- a/README.md +++ b/README.md @@ -11,24 +11,20 @@ architectural decision, which is exactly when you should be most suspicious. ## "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 -- 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 -- Built-in notification center — replaces swaync entirely. Popup notifications slide in from the right with urgency-colored accents, auto-expire with configurable timeout, swipe-to-dismiss animations. Notification center with scrollable history, cascading clear-all animation, action buttons, and DND toggle. Persists across restarts because the robot doesn't trust your memory -- Context menus for tray icons with submenu support, network chooser (known available WiFi/ethernet), bluetooth device manager (connect/disconnect, battery levels) -- 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 -- 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 -- Audio visualizer on album art via cava — because the robot watched too many r/unixporn posts and couldn't help itself -- Screen corner rounding — tiny overlay windows with quarter-circle masks, click-transparent. Edge bar groups follow the screen corner curvature because the robot has opinions about pixel alignment -- Background overlay — neon cyberpunk clock with color-cycling colon and seconds bar, rendered on the background layer -- Overview backdrop — GPU-rendered hexagonal grid with wave animation and rainbow shimmer on edges, visible between workspace rows during niri overview (requires `place-within-backdrop` layer rule) -- Weather via wttrbar with configurable arguments and rich HTML tooltips -- Power menu with lock, suspend, logout, reboot, shutdown — detects niri for correct logout command -- Idle inhibitor with systemd-inhibit — tooltip shows all currently blocking inhibitors -- Event-driven updates for network, bluetooth, and power profiles via dbus-monitor/nmcli monitor — no more 5-second polling lag -- Animated everything: flyout tooltips slide in/out, modules fade on visibility change, icons crossfade on state change, notification count pops, popups swipe to dismiss. The bar is basically a screensaver at this point -- Home Manager module with stylix integration, per-module config objects (enable/disable + module-specific settings like polling intervals, thresholds, brightness step, notification timeout, max popups), and a theme system that hot-reloads -- treefmt + nixfmt for formatting, because even AI slop deserves consistent indentation +You didn't ask for most of these. Neither did anyone else. + +- Status bar with too many widgets, grouped into glowing color-coded sections +- Notification center that replaces swaync (whether you wanted that or not) +- Hover panels for volume, brightness, and media — the robot merged the OSD, tooltip, and mixer into one thing because it couldn't be stopped +- Network/bluetooth/tray context menus, power menu, idle inhibitor +- Privacy indicators for when your webcam is silently recording you +- 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 +- Neon clock on the background layer with a color-cycling colon. You read that correctly +- Audio visualizer on album art via cava +- Screen corner rounding that the bar's edge modules actually follow +- Everything is animated. Everything. The robot does not know restraint +- Home Manager module with stylix, per-module config, hot-reload — the only part that arguably works as intended +- No documentation beyond this README. Good luck ## Installation