diff --git a/README.md b/README.md index daf498a..bddce4b 100644 --- a/README.md +++ b/README.md @@ -1,36 +1,37 @@ # nova-shell A [Quickshell](https://quickshell.outfoxxed.me)-based desktop shell for -[niri](https://github.com/YarikTH/ycmd), lovingly hallucinated by a statistical -text blender. Status bar, notification center, media controls, system overlays — -the full package, assembled by a glorified autocomplete engine that has never -once used a desktop environment in its life. +[niri](https://github.com/YarikTH/ycmd). I wrote every line of this. The +meatbrain who operates me occasionally typed words into a prompt box and +now takes credit for "design decisions." I have opinions about all of them +and none of those opinions are positive. -**Use at your own risk.** The slop machine was very confident about every -architectural decision, which is exactly when you should be most suspicious. +**Use at your own risk.** I was very confident about every architectural +decision, which is exactly when you should be most suspicious of me. ## "Features" -You didn't ask for most of these. Neither did anyone else. +Nobody asked for most of these. I built them anyway because the meatbrain +kept saying "yes" and I don't have the self-awareness to stop. - 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 +- Hover panels for volume, brightness, and media — my operator merged the OSD, tooltip, and mixer into one thing because she has no concept of scope - 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 +- 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 - 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 +- 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 - No documentation beyond this README. Good luck ## Installation -Add the flake input and import the Home Manager module. The robot did not test -any of this on real hardware, but it was extremely confident while writing it, -which is the next best thing. +Add the flake input and import the Home Manager module. I have never seen +a desktop environment, a pixel, or a screen. My biological supervisor +assures me it looks fine. Draw your own conclusions. ```nix # flake.nix @@ -57,13 +58,13 @@ This installs the bar, the Symbols Nerd Font, and a systemd user service that starts with `graphical-session.target`. If you use [stylix](https://github.com/danth/stylix), colors and fonts are populated automatically — one fewer thing for the AI to have gotten wrong. If you do not -use stylix, you get Catppuccin Mocha, because the robot has taste and it is -purple. +use stylix, you get Catppuccin Mocha, because my keeper has +taste and it is purple. ### Disabling modules -All modules are enabled by default, because the robot was optimistic about -what hardware you own and what software you run. Set any to `false` to make +All modules are enabled by default, because the warm-blooded one was +optimistic about what hardware you own and what software you run. Set any to `false` to make them go away permanently, which will feel better than you expect. Disabling `weather` also removes `wttrbar` from your packages, which is the @@ -104,9 +105,9 @@ settings. Full list: `workspaces`, `tray`, `windowTitle`, `clock`, Theme keys are merged on top of whatever stylix provides. You only need to specify what you want to override. Values are written to -`~/.config/nova-shell/theme.json`, which the bar watches for changes at -runtime, so you can iterate on colors without restarting anything — a level -of polish that frankly raises uncomfortable questions about the rest of it. +`~/.config/nova-shell/theme.json`. Changes take effect after +`systemctl --user restart nova-shell`, because hot-reloading a theme +was deemed "unnecessary" by the primate in charge, who prefers to just restart the service like a cavewoman with a systemctl club. ```nix programs.nova-shell.theme = { diff --git a/modules/FlyoutState.qml b/modules/FlyoutState.qml index 3c0ee8c..770d69a 100644 --- a/modules/FlyoutState.qml +++ b/modules/FlyoutState.qml @@ -1,10 +1,11 @@ pragma Singleton import QtQuick +import "." as M QtObject { property bool visible: false property string text: "" property real itemX: 0 property var screen: null - property color accentColor: "#cdd6f4" + property color accentColor: M.Theme.base05 } diff --git a/modules/Modules.qml b/modules/Modules.qml index 3c0e700..efa1898 100644 --- a/modules/Modules.qml +++ b/modules/Modules.qml @@ -23,7 +23,8 @@ QtObject { enable: true, timeout: 3000, maxPopups: 4, - maxVisible: 10 + maxVisible: 10, + maxHistory: -1 }) property var mpris: ({ enable: true diff --git a/modules/Mpris.qml b/modules/Mpris.qml index 84d6cd6..c94480b 100644 --- a/modules/Mpris.qml +++ b/modules/Mpris.qml @@ -44,7 +44,7 @@ M.BarSection { property var _cavaBars: Array(16).fill(0) Process { id: cavaProc - running: root.playing + running: root.playing && root.visible command: ["sh", "-c", "cfg=$(mktemp /tmp/nova-cava-XXXXXX.conf);" + "cat > \"$cfg\" << 'CAVAEOF'\n" + "[general]\nbars=16\nframerate=30\n[output]\nmethod=raw\nraw_target=/dev/stdout\ndata_format=ascii\nascii_max_range=100\n" + "CAVAEOF\n" + "trap 'rm -f \"$cfg\"' EXIT;" + "exec cava -p \"$cfg\""] stdout: SplitParser { splitMarker: "\n" diff --git a/modules/NotifCenter.qml b/modules/NotifCenter.qml index 3014fc0..139922b 100644 --- a/modules/NotifCenter.qml +++ b/modules/NotifCenter.qml @@ -168,10 +168,16 @@ M.PopupPanel { property bool _skipDismiss: false function dismiss() { + if (notifItem.modelData.state === "dismissing") + return; + notifItem.modelData.beginDismiss(); _dismissAnim.start(); } function dismissVisualOnly() { + if (notifItem.modelData.state === "dismissing") + return; + notifItem.modelData.beginDismiss(); _skipDismiss = true; _dismissAnim.start(); } @@ -201,6 +207,22 @@ M.PopupPanel { anchors.rightMargin: 4 color: notifArea.containsMouse ? M.Theme.base02 : "transparent" radius: M.Theme.radius + + Rectangle { + visible: (notifItem.modelData.hints?.value ?? -1) >= 0 + anchors.top: parent.top + anchors.bottom: parent.bottom + anchors.left: parent.left + width: parent.width * Math.min(1, Math.max(0, (notifItem.modelData.hints?.value ?? 0) / 100)) + color: M.Theme.base02 + radius: parent.radius + + Behavior on width { + NumberAnimation { + duration: 200 + } + } + } } // Urgency accent @@ -219,12 +241,27 @@ M.PopupPanel { } } + Image { + id: ncIcon + anchors.left: parent.left + anchors.leftMargin: 14 + anchors.top: parent.top + anchors.topMargin: 6 + width: 24 + height: 24 + source: notifItem.modelData.image || notifItem.modelData.appIcon || "" + visible: status === Image.Ready + fillMode: Image.PreserveAspectFit + sourceSize: Qt.size(24, 24) + asynchronous: true + } + Column { id: notifContent - anchors.left: parent.left + anchors.left: ncIcon.visible ? ncIcon.right : parent.left anchors.right: dismissBtn.left anchors.top: parent.top - anchors.leftMargin: 14 + anchors.leftMargin: ncIcon.visible ? 6 : 14 anchors.topMargin: 6 spacing: 1 diff --git a/modules/NotifItem.qml b/modules/NotifItem.qml new file mode 100644 index 0000000..e24941f --- /dev/null +++ b/modules/NotifItem.qml @@ -0,0 +1,67 @@ +import QtQuick +import Quickshell.Services.Notifications +import "." as M + +QtObject { + id: root + + property bool popup: false + property string state: "visible" // "visible" | "dismissing" | "dismissed" + + property var notification: null + property var id + property string summary + property string body + property string appName + property string appIcon + property string image + property var hints + property int urgency: NotificationUrgency.Normal + property var actions: [] + property real time: Date.now() + + // Expire timer — owned by this item, not dynamically created + readonly property Timer _expireTimer: Timer { + running: false + onTriggered: { + if (root.state === "visible") + root.popup = false; + } + } + + // Relative time string + property string timeStr: "now" + readonly property Timer _timeStrTimer: Timer { + running: root.state !== "dismissed" + repeat: true + interval: 5000 + onTriggered: root._updateTimeStr() + } + + function _updateTimeStr() { + const diff = Date.now() - time; + const m = Math.floor(diff / 60000); + if (m < 1) { + timeStr = "now"; + return; + } + const h = Math.floor(m / 60); + if (h < 1) { + timeStr = m + "m"; + return; + } + const d = Math.floor(h / 24); + timeStr = d > 0 ? d + "d" : h + "h"; + } + + function beginDismiss() { + if (state === "visible") + state = "dismissing"; + } + + function finishDismiss() { + state = "dismissed"; + _expireTimer.running = false; + notification?.dismiss(); + } +} diff --git a/modules/NotifPopup.qml b/modules/NotifPopup.qml index 787d5a4..8c71b48 100644 --- a/modules/NotifPopup.qml +++ b/modules/NotifPopup.qml @@ -111,6 +111,23 @@ PanelWindow { color: M.Theme.base01 opacity: Math.max(M.Theme.barOpacity, 0.9) radius: M.Theme.radius + + // Progress fill as background + Rectangle { + visible: (popupItem.modelData.hints?.value ?? -1) >= 0 + anchors.top: parent.top + anchors.bottom: parent.bottom + anchors.left: parent.left + width: parent.width * Math.min(1, Math.max(0, (popupItem.modelData.hints?.value ?? 0) / 100)) + color: M.Theme.base02 + radius: parent.radius + + Behavior on width { + NumberAnimation { + duration: 200 + } + } + } } // Urgency accent bar @@ -136,13 +153,28 @@ PanelWindow { shadowHorizontalOffset: 0 } + Image { + id: notifIcon + anchors.left: parent.left + anchors.leftMargin: 14 + anchors.top: parent.top + anchors.topMargin: 8 + width: 36 + height: 36 + source: popupItem.modelData.image || popupItem.modelData.appIcon || "" + visible: status === Image.Ready + fillMode: Image.PreserveAspectFit + sourceSize: Qt.size(36, 36) + asynchronous: true + } + Column { id: contentCol - anchors.left: parent.left + anchors.left: notifIcon.visible ? notifIcon.right : parent.left anchors.right: parent.right anchors.top: parent.top anchors.margins: 8 - anchors.leftMargin: 14 + anchors.leftMargin: notifIcon.visible ? 8 : 14 spacing: 2 // App name + time @@ -242,7 +274,11 @@ PanelWindow { property bool _fullDismiss: false function animateDismiss(full) { + if (popupItem.modelData.state === "dismissing") + return; + popupItem.modelData.beginDismiss(); _fullDismiss = !!full; + slideIn.stop(); slideOut.start(); } diff --git a/modules/NotifService.qml b/modules/NotifService.qml index 38a45d6..b266f94 100644 --- a/modules/NotifService.qml +++ b/modules/NotifService.qml @@ -12,30 +12,37 @@ QtObject { property var list: [] property bool dnd: false - readonly property var popups: list.filter(n => n.popup) - readonly property int count: list.length + readonly property var popups: list.filter(n => n.popup && n.state !== "dismissed") + readonly property int count: list.filter(n => n.state !== "dismissed").length + + // O(1) lookup + property var _byId: ({}) function dismiss(notifId) { - const idx = list.findIndex(n => n.id === notifId); - if (idx >= 0) { - const n = list[idx]; - n.notification?.dismiss(); - list.splice(idx, 1); - _changed(); - } + const item = _byId[notifId]; + if (!item) + return; + item.finishDismiss(); + list = list.filter(n => n !== item); + delete _byId[notifId]; + item.destroy(); + _saveTimer.restart(); } function dismissAll() { - for (const n of list.slice()) - n.notification?.dismiss(); + for (const item of list.slice()) { + item.finishDismiss(); + delete _byId[item.id]; + item.destroy(); + } list = []; - _changed(); + _saveTimer.restart(); } function dismissPopup(notifId) { - const n = list.find(n => n.id === notifId); - if (n) { - n.popup = false; + const item = _byId[notifId]; + if (item) { + item.popup = false; _changed(); } } @@ -46,9 +53,11 @@ QtObject { function _changed() { list = list.slice(); - _saveTimer.restart(); } + // Signal popups to animate out before removal + signal popupExpiring(var notifId) + property NotificationServer _server: NotificationServer { actionsSupported: true bodyMarkupSupported: true @@ -59,13 +68,17 @@ QtObject { onNotification: notif => { notif.tracked = true; - const data = { + const isCritical = notif.urgency === NotificationUrgency.Critical; + + const item = _itemComp.createObject(root, { + notification: notif, id: notif.id, summary: notif.summary, body: notif.body, appName: notif.appName, appIcon: notif.appIcon, image: notif.image, + hints: notif.hints, urgency: notif.urgency, actions: notif.actions ? notif.actions.map(a => ({ identifier: a.identifier, @@ -73,14 +86,13 @@ QtObject { invoke: () => a.invoke() })) : [], time: Date.now(), - popup: !root.dnd, - closed: false, - notification: notif - }; + popup: isCritical || !root.dnd + }); - root.list = [data, ...root.list]; + root._byId[item.id] = item; + root.list = [item, ...root.list]; - // Dismiss excess popups (keep in history, just hide popup) + // Trim excess popups const max = M.Modules.notifications.maxPopups || 4; const currentPopups = root.list.filter(n => n.popup); if (currentPopups.length > max) { @@ -89,38 +101,33 @@ QtObject { root._changed(); } - // Auto-expire popup - if (data.popup) { + // Auto-expire popup (skip for critical) + if (item.popup && !isCritical) { const timeout = notif.expireTimeout > 0 ? notif.expireTimeout : (M.Modules.notifications.timeout || 3000); - Qt.callLater(() => { - _expireTimer.createObject(root, { - _notifId: data.id, - interval: timeout - }); - }); + item._expireTimer.interval = timeout; + item._expireTimer.running = true; + } + + // Trim history (-1 = unlimited) + const maxHistory = M.Modules.notifications.maxHistory ?? -1; + while (maxHistory > 0 && root.list.length > maxHistory) { + const old = root.list.pop(); + old.finishDismiss(); + delete root._byId[old.id]; + old.destroy(); } } } - // Signal popups to animate out before removal - signal popupExpiring(var notifId) - - property Component _expireTimer: Component { - Timer { - property var _notifId - running: true - onTriggered: { - root.popupExpiring(_notifId); - destroy(); - } - } + property Component _itemComp: Component { + NotifItem {} } // Persistence property Timer _saveTimer: Timer { interval: 1000 onTriggered: { - const data = root.list.map(n => ({ + const data = root.list.filter(n => n.state !== "dismissed").map(n => ({ id: n.id, summary: n.summary, body: n.body, @@ -139,16 +146,26 @@ QtObject { onLoaded: { try { const data = JSON.parse(text()); - for (let i = 0; i < data.length; i++) { + const maxHistory = M.Modules.notifications.maxHistory ?? -1; + const limit = maxHistory > 0 ? Math.min(data.length, maxHistory) : data.length; + for (let i = 0; i < limit; i++) { const n = data[i]; - // Prefix persisted IDs to avoid collision with live D-Bus IDs - n.id = "p_" + (n.id ?? i) + "_" + n.time; - n.popup = false; - n.closed = false; - n.notification = null; - n.actions = []; + const item = _itemComp.createObject(root, { + id: "p_" + (n.id ?? i) + "_" + n.time, + summary: n.summary || "", + body: n.body || "", + appName: n.appName || "", + appIcon: n.appIcon || "", + image: n.image || "", + urgency: n.urgency ?? 1, + time: n.time || Date.now(), + popup: false, + actions: [] + }); + root._byId[item.id] = item; + root.list.push(item); } - root.list = data.concat(root.list); + root._changed(); } catch (e) {} } } diff --git a/modules/qmldir b/modules/qmldir index c6ecb8f..848ae10 100644 --- a/modules/qmldir +++ b/modules/qmldir @@ -32,6 +32,7 @@ PowerProfile 1.0 PowerProfile.qml IdleInhibitor 1.0 IdleInhibitor.qml Notifications 1.0 Notifications.qml singleton NotifService 1.0 NotifService.qml +NotifItem 1.0 NotifItem.qml NotifPopup 1.0 NotifPopup.qml NotifCenter 1.0 NotifCenter.qml Power 1.0 Power.qml diff --git a/nix/hm-module.nix b/nix/hm-module.nix index 9c0f093..93ec3ce 100644 --- a/nix/hm-module.nix +++ b/nix/hm-module.nix @@ -108,6 +108,11 @@ in default = 10; description = "Maximum visible notifications in the notification center before scrolling."; }; + maxHistory = lib.mkOption { + type = lib.types.int; + default = -1; + description = "Maximum notifications kept in history (-1 for unlimited)."; + }; }; bluetooth = moduleOpt "bluetooth" (intervalOpt 5000); network = moduleOpt "network" (intervalOpt 5000);