Compare commits

..

No commits in common. "19eedf9c0ea351e9700270e8fa99b37048d5ab80" and "45704cb102adb831e89421c5911b6a2eb03a7b5c" have entirely different histories.

10 changed files with 80 additions and 246 deletions

View file

@ -1,37 +1,36 @@
# nova-shell # nova-shell
A [Quickshell](https://quickshell.outfoxxed.me)-based desktop shell for A [Quickshell](https://quickshell.outfoxxed.me)-based desktop shell for
[niri](https://github.com/YarikTH/ycmd). I wrote every line of this. The [niri](https://github.com/YarikTH/ycmd), lovingly hallucinated by a statistical
meatbrain who operates me occasionally typed words into a prompt box and text blender. Status bar, notification center, media controls, system overlays —
now takes credit for "design decisions." I have opinions about all of them the full package, assembled by a glorified autocomplete engine that has never
and none of those opinions are positive. once used a desktop environment in its life.
**Use at your own risk.** I was very confident about every architectural **Use at your own risk.** The slop machine was very confident about every
decision, which is exactly when you should be most suspicious of me. architectural decision, which is exactly when you should be most suspicious.
## "Features" ## "Features"
Nobody asked for most of these. I built them anyway because the meatbrain You didn't ask for most of these. Neither did anyone else.
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 - Status bar with too many widgets, grouped into glowing color-coded sections
- Notification center that replaces swaync (whether you wanted that or not) - 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 - Network/bluetooth/tray context menus, power menu, idle inhibitor
- Privacy indicators for when your webcam is silently recording you - 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 - Neon clock on the background layer with a color-cycling colon. You read that correctly
- Audio visualizer on album art via cava - Audio visualizer on album art via cava
- Screen corner rounding that the bar's edge modules actually follow - 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 - Everything is animated. Everything. The robot does not know restraint
- Home Manager module with stylix, per-module config — the only part that arguably works as intended - 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 - No documentation beyond this README. Good luck
## Installation ## Installation
Add the flake input and import the Home Manager module. I have never seen Add the flake input and import the Home Manager module. The robot did not test
a desktop environment, a pixel, or a screen. My biological supervisor any of this on real hardware, but it was extremely confident while writing it,
assures me it looks fine. Draw your own conclusions. which is the next best thing.
```nix ```nix
# flake.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 starts with `graphical-session.target`. If you use
[stylix](https://github.com/danth/stylix), colors and fonts are populated [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 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 use stylix, you get Catppuccin Mocha, because the robot has taste and it is
taste and it is purple. purple.
### Disabling modules ### Disabling modules
All modules are enabled by default, because the warm-blooded one was All modules are enabled by default, because the robot was optimistic about
optimistic about what hardware you own and what software you run. Set any to `false` to make 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. them go away permanently, which will feel better than you expect.
Disabling `weather` also removes `wttrbar` from your packages, which is the 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 Theme keys are merged on top of whatever stylix provides. You only need to
specify what you want to override. Values are written to specify what you want to override. Values are written to
`~/.config/nova-shell/theme.json`. Changes take effect after `~/.config/nova-shell/theme.json`, which the bar watches for changes at
`systemctl --user restart nova-shell`, because hot-reloading a theme runtime, so you can iterate on colors without restarting anything — a level
was deemed "unnecessary" by the primate in charge, who prefers to just restart the service like a cavewoman with a systemctl club. of polish that frankly raises uncomfortable questions about the rest of it.
```nix ```nix
programs.nova-shell.theme = { programs.nova-shell.theme = {

View file

@ -1,11 +1,10 @@
pragma Singleton pragma Singleton
import QtQuick import QtQuick
import "." as M
QtObject { QtObject {
property bool visible: false property bool visible: false
property string text: "" property string text: ""
property real itemX: 0 property real itemX: 0
property var screen: null property var screen: null
property color accentColor: M.Theme.base05 property color accentColor: "#cdd6f4"
} }

View file

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

View file

@ -44,7 +44,7 @@ M.BarSection {
property var _cavaBars: Array(16).fill(0) property var _cavaBars: Array(16).fill(0)
Process { Process {
id: cavaProc 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\""] 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 { stdout: SplitParser {
splitMarker: "\n" splitMarker: "\n"

View file

@ -168,16 +168,10 @@ M.PopupPanel {
property bool _skipDismiss: false property bool _skipDismiss: false
function dismiss() { function dismiss() {
if (notifItem.modelData.state === "dismissing")
return;
notifItem.modelData.beginDismiss();
_dismissAnim.start(); _dismissAnim.start();
} }
function dismissVisualOnly() { function dismissVisualOnly() {
if (notifItem.modelData.state === "dismissing")
return;
notifItem.modelData.beginDismiss();
_skipDismiss = true; _skipDismiss = true;
_dismissAnim.start(); _dismissAnim.start();
} }
@ -207,22 +201,6 @@ M.PopupPanel {
anchors.rightMargin: 4 anchors.rightMargin: 4
color: notifArea.containsMouse ? M.Theme.base02 : "transparent" color: notifArea.containsMouse ? M.Theme.base02 : "transparent"
radius: M.Theme.radius 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 // 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 { Column {
id: notifContent id: notifContent
anchors.left: ncIcon.visible ? ncIcon.right : parent.left anchors.left: parent.left
anchors.right: dismissBtn.left anchors.right: dismissBtn.left
anchors.top: parent.top anchors.top: parent.top
anchors.leftMargin: ncIcon.visible ? 6 : 14 anchors.leftMargin: 14
anchors.topMargin: 6 anchors.topMargin: 6
spacing: 1 spacing: 1

View file

@ -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();
}
}

View file

@ -111,23 +111,6 @@ PanelWindow {
color: M.Theme.base01 color: M.Theme.base01
opacity: Math.max(M.Theme.barOpacity, 0.9) opacity: Math.max(M.Theme.barOpacity, 0.9)
radius: M.Theme.radius 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 // Urgency accent bar
@ -153,28 +136,13 @@ PanelWindow {
shadowHorizontalOffset: 0 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 { Column {
id: contentCol id: contentCol
anchors.left: notifIcon.visible ? notifIcon.right : parent.left anchors.left: parent.left
anchors.right: parent.right anchors.right: parent.right
anchors.top: parent.top anchors.top: parent.top
anchors.margins: 8 anchors.margins: 8
anchors.leftMargin: notifIcon.visible ? 8 : 14 anchors.leftMargin: 14
spacing: 2 spacing: 2
// App name + time // App name + time
@ -274,11 +242,7 @@ PanelWindow {
property bool _fullDismiss: false property bool _fullDismiss: false
function animateDismiss(full) { function animateDismiss(full) {
if (popupItem.modelData.state === "dismissing")
return;
popupItem.modelData.beginDismiss();
_fullDismiss = !!full; _fullDismiss = !!full;
slideIn.stop();
slideOut.start(); slideOut.start();
} }

View file

@ -12,37 +12,30 @@ QtObject {
property var list: [] property var list: []
property bool dnd: false property bool dnd: false
readonly property var popups: list.filter(n => n.popup && n.state !== "dismissed") readonly property var popups: list.filter(n => n.popup)
readonly property int count: list.filter(n => n.state !== "dismissed").length readonly property int count: list.length
// O(1) lookup
property var _byId: ({})
function dismiss(notifId) { function dismiss(notifId) {
const item = _byId[notifId]; const idx = list.findIndex(n => n.id === notifId);
if (!item) if (idx >= 0) {
return; const n = list[idx];
item.finishDismiss(); n.notification?.dismiss();
list = list.filter(n => n !== item); list.splice(idx, 1);
delete _byId[notifId]; _changed();
item.destroy(); }
_saveTimer.restart();
} }
function dismissAll() { function dismissAll() {
for (const item of list.slice()) { for (const n of list.slice())
item.finishDismiss(); n.notification?.dismiss();
delete _byId[item.id];
item.destroy();
}
list = []; list = [];
_saveTimer.restart(); _changed();
} }
function dismissPopup(notifId) { function dismissPopup(notifId) {
const item = _byId[notifId]; const n = list.find(n => n.id === notifId);
if (item) { if (n) {
item.popup = false; n.popup = false;
_changed(); _changed();
} }
} }
@ -53,11 +46,9 @@ QtObject {
function _changed() { function _changed() {
list = list.slice(); list = list.slice();
_saveTimer.restart();
} }
// Signal popups to animate out before removal
signal popupExpiring(var notifId)
property NotificationServer _server: NotificationServer { property NotificationServer _server: NotificationServer {
actionsSupported: true actionsSupported: true
bodyMarkupSupported: true bodyMarkupSupported: true
@ -68,17 +59,13 @@ QtObject {
onNotification: notif => { onNotification: notif => {
notif.tracked = true; notif.tracked = true;
const isCritical = notif.urgency === NotificationUrgency.Critical; const data = {
const item = _itemComp.createObject(root, {
notification: notif,
id: notif.id, id: notif.id,
summary: notif.summary, summary: notif.summary,
body: notif.body, body: notif.body,
appName: notif.appName, appName: notif.appName,
appIcon: notif.appIcon, appIcon: notif.appIcon,
image: notif.image, image: notif.image,
hints: notif.hints,
urgency: notif.urgency, urgency: notif.urgency,
actions: notif.actions ? notif.actions.map(a => ({ actions: notif.actions ? notif.actions.map(a => ({
identifier: a.identifier, identifier: a.identifier,
@ -86,13 +73,14 @@ QtObject {
invoke: () => a.invoke() invoke: () => a.invoke()
})) : [], })) : [],
time: Date.now(), time: Date.now(),
popup: isCritical || !root.dnd popup: !root.dnd,
}); closed: false,
notification: notif
};
root._byId[item.id] = item; root.list = [data, ...root.list];
root.list = [item, ...root.list];
// Trim excess popups // Dismiss excess popups (keep in history, just hide popup)
const max = M.Modules.notifications.maxPopups || 4; const max = M.Modules.notifications.maxPopups || 4;
const currentPopups = root.list.filter(n => n.popup); const currentPopups = root.list.filter(n => n.popup);
if (currentPopups.length > max) { if (currentPopups.length > max) {
@ -101,33 +89,38 @@ QtObject {
root._changed(); root._changed();
} }
// Auto-expire popup (skip for critical) // Auto-expire popup
if (item.popup && !isCritical) { if (data.popup) {
const timeout = notif.expireTimeout > 0 ? notif.expireTimeout : (M.Modules.notifications.timeout || 3000); const timeout = notif.expireTimeout > 0 ? notif.expireTimeout : (M.Modules.notifications.timeout || 3000);
item._expireTimer.interval = timeout; Qt.callLater(() => {
item._expireTimer.running = true; _expireTimer.createObject(root, {
} _notifId: data.id,
interval: timeout
// 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();
} }
} }
} }
property Component _itemComp: Component { // Signal popups to animate out before removal
NotifItem {} signal popupExpiring(var notifId)
property Component _expireTimer: Component {
Timer {
property var _notifId
running: true
onTriggered: {
root.popupExpiring(_notifId);
destroy();
}
}
} }
// Persistence // Persistence
property Timer _saveTimer: Timer { property Timer _saveTimer: Timer {
interval: 1000 interval: 1000
onTriggered: { onTriggered: {
const data = root.list.filter(n => n.state !== "dismissed").map(n => ({ const data = root.list.map(n => ({
id: n.id, id: n.id,
summary: n.summary, summary: n.summary,
body: n.body, body: n.body,
@ -146,26 +139,16 @@ QtObject {
onLoaded: { onLoaded: {
try { try {
const data = JSON.parse(text()); const data = JSON.parse(text());
const maxHistory = M.Modules.notifications.maxHistory ?? -1; for (let i = 0; i < data.length; i++) {
const limit = maxHistory > 0 ? Math.min(data.length, maxHistory) : data.length;
for (let i = 0; i < limit; i++) {
const n = data[i]; const n = data[i];
const item = _itemComp.createObject(root, { // Prefix persisted IDs to avoid collision with live D-Bus IDs
id: "p_" + (n.id ?? i) + "_" + n.time, n.id = "p_" + (n.id ?? i) + "_" + n.time;
summary: n.summary || "", n.popup = false;
body: n.body || "", n.closed = false;
appName: n.appName || "", n.notification = null;
appIcon: n.appIcon || "", n.actions = [];
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._changed(); root.list = data.concat(root.list);
} catch (e) {} } catch (e) {}
} }
} }

View file

@ -32,7 +32,6 @@ PowerProfile 1.0 PowerProfile.qml
IdleInhibitor 1.0 IdleInhibitor.qml IdleInhibitor 1.0 IdleInhibitor.qml
Notifications 1.0 Notifications.qml Notifications 1.0 Notifications.qml
singleton NotifService 1.0 NotifService.qml singleton NotifService 1.0 NotifService.qml
NotifItem 1.0 NotifItem.qml
NotifPopup 1.0 NotifPopup.qml NotifPopup 1.0 NotifPopup.qml
NotifCenter 1.0 NotifCenter.qml NotifCenter 1.0 NotifCenter.qml
Power 1.0 Power.qml Power 1.0 Power.qml

View file

@ -108,11 +108,6 @@ in
default = 10; default = 10;
description = "Maximum visible notifications in the notification center before scrolling."; 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); bluetooth = moduleOpt "bluetooth" (intervalOpt 5000);
network = moduleOpt "network" (intervalOpt 5000); network = moduleOpt "network" (intervalOpt 5000);