diff --git a/README.md b/README.md index bddce4b..daf498a 100644 --- a/README.md +++ b/README.md @@ -1,37 +1,36 @@ # nova-shell A [Quickshell](https://quickshell.outfoxxed.me)-based desktop shell for -[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. +[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. -**Use at your own risk.** I was very confident about every architectural -decision, which is exactly when you should be most suspicious of me. +**Use at your own risk.** The slop machine was very confident about every +architectural decision, which is exactly when you should be most suspicious. ## "Features" -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. +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 — my operator merged the OSD, tooltip, and mixer into one thing because she has no concept of scope +- 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 — 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, 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. 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 +- 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 -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. +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. ```nix # flake.nix @@ -58,13 +57,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 my keeper has -taste and it is purple. +use stylix, you get Catppuccin Mocha, because the robot has taste and it is +purple. ### Disabling modules -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 +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 them go away permanently, which will feel better than you expect. Disabling `weather` also removes `wttrbar` from your packages, which is the @@ -105,9 +104,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`. 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. +`~/.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. ```nix programs.nova-shell.theme = { diff --git a/modules/FlyoutState.qml b/modules/FlyoutState.qml index 770d69a..3c0ee8c 100644 --- a/modules/FlyoutState.qml +++ b/modules/FlyoutState.qml @@ -1,11 +1,10 @@ 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: M.Theme.base05 + property color accentColor: "#cdd6f4" } diff --git a/modules/Modules.qml b/modules/Modules.qml index efa1898..3c0e700 100644 --- a/modules/Modules.qml +++ b/modules/Modules.qml @@ -23,8 +23,7 @@ QtObject { enable: true, timeout: 3000, maxPopups: 4, - maxVisible: 10, - maxHistory: -1 + maxVisible: 10 }) property var mpris: ({ enable: true diff --git a/modules/Mpris.qml b/modules/Mpris.qml index c94480b..84d6cd6 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 && root.visible + running: root.playing 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 139922b..3014fc0 100644 --- a/modules/NotifCenter.qml +++ b/modules/NotifCenter.qml @@ -168,16 +168,10 @@ 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(); } @@ -207,22 +201,6 @@ 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 @@ -241,27 +219,12 @@ 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: ncIcon.visible ? ncIcon.right : parent.left + anchors.left: parent.left anchors.right: dismissBtn.left anchors.top: parent.top - anchors.leftMargin: ncIcon.visible ? 6 : 14 + anchors.leftMargin: 14 anchors.topMargin: 6 spacing: 1 diff --git a/modules/NotifItem.qml b/modules/NotifItem.qml deleted file mode 100644 index e24941f..0000000 --- a/modules/NotifItem.qml +++ /dev/null @@ -1,67 +0,0 @@ -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 8c71b48..787d5a4 100644 --- a/modules/NotifPopup.qml +++ b/modules/NotifPopup.qml @@ -111,23 +111,6 @@ 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 @@ -153,28 +136,13 @@ 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: notifIcon.visible ? notifIcon.right : parent.left + anchors.left: parent.left anchors.right: parent.right anchors.top: parent.top anchors.margins: 8 - anchors.leftMargin: notifIcon.visible ? 8 : 14 + anchors.leftMargin: 14 spacing: 2 // App name + time @@ -274,11 +242,7 @@ 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 b266f94..38a45d6 100644 --- a/modules/NotifService.qml +++ b/modules/NotifService.qml @@ -12,37 +12,30 @@ QtObject { property var list: [] property bool dnd: false - 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: ({}) + readonly property var popups: list.filter(n => n.popup) + readonly property int count: list.length function dismiss(notifId) { - const item = _byId[notifId]; - if (!item) - return; - item.finishDismiss(); - list = list.filter(n => n !== item); - delete _byId[notifId]; - item.destroy(); - _saveTimer.restart(); + const idx = list.findIndex(n => n.id === notifId); + if (idx >= 0) { + const n = list[idx]; + n.notification?.dismiss(); + list.splice(idx, 1); + _changed(); + } } function dismissAll() { - for (const item of list.slice()) { - item.finishDismiss(); - delete _byId[item.id]; - item.destroy(); - } + for (const n of list.slice()) + n.notification?.dismiss(); list = []; - _saveTimer.restart(); + _changed(); } function dismissPopup(notifId) { - const item = _byId[notifId]; - if (item) { - item.popup = false; + const n = list.find(n => n.id === notifId); + if (n) { + n.popup = false; _changed(); } } @@ -53,11 +46,9 @@ 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 @@ -68,17 +59,13 @@ QtObject { onNotification: notif => { notif.tracked = true; - const isCritical = notif.urgency === NotificationUrgency.Critical; - - const item = _itemComp.createObject(root, { - notification: notif, + const data = { 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, @@ -86,13 +73,14 @@ QtObject { invoke: () => a.invoke() })) : [], time: Date.now(), - popup: isCritical || !root.dnd - }); + popup: !root.dnd, + closed: false, + notification: notif + }; - root._byId[item.id] = item; - root.list = [item, ...root.list]; + root.list = [data, ...root.list]; - // Trim excess popups + // Dismiss excess popups (keep in history, just hide popup) const max = M.Modules.notifications.maxPopups || 4; const currentPopups = root.list.filter(n => n.popup); if (currentPopups.length > max) { @@ -101,33 +89,38 @@ QtObject { root._changed(); } - // Auto-expire popup (skip for critical) - if (item.popup && !isCritical) { + // Auto-expire popup + if (data.popup) { const timeout = notif.expireTimeout > 0 ? notif.expireTimeout : (M.Modules.notifications.timeout || 3000); - 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(); + Qt.callLater(() => { + _expireTimer.createObject(root, { + _notifId: data.id, + interval: timeout + }); + }); } } } - property Component _itemComp: Component { - NotifItem {} + // 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(); + } + } } // Persistence property Timer _saveTimer: Timer { interval: 1000 onTriggered: { - const data = root.list.filter(n => n.state !== "dismissed").map(n => ({ + const data = root.list.map(n => ({ id: n.id, summary: n.summary, body: n.body, @@ -146,26 +139,16 @@ QtObject { onLoaded: { try { const data = JSON.parse(text()); - 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++) { + for (let i = 0; i < data.length; i++) { const n = data[i]; - 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); + // 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 = []; } - root._changed(); + root.list = data.concat(root.list); } catch (e) {} } } diff --git a/modules/qmldir b/modules/qmldir index 848ae10..c6ecb8f 100644 --- a/modules/qmldir +++ b/modules/qmldir @@ -32,7 +32,6 @@ 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 93ec3ce..9c0f093 100644 --- a/nix/hm-module.nix +++ b/nix/hm-module.nix @@ -108,11 +108,6 @@ 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);