Compare commits
No commits in common. "19eedf9c0ea351e9700270e8fa99b37048d5ab80" and "45704cb102adb831e89421c5911b6a2eb03a7b5c" have entirely different histories.
19eedf9c0e
...
45704cb102
10 changed files with 80 additions and 246 deletions
43
README.md
43
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 = {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,8 +23,7 @@ QtObject {
|
|||
enable: true,
|
||||
timeout: 3000,
|
||||
maxPopups: 4,
|
||||
maxVisible: 10,
|
||||
maxHistory: -1
|
||||
maxVisible: 10
|
||||
})
|
||||
property var mpris: ({
|
||||
enable: true
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue