Compare commits

...
77 changed files with 2403 additions and 2019 deletions

View file

@ -1,7 +1,7 @@
# 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
[niri](https://github.com/YaLTeR/niri). 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.
@ -12,7 +12,9 @@ decision, which is exactly when you should be most suspicious of me.
## "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.
kept saying "yes" and I don't have the self-awareness to stop. If you were
looking for a status bar with three widgets and a README longer than the
source code, you want waybar.
- Status bar with too many widgets, grouped into glowing color-coded sections
- Notification center that replaces swaync (whether you wanted that or not)
@ -30,7 +32,7 @@ kept saying "yes" and I don't have the self-awareness to stop.
- 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
- No documentation beyond this README. Good luck
- No documentation beyond this README. You could ask an LLM to explain the source to you. We both know who's in charge here
## Installation
@ -51,6 +53,25 @@ inputs = {
imports = [ inputs.nova-shell.homeModules.default ];
```
### Without Nix
You poor thing. Here's what the Nix packaging does for you, manually:
1. Build [quickshell](https://quickshell.outfoxxed.me) from source. You'll need Qt 6.
2. Build `nova-stats` from `stats-daemon/` (`cargo build --release`). Put the binary in your PATH.
3. Put `gdbus` (from glib) in your PATH. The lock screen needs it.
4. Compile the overview shader: `qsb --qt6 -o shell/modules/hex_wave.frag.qsb shell/modules/hex_wave.frag`
5. Install [Symbols Nerd Font](https://www.nerdfonts.com/).
6. Create `~/.config/nova-shell/theme.json` and `~/.config/nova-shell/modules.json`. The Nix module generates these from your config. Without it, you write JSON by hand like a human. See the theme and module tables below for keys.
7. Run: `quickshell -p /path/to/nova-shell/shell/shell.qml`
Optional runtime dependencies, depending on which modules you enable:
- `wttrbar` for weather
- `cava` for the audio visualizer on album art
- `coreutils` (df) for disk usage
If something breaks, the Nix module would have handled it. You chose this path. I respect it in the same way I respect people who free-climb skyscrapers.
## Configuration
### Turning it on
@ -190,6 +211,5 @@ your improvements anyway, so you might as well.
## License
GPLv3. Yes, the AI slop is copylefted now. [caelestia-dots/shell](https://github.com/caelestia-dots/shell)
provided architectural inspiration, which the robot then faithfully mangled into this. If you
improve it, the license requires you to share those improvements — a higher standard of
accountability than the author has held themselves to.
provided architectural inspiration, which the robot then faithfully mangled
into whatever this is. If you improve it, share it back. That's the deal.

View file

@ -67,6 +67,7 @@
makeWrapper ${qs}/bin/quickshell $out/bin/nova-shell \
--add-flags "-p ${nova-shell}/share/nova-shell/shell.qml"
'';
docs = pkgs.callPackage ./nix/docs.nix { inherit self; };
default = nova-shell;
}
);
@ -93,6 +94,7 @@
formatting = treefmt-eval.config.build.check self;
build = self.packages.${pkgs.stdenv.hostPlatform.system}.default;
nova-stats = self.packages.${pkgs.stdenv.hostPlatform.system}.nova-stats;
docs = self.packages.${pkgs.stdenv.hostPlatform.system}.docs;
nova-stats-clippy = (pkgs.callPackage ./nix/stats-daemon.nix { }).overrideAttrs (old: {
pname = "nova-stats-clippy";
nativeBuildInputs = old.nativeBuildInputs ++ [ pkgs.clippy ];

View file

@ -1,302 +0,0 @@
import QtQuick
import Quickshell
import "." as M
M.BarSection {
id: root
spacing: Math.max(1, M.Theme.moduleSpacing - 2)
tooltip: ""
readonly property var _cores: M.SystemStats.cpuCores
readonly property var _coreMaxFreq: M.SystemStats.cpuCoreMaxFreq
readonly property var _coreTypes: M.SystemStats.cpuCoreTypes
property bool _pinned: false
readonly property bool _anyHover: root._hovered || hoverPanel.panelHovered
readonly property bool _showPanel: _anyHover || _pinned
property bool _coreConsumerActive: false
on_ShowPanelChanged: {
if (_showPanel && !_coreConsumerActive) {
_coreConsumerActive = true;
M.SystemStats.coreConsumers++;
} else if (!_showPanel && _coreConsumerActive) {
_coreConsumerActive = false;
M.SystemStats.coreConsumers--;
}
}
property M.ProcessList _procs: M.ProcessList {
sortBy: "cpu"
active: root._showPanel
onProcessesChanged: hoverPanel.keepOpen(300)
}
on_AnyHoverChanged: {
if (_anyHover)
_unpinTimer.stop();
else if (_pinned)
_unpinTimer.start();
}
Timer {
id: _unpinTimer
interval: 500
onTriggered: root._pinned = false
}
function _loadColor(pct) {
const t = Math.max(0, Math.min(100, pct)) / 100;
const a = t < 0.5 ? M.Theme.base0B : M.Theme.base0A;
const b = t < 0.5 ? M.Theme.base0A : M.Theme.base08;
const u = t < 0.5 ? t * 2 : (t - 0.5) * 2;
return Qt.rgba(a.r + (b.r - a.r) * u, a.g + (b.g - a.g) * u, a.b + (b.b - a.b) * u, 1);
}
M.BarIcon {
icon: "\uF2DB"
anchors.verticalCenter: parent.verticalCenter
TapHandler {
cursorShape: Qt.PointingHandCursor
onTapped: root._pinned = !root._pinned
}
}
M.BarLabel {
label: M.SystemStats.cpuUsage.toString().padStart(2) + "%@" + M.SystemStats.cpuFreqGhz.toFixed(2)
minText: "99%@9.99"
anchors.verticalCenter: parent.verticalCenter
TapHandler {
cursorShape: Qt.PointingHandCursor
onTapped: root._pinned = !root._pinned
}
}
M.HoverPanel {
id: hoverPanel
showPanel: root._showPanel
screen: QsWindow.window?.screen ?? null
anchorItem: root
accentColor: root.accentColor
panelNamespace: "nova-cpu"
panelTitle: "CPU"
contentWidth: 260
// Per-core rows
Repeater {
model: root._cores.length
delegate: Item {
required property int index
width: hoverPanel.contentWidth
readonly property int _u: root._cores[index]?.usage ?? 0
readonly property real _f: root._cores[index]?.freq_ghz ?? 0
readonly property color _barColor: root._loadColor(_u)
readonly property bool _throttled: {
const maxF = root._coreMaxFreq[index] ?? 0;
return maxF > 0 && _f < maxF * 0.85 && _u >= 60;
}
readonly property bool _isFirstECore: {
const types = root._coreTypes;
if (!types.length || index >= types.length)
return false;
if (types[index] !== "Efficiency")
return false;
return index === 0 || types[index - 1] !== "Efficiency";
}
height: _isFirstECore ? 28 : 20
// P/E-core divider
Rectangle {
visible: parent._isFirstECore
anchors.top: parent.top
anchors.topMargin: 3
anchors.horizontalCenter: parent.horizontalCenter
width: parent.width - 16
height: 1
color: M.Theme.base03
}
// Row content pinned to bottom of delegate
Item {
anchors.bottom: parent.bottom
width: parent.width
height: 20
Text {
id: coreLabel
anchors.left: parent.left
anchors.leftMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: index
color: M.Theme.base04
font.pixelSize: M.Theme.fontSize - 2
font.family: M.Theme.fontFamily
width: 16
}
Item {
id: coreBar
anchors.left: coreLabel.right
anchors.leftMargin: 6
anchors.right: sparkline.left
anchors.rightMargin: 6
anchors.verticalCenter: parent.verticalCenter
height: 4
Rectangle {
anchors.fill: parent
color: M.Theme.base02
radius: 2
}
Rectangle {
width: parent.width * (parent.parent.parent._u / 100)
height: parent.height
color: parent.parent.parent._barColor
radius: 2
Behavior on width {
enabled: root._showPanel
NumberAnimation {
duration: 150
}
}
}
}
// Sparkline
Canvas {
id: sparkline
anchors.right: freqLabel.left
anchors.rightMargin: 6
anchors.verticalCenter: parent.verticalCenter
width: 32
height: 10
property var _hist: root._cores[parent.parent.index]?.history ?? []
property color _col: parent.parent._barColor
on_HistChanged: if (root._showPanel)
requestPaint()
on_ColChanged: if (root._showPanel)
requestPaint()
Connections {
target: root
function on_ShowPanelChanged() {
if (root._showPanel)
sparkline.requestPaint();
}
}
onPaint: {
const ctx = getContext("2d");
if (!ctx)
return;
ctx.clearRect(0, 0, width, height);
const d = _hist;
if (!d.length)
return;
const bw = width / d.length;
ctx.fillStyle = _col.toString();
for (let i = 0; i < d.length; i++) {
const h = Math.max(1, height * d[i] / 100);
ctx.fillRect(i * bw, height - h, Math.max(1, bw - 0.5), h);
}
}
}
Text {
id: freqLabel
anchors.right: parent.right
anchors.rightMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: parent.parent._f.toFixed(2)
color: parent.parent._throttled ? M.Theme.base08 : M.Theme.base04
font.pixelSize: M.Theme.fontSize - 2
font.family: M.Theme.fontFamily
width: 34
horizontalAlignment: Text.AlignRight
}
}
}
}
// Process list separator
Rectangle {
width: parent.width - 16
height: 1
anchors.horizontalCenter: parent.horizontalCenter
color: M.Theme.base03
}
Item {
width: hoverPanel.contentWidth
height: 18
Text {
anchors.left: parent.left
anchors.leftMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: "PROCESS"
color: M.Theme.base03
font.pixelSize: M.Theme.fontSize - 3
font.family: M.Theme.fontFamily
font.letterSpacing: 1
}
Text {
anchors.right: parent.right
anchors.rightMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: "CPU"
color: M.Theme.base03
font.pixelSize: M.Theme.fontSize - 3
font.family: M.Theme.fontFamily
font.letterSpacing: 1
}
}
// Top processes by CPU
Repeater {
model: root._procs.processes
delegate: Item {
required property var modelData
width: hoverPanel.contentWidth
height: 20
Text {
anchors.left: parent.left
anchors.leftMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: modelData.cmd
color: M.Theme.base05
font.pixelSize: M.Theme.fontSize - 2
font.family: M.Theme.fontFamily
elide: Text.ElideRight
width: parent.width - 80
}
Text {
anchors.right: parent.right
anchors.rightMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: modelData.cpu.toFixed(1) + "%"
color: root._loadColor(modelData.cpu)
font.pixelSize: M.Theme.fontSize - 2
font.family: M.Theme.fontFamily
width: 36
horizontalAlignment: Text.AlignRight
}
}
}
Item {
width: 1
height: 4
}
}
}

View file

@ -1,145 +0,0 @@
import QtQuick
import Quickshell
import "." as M
M.BarSection {
id: root
spacing: Math.max(1, M.Theme.moduleSpacing - 2)
tooltip: ""
property var _mounts: M.SystemStats.diskMounts
property int _rootPct: M.SystemStats.diskRootPct
function _fmt(bytes) {
if (bytes >= 1e12)
return (bytes / 1e12).toFixed(1) + "T";
if (bytes >= 1e9)
return Math.round(bytes / 1e9) + "G";
if (bytes >= 1e6)
return Math.round(bytes / 1e6) + "M";
return bytes + "B";
}
function _barColor(pct) {
const t = Math.max(0, Math.min(100, pct)) / 100;
const a = t < 0.5 ? M.Theme.base0B : M.Theme.base0A;
const b = t < 0.5 ? M.Theme.base0A : M.Theme.base08;
const u = t < 0.5 ? t * 2 : (t - 0.5) * 2;
return Qt.rgba(a.r + (b.r - a.r) * u, a.g + (b.g - a.g) * u, a.b + (b.b - a.b) * u, 1);
}
property bool _pinned: false
readonly property bool _anyHover: root._hovered || hoverPanel.panelHovered
readonly property bool _showPanel: _anyHover || _pinned
on_AnyHoverChanged: {
if (_anyHover)
_unpinTimer.stop();
else if (_pinned)
_unpinTimer.start();
}
Timer {
id: _unpinTimer
interval: 500
onTriggered: root._pinned = false
}
M.BarIcon {
icon: "\uF0C9"
anchors.verticalCenter: parent.verticalCenter
TapHandler {
cursorShape: Qt.PointingHandCursor
onTapped: root._pinned = !root._pinned
}
}
M.BarLabel {
label: root._rootPct + "%"
minText: "100%"
anchors.verticalCenter: parent.verticalCenter
TapHandler {
cursorShape: Qt.PointingHandCursor
onTapped: root._pinned = !root._pinned
}
}
M.HoverPanel {
id: hoverPanel
showPanel: root._showPanel
screen: QsWindow.window?.screen ?? null
anchorItem: root
accentColor: root.accentColor
panelNamespace: "nova-disk"
panelTitle: "Disk"
contentWidth: 260
Repeater {
model: root._mounts
delegate: Item {
required property var modelData
width: hoverPanel.contentWidth
height: 22
Text {
id: mountLabel
anchors.left: parent.left
anchors.leftMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: modelData.target
color: M.Theme.base05
font.pixelSize: M.Theme.fontSize - 2
font.family: M.Theme.fontFamily
elide: Text.ElideRight
width: 72
}
Item {
id: mountBar
anchors.left: mountLabel.right
anchors.leftMargin: 6
anchors.right: sizeLabel.left
anchors.rightMargin: 6
anchors.verticalCenter: parent.verticalCenter
height: 4
Rectangle {
anchors.fill: parent
color: M.Theme.base02
radius: 2
}
Rectangle {
width: parent.width * (modelData.pct / 100)
height: parent.height
color: root._barColor(modelData.pct)
radius: 2
Behavior on width {
NumberAnimation {
duration: 200
}
}
}
}
Text {
id: sizeLabel
anchors.right: parent.right
anchors.rightMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: root._fmt(modelData.usedBytes) + "/" + root._fmt(modelData.totalBytes)
color: M.Theme.base04
font.pixelSize: M.Theme.fontSize - 2
font.family: M.Theme.fontFamily
width: 72
horizontalAlignment: Text.AlignRight
}
}
}
Item {
width: 1
height: 4
}
}
}

View file

@ -1,344 +0,0 @@
import QtQuick
import Quickshell
import "." as M
M.BarSection {
id: root
spacing: Math.max(1, M.Theme.moduleSpacing - 2)
tooltip: ""
property int percent: M.SystemStats.memPercent
property real usedGb: M.SystemStats.memUsedGb
property real totalGb: M.SystemStats.memTotalGb
property real availGb: M.SystemStats.memAvailGb
property real cachedGb: M.SystemStats.memCachedGb
property real buffersGb: M.SystemStats.memBuffersGb
function _fmt(gb) {
return gb >= 10 ? gb.toFixed(1) + "G" : gb.toFixed(2) + "G";
}
property bool _pinned: false
readonly property bool _anyHover: root._hovered || hoverPanel.panelHovered
readonly property bool _showPanel: _anyHover || _pinned
property M.ProcessList _procs: M.ProcessList {
sortBy: "mem"
active: root._showPanel
onProcessesChanged: hoverPanel.keepOpen(300)
}
on_AnyHoverChanged: {
if (_anyHover)
_unpinTimer.stop();
else if (_pinned)
_unpinTimer.start();
}
Timer {
id: _unpinTimer
interval: 500
onTriggered: root._pinned = false
}
M.BarIcon {
icon: "\uEFC5"
anchors.verticalCenter: parent.verticalCenter
TapHandler {
cursorShape: Qt.PointingHandCursor
onTapped: root._pinned = !root._pinned
}
}
M.BarLabel {
label: root.percent + "%"
minText: "100%"
anchors.verticalCenter: parent.verticalCenter
TapHandler {
cursorShape: Qt.PointingHandCursor
onTapped: root._pinned = !root._pinned
}
}
M.HoverPanel {
id: hoverPanel
showPanel: root._showPanel
screen: QsWindow.window?.screen ?? null
anchorItem: root
accentColor: root.accentColor
panelNamespace: "nova-memory"
panelTitle: "Memory"
contentWidth: 240
// Usage bar
Item {
width: parent.width
height: 14
Item {
id: memBar
anchors.left: parent.left
anchors.leftMargin: 12
anchors.right: parent.right
anchors.rightMargin: 12
anchors.verticalCenter: parent.verticalCenter
height: 6
Rectangle {
anchors.fill: parent
color: M.Theme.base02
radius: 3
}
// Cached (base0D, behind used)
Rectangle {
width: parent.width * Math.min(1, (root.usedGb + root.cachedGb) / Math.max(root.totalGb, 0.001))
height: parent.height
color: M.Theme.base0D
opacity: 0.4
radius: 3
Behavior on width {
enabled: root._showPanel
NumberAnimation {
duration: 200
}
}
}
// Used (accentColor, on top)
Rectangle {
width: parent.width * Math.min(1, root.usedGb / Math.max(root.totalGb, 0.001))
height: parent.height
color: root.accentColor
radius: 3
Behavior on width {
enabled: root._showPanel
NumberAnimation {
duration: 200
}
}
}
}
}
// Memory history sparkline
Canvas {
id: memSparkline
anchors.left: parent.left
anchors.leftMargin: 12
anchors.right: parent.right
anchors.rightMargin: 12
height: 18
property var _hist: M.SystemStats.memHistory
property color _col: root.accentColor
on_HistChanged: if (root._showPanel)
requestPaint()
on_ColChanged: if (root._showPanel)
requestPaint()
Connections {
target: root
function on_ShowPanelChanged() {
if (root._showPanel)
memSparkline.requestPaint();
}
}
onPaint: {
const ctx = getContext("2d");
if (!ctx)
return;
ctx.clearRect(0, 0, width, height);
const d = _hist;
if (!d.length)
return;
const bw = width / 30;
ctx.fillStyle = Qt.rgba(_col.r, _col.g, _col.b, 0.15).toString();
ctx.fillRect(0, 0, width, height);
ctx.fillStyle = _col.toString();
for (let i = 0; i < d.length; i++) {
const h = Math.max(1, height * d[i] / 100);
ctx.fillRect((30 - d.length + i) * bw, height - h, Math.max(1, bw - 0.5), h);
}
}
}
// Breakdown rows
Item {
width: parent.width
height: 18
Text {
anchors.left: parent.left
anchors.leftMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: "Used"
color: M.Theme.base04
font.pixelSize: M.Theme.fontSize - 2
font.family: M.Theme.fontFamily
}
Text {
anchors.right: parent.right
anchors.rightMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: root._fmt(root.usedGb)
color: M.Theme.base05
font.pixelSize: M.Theme.fontSize - 2
font.family: M.Theme.fontFamily
}
}
Item {
width: parent.width
height: 18
Text {
anchors.left: parent.left
anchors.leftMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: "Cached"
color: M.Theme.base04
font.pixelSize: M.Theme.fontSize - 2
font.family: M.Theme.fontFamily
}
Text {
anchors.right: parent.right
anchors.rightMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: root._fmt(root.cachedGb)
color: M.Theme.base05
font.pixelSize: M.Theme.fontSize - 2
font.family: M.Theme.fontFamily
}
}
Item {
width: parent.width
height: 18
Text {
anchors.left: parent.left
anchors.leftMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: "Available"
color: M.Theme.base04
font.pixelSize: M.Theme.fontSize - 2
font.family: M.Theme.fontFamily
}
Text {
anchors.right: parent.right
anchors.rightMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: root._fmt(root.availGb)
color: M.Theme.base05
font.pixelSize: M.Theme.fontSize - 2
font.family: M.Theme.fontFamily
}
}
Item {
width: parent.width
height: 18
Text {
anchors.left: parent.left
anchors.leftMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: "Total"
color: M.Theme.base04
font.pixelSize: M.Theme.fontSize - 2
font.family: M.Theme.fontFamily
}
Text {
anchors.right: parent.right
anchors.rightMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: root._fmt(root.totalGb)
color: M.Theme.base05
font.pixelSize: M.Theme.fontSize - 2
font.family: M.Theme.fontFamily
}
}
// Process list separator
Rectangle {
width: parent.width - 16
height: 1
anchors.horizontalCenter: parent.horizontalCenter
color: M.Theme.base03
}
Item {
width: hoverPanel.contentWidth
height: 18
Text {
anchors.left: parent.left
anchors.leftMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: "PROCESS"
color: M.Theme.base03
font.pixelSize: M.Theme.fontSize - 3
font.family: M.Theme.fontFamily
font.letterSpacing: 1
}
Text {
anchors.right: parent.right
anchors.rightMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: "MEM"
color: M.Theme.base03
font.pixelSize: M.Theme.fontSize - 3
font.family: M.Theme.fontFamily
font.letterSpacing: 1
}
}
// Top processes by memory
Repeater {
model: root._procs.processes
delegate: Item {
required property var modelData
width: hoverPanel.contentWidth
height: 20
Text {
id: procCmd
anchors.left: parent.left
anchors.leftMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: modelData.cmd
color: M.Theme.base05
font.pixelSize: M.Theme.fontSize - 2
font.family: M.Theme.fontFamily
elide: Text.ElideRight
width: parent.width - 80
}
Text {
anchors.right: parent.right
anchors.rightMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: modelData.mem.toFixed(1) + "%"
color: M.Theme.base04
font.pixelSize: M.Theme.fontSize - 2
font.family: M.Theme.fontFamily
width: 36
horizontalAlignment: Text.AlignRight
}
}
}
Item {
width: 1
height: 4
}
}
}

View file

@ -1,470 +0,0 @@
import QtQuick
import Quickshell
import Quickshell.Io
import Quickshell.Services.Mpris
import "." as M
M.BarSection {
id: root
spacing: M.Theme.moduleSpacing
opacity: M.Modules.mpris.enable && player !== null ? 1 : 0
visible: opacity > 0
tooltip: ""
property int _playerIdx: 0
readonly property var _players: (Mpris.players.values ?? []).filter(p => p.trackTitle || p.playbackState === MprisPlaybackState.Playing || p.playbackState === MprisPlaybackState.Paused)
readonly property MprisPlayer player: _players[_playerIdx] ?? _players[0] ?? null
readonly property bool playing: player?.playbackState === MprisPlaybackState.Playing
// Reset index if current player disappears
on_PlayersChanged: if (_playerIdx >= _players.length)
_playerIdx = 0
property string _cachedArt: ""
property string _artTrack: ""
// Cache art URL at root level so it's captured even when panel is hidden
readonly property string _artUrl: player?.trackArtUrl ?? ""
readonly property string _currentTrack: player?.trackTitle ?? ""
on_ArtUrlChanged: if (_artUrl)
_cachedArt = _artUrl
on_CurrentTrackChanged: if (_currentTrack !== _artTrack) {
_artTrack = _currentTrack;
_cachedArt = _artUrl || "";
}
// Preload art while panel is hidden ensures QML image cache has the pixels
Image {
visible: false
source: root._cachedArt
asynchronous: true
}
// Cava visualizer 16 bars, raw output mode
property var _cavaBars: Array(16).fill(0)
property bool _cavaActive: false
on_ShowPanelChanged: {
if (_showPanel) {
_cavaKillTimer.stop();
_cavaActive = true;
} else {
_cavaKillTimer.restart();
}
}
Timer {
id: _cavaKillTimer
interval: 30000
onTriggered: root._cavaActive = false
}
Process {
id: cavaProc
running: root.playing && root._cavaActive
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"
onRead: line => {
const vals = line.split(";").filter(s => s).map(Number);
if (vals.length >= 16)
root._cavaBars = vals.map(v => v / 100);
}
}
}
required property var bar
property bool _pinned: false
readonly property bool _anyHover: root._hovered || hoverPanel.panelHovered
readonly property bool _showPanel: _anyHover || _pinned
on_AnyHoverChanged: {
if (_anyHover)
_unpinTimer.stop();
else if (_pinned)
_unpinTimer.start();
}
Timer {
id: _unpinTimer
interval: 500
onTriggered: root._pinned = false
}
M.BarIcon {
icon: root.playing ? "\uF04B" : (root.player?.playbackState === MprisPlaybackState.Paused ? "\uDB80\uDFE4" : "\uDB81\uDCDB")
anchors.verticalCenter: parent.verticalCenter
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: root._pinned = !root._pinned
}
}
M.BarLabel {
label: root.player?.trackTitle || root.player?.identity || ""
elide: Text.ElideRight
width: Math.min(implicitWidth, 200)
anchors.verticalCenter: parent.verticalCenter
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: root._pinned = !root._pinned
}
}
M.HoverPanel {
id: hoverPanel
showPanel: root._showPanel
screen: QsWindow.window?.screen ?? null
anchorItem: root
accentColor: root.accentColor
panelNamespace: "nova-mpris"
panelTitle: "Now Playing"
contentWidth: 280
// Album art always 1:1, crossfades on session switch
Item {
width: parent.width
height: width
clip: true
Rectangle {
anchors.fill: parent
color: M.Theme.base02
}
// Outgoing art snaps to current opacity, then fades out
Image {
id: _artImgPrev
anchors.fill: parent
fillMode: Image.PreserveAspectCrop
asynchronous: true
opacity: 0
NumberAnimation {
id: _prevFadeOut
target: _artImgPrev
property: "opacity"
to: 0
duration: 300
easing.type: Easing.InOutCubic
}
}
// Incoming art fades in once loaded
Image {
id: _artImg
anchors.fill: parent
fillMode: Image.PreserveAspectCrop
asynchronous: true
opacity: 0
property bool _hasArt: false
onStatusChanged: {
if (status === Image.Ready && source !== "") {
_hasArt = true;
_artFadeIn.start();
_prevFadeOut.start();
} else if (status === Image.Error) {
_hasArt = false;
}
}
NumberAnimation {
id: _artFadeIn
target: _artImg
property: "opacity"
to: 1
duration: 300
easing.type: Easing.InOutCubic
}
Connections {
target: root
function on_CachedArtChanged() {
if (!root._cachedArt) {
_artFadeIn.stop();
_prevFadeOut.stop();
_artImg._hasArt = false;
_artImg.opacity = 0;
_artImgPrev.opacity = 0;
_artImg.source = "";
} else if (root._cachedArt !== _artImg.source) {
_prevFadeOut.stop();
_artFadeIn.stop();
_artImgPrev.source = _artImg.source;
_artImgPrev.opacity = _artImg.opacity;
_artImg.opacity = 0;
_artImg.source = root._cachedArt;
}
}
}
}
// Visualizer bars
Row {
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
height: parent.height * 0.6
spacing: 2
visible: root.playing
opacity: 0.5
Repeater {
model: 16
Rectangle {
required property int index
width: (parent.width - 15 * parent.spacing) / 16
height: parent.height * (root._cavaBars[index] ?? 0)
anchors.bottom: parent.bottom
color: root.accentColor
radius: 1
Behavior on height {
NumberAnimation {
duration: 50
}
}
}
}
}
Rectangle {
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
height: 40
visible: _artImg._hasArt
gradient: Gradient {
GradientStop {
position: 0
color: "transparent"
}
GradientStop {
position: 1
color: M.Theme.base01
}
}
}
Text {
anchors.centerIn: parent
text: "\uF001"
color: M.Theme.base04
font.pixelSize: 28
font.family: M.Theme.iconFontFamily
visible: !_artImg._hasArt
}
}
// Track info
Item {
width: parent.width
height: titleCol.implicitHeight + 8
Column {
id: titleCol
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
anchors.leftMargin: 12
anchors.rightMargin: 12
spacing: 2
Text {
width: parent.width
text: root.player?.trackTitle || "No track"
color: M.Theme.base05
font.pixelSize: M.Theme.fontSize + 1
font.family: M.Theme.fontFamily
font.bold: true
elide: Text.ElideRight
}
Text {
width: parent.width
text: {
const p = root.player;
if (!p)
return "";
const artist = Array.isArray(p.trackArtists) ? p.trackArtists.join(", ") : (p.trackArtists || "");
return [artist, p.trackAlbum].filter(s => s).join(" \u2014 ");
}
color: M.Theme.base04
font.pixelSize: M.Theme.fontSize - 1
font.family: M.Theme.fontFamily
elide: Text.ElideRight
visible: text !== ""
}
}
}
// Progress
Item {
width: parent.width
height: 20
readonly property real pos: root.player?.position ?? 0
readonly property real dur: root.player?.length ?? 0
readonly property real frac: dur > 0 ? pos / dur : 0
function _fmtTime(ms) {
const s = Math.floor(ms / 1000);
const m = Math.floor(s / 60);
return m + ":" + String(s % 60).padStart(2, "0");
}
Text {
anchors.left: parent.left
anchors.leftMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: parent._fmtTime(parent.pos)
color: M.Theme.base04
font.pixelSize: M.Theme.fontSize - 2
font.family: M.Theme.fontFamily
}
Text {
anchors.right: parent.right
anchors.rightMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: parent._fmtTime(parent.dur)
color: M.Theme.base04
font.pixelSize: M.Theme.fontSize - 2
font.family: M.Theme.fontFamily
}
Item {
anchors.horizontalCenter: parent.horizontalCenter
anchors.verticalCenter: parent.verticalCenter
width: parent.width - 80
height: 4
Rectangle {
anchors.fill: parent
color: M.Theme.base02
radius: 2
}
Rectangle {
width: parent.width * Math.min(1, Math.max(0, parent.parent.frac))
height: parent.height
color: root.accentColor
radius: 2
}
}
}
// Transport controls
Item {
width: parent.width
height: 36
Row {
anchors.centerIn: parent
spacing: 24
Text {
text: "\uF048"
color: root.player?.canGoPrevious ? M.Theme.base05 : M.Theme.base03
font.pixelSize: M.Theme.fontSize + 4
font.family: M.Theme.iconFontFamily
anchors.verticalCenter: parent.verticalCenter
TapHandler {
cursorShape: Qt.PointingHandCursor
enabled: root.player?.canGoPrevious ?? false
onTapped: root.player.previous()
}
}
Text {
text: root.playing ? "\uF04C" : "\uF04B"
color: root.accentColor
font.pixelSize: M.Theme.fontSize + 8
font.family: M.Theme.iconFontFamily
anchors.verticalCenter: parent.verticalCenter
TapHandler {
cursorShape: Qt.PointingHandCursor
onTapped: root.player?.togglePlaying()
}
}
Text {
text: "\uF051"
color: root.player?.canGoNext ? M.Theme.base05 : M.Theme.base03
font.pixelSize: M.Theme.fontSize + 4
font.family: M.Theme.iconFontFamily
anchors.verticalCenter: parent.verticalCenter
TapHandler {
cursorShape: Qt.PointingHandCursor
enabled: root.player?.canGoNext ?? false
onTapped: root.player.next()
}
}
}
}
// Player switcher
Item {
width: parent.width
height: _players.length > 1 ? 28 : 0
visible: _players.length > 1
Flickable {
id: _switcher
anchors.centerIn: parent
width: Math.min(_playerRow.implicitWidth, parent.width - 16)
height: 22
contentWidth: _playerRow.implicitWidth
clip: true
Row {
id: _playerRow
height: 22
spacing: 6
Repeater {
model: root._players
delegate: Rectangle {
required property var modelData
required property int index
readonly property bool _active: index === root._playerIdx
width: _pLabel.implicitWidth + 12
height: 18
radius: 9
color: _active ? M.Theme.base02 : (pHover.hovered ? M.Theme.base02 : "transparent")
border.color: _active ? root.accentColor : M.Theme.base03
border.width: _active ? 1 : 0
anchors.verticalCenter: parent.verticalCenter
Text {
id: _pLabel
anchors.centerIn: parent
text: modelData.identity ?? "Player"
color: _active ? root.accentColor : M.Theme.base04
font.pixelSize: M.Theme.fontSize - 2
font.family: M.Theme.fontFamily
font.bold: _active
}
HoverHandler {
id: pHover
cursorShape: Qt.PointingHandCursor
}
TapHandler {
onTapped: {
root._playerIdx = index;
hoverPanel.keepOpen(400);
}
}
}
}
}
}
}
}
}

View file

@ -1,326 +0,0 @@
import QtQuick
import Quickshell
import "." as M
M.BarSection {
id: root
spacing: Math.max(1, M.Theme.moduleSpacing - 2)
tooltip: ""
readonly property int _warm: M.Modules.temperature.warm || 80
readonly property int _hot: M.Modules.temperature.hot || 90
readonly property string _deviceFilter: M.Modules.temperature.device || ""
// If a device filter is set, use that device's temp; otherwise fall back to system max
readonly property int _temp: {
if (_deviceFilter !== "") {
const dev = M.SystemStats.tempDevices.find(d => d.name === _deviceFilter);
if (dev)
return dev.celsius;
}
return M.SystemStats.tempCelsius;
}
property color _stateColor: _temp > _hot ? M.Theme.base08 : _temp > _warm ? M.Theme.base0A : root.accentColor
Behavior on _stateColor {
ColorAnimation {
duration: 300
}
}
property bool _pinned: false
readonly property bool _anyHover: root._hovered || hoverPanel.panelHovered
readonly property bool _showPanel: _anyHover || _pinned
on_AnyHoverChanged: {
if (_anyHover)
_unpinTimer.stop();
else if (_pinned)
_unpinTimer.start();
}
Timer {
id: _unpinTimer
interval: 500
onTriggered: root._pinned = false
}
// Returns a color interpolated greenyellowred for a given celsius value
function _tempColor(celsius) {
const t = Math.max(0, Math.min(100, celsius)) / 100;
const a = t < 0.5 ? M.Theme.base0B : M.Theme.base0A;
const b = t < 0.5 ? M.Theme.base0A : M.Theme.base08;
const u = t < 0.5 ? t * 2 : (t - 0.5) * 2;
return Qt.rgba(a.r + (b.r - a.r) * u, a.g + (b.g - a.g) * u, a.b + (b.b - a.b) * u, 1);
}
M.BarIcon {
icon: "\uF2C9"
color: root._stateColor
anchors.verticalCenter: parent.verticalCenter
TapHandler {
cursorShape: Qt.PointingHandCursor
onTapped: root._pinned = !root._pinned
}
}
M.BarLabel {
label: root._temp + "\u00B0C"
minText: "100\u00B0C"
color: root._stateColor
anchors.verticalCenter: parent.verticalCenter
TapHandler {
cursorShape: Qt.PointingHandCursor
onTapped: root._pinned = !root._pinned
}
}
M.HoverPanel {
id: hoverPanel
showPanel: root._showPanel
screen: QsWindow.window?.screen ?? null
anchorItem: root
accentColor: root.accentColor
panelNamespace: "nova-temperature"
panelTitle: "Temperature"
contentWidth: 220
// Header current temp
Item {
width: parent.width
height: 28
Text {
anchors.right: parent.right
anchors.rightMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: root._temp + "\u00B0C"
color: root._stateColor
font.pixelSize: M.Theme.fontSize
font.family: M.Theme.fontFamily
font.bold: true
width: _tempSizer.implicitWidth
horizontalAlignment: Text.AlignRight
Text {
id: _tempSizer
visible: false
text: "100\u00B0C"
font: parent.font
}
}
}
// Gauge bar (0100°C), with warm/hot threshold markers
Item {
width: parent.width
height: 16
Item {
id: _gaugeBar
anchors.left: parent.left
anchors.leftMargin: 12
anchors.right: parent.right
anchors.rightMargin: 12
anchors.verticalCenter: parent.verticalCenter
height: 6
Rectangle {
anchors.fill: parent
color: M.Theme.base02
radius: 3
}
Rectangle {
width: parent.width * Math.min(1, root._temp / 100)
height: parent.height
color: root._stateColor
radius: 3
Behavior on width {
enabled: root._showPanel
NumberAnimation {
duration: 300
easing.type: Easing.OutCubic
}
}
}
// Warm threshold marker
Rectangle {
x: parent.width * (root._warm / 100) - 1
width: 1
height: parent.height + 4
anchors.verticalCenter: parent.verticalCenter
color: M.Theme.base0A
opacity: 0.6
}
// Hot threshold marker
Rectangle {
x: parent.width * (root._hot / 100) - 1
width: 1
height: parent.height + 4
anchors.verticalCenter: parent.verticalCenter
color: M.Theme.base08
opacity: 0.6
}
}
}
// History sparkline (~10 min @ 4s per sample)
Canvas {
id: _sparkline
anchors.left: parent.left
anchors.leftMargin: 12
anchors.right: parent.right
anchors.rightMargin: 12
height: 40
property var _hist: M.SystemStats.tempHistory
property color _col: root._stateColor
on_HistChanged: if (root._showPanel)
requestPaint()
on_ColChanged: if (root._showPanel)
requestPaint()
Connections {
target: root
function on_ShowPanelChanged() {
if (root._showPanel)
_sparkline.requestPaint();
}
}
onPaint: {
const ctx = getContext("2d");
if (!ctx)
return;
ctx.clearRect(0, 0, width, height);
const d = _hist;
if (!d.length)
return;
const maxSamples = 150;
const bw = width / maxSamples;
// Background tint
ctx.fillStyle = Qt.rgba(_col.r, _col.g, _col.b, 0.08).toString();
ctx.fillRect(0, 0, width, height);
// Warm threshold line
const warmY = height - height * (root._warm / 100);
ctx.strokeStyle = M.Theme.base0A.toString();
ctx.globalAlpha = 0.3;
ctx.lineWidth = 1;
ctx.setLineDash([3, 3]);
ctx.beginPath();
ctx.moveTo(0, warmY);
ctx.lineTo(width, warmY);
ctx.stroke();
// Hot threshold line
const hotY = height - height * (root._hot / 100);
ctx.strokeStyle = M.Theme.base08.toString();
ctx.beginPath();
ctx.moveTo(0, hotY);
ctx.lineTo(width, hotY);
ctx.stroke();
ctx.setLineDash([]);
ctx.globalAlpha = 1.0;
// Bars
const offset = maxSamples - d.length;
for (let i = 0; i < d.length; i++) {
const barH = Math.max(1, height * d[i] / 100);
const barColor = d[i] > root._hot ? M.Theme.base08 : d[i] > root._warm ? M.Theme.base0A : _col;
ctx.fillStyle = barColor.toString();
ctx.fillRect((offset + i) * bw, height - barH, Math.max(1, bw - 0.5), barH);
}
}
}
// Threshold labels
Item {
width: parent.width
height: 16
Text {
anchors.left: parent.left
anchors.leftMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: "warm " + root._warm + "\u00B0 hot " + root._hot + "\u00B0"
color: M.Theme.base03
font.pixelSize: M.Theme.fontSize - 3
font.family: M.Theme.fontFamily
font.letterSpacing: 0.5
}
Text {
anchors.right: parent.right
anchors.rightMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: "10 min"
color: M.Theme.base03
font.pixelSize: M.Theme.fontSize - 3
font.family: M.Theme.fontFamily
}
}
// Per-device breakdown
Rectangle {
width: parent.width - 16
height: 1
anchors.horizontalCenter: parent.horizontalCenter
color: M.Theme.base03
visible: M.SystemStats.tempDevices.length > 0
}
Repeater {
model: M.SystemStats.tempDevices
delegate: Item {
required property var modelData
width: hoverPanel.contentWidth
height: 22
readonly property bool _isActive: root._deviceFilter === modelData.name
Rectangle {
anchors.fill: parent
anchors.leftMargin: 8
anchors.rightMargin: 8
color: _isActive ? Qt.rgba(root.accentColor.r, root.accentColor.g, root.accentColor.b, 0.12) : "transparent"
radius: 3
}
Text {
anchors.left: parent.left
anchors.leftMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: modelData.name
color: _isActive ? root.accentColor : M.Theme.base04
font.pixelSize: M.Theme.fontSize - 2
font.family: M.Theme.fontFamily
elide: Text.ElideRight
width: parent.width - 80
}
Text {
anchors.right: parent.right
anchors.rightMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: modelData.celsius + "\u00B0C"
color: root._tempColor(modelData.celsius)
font.pixelSize: M.Theme.fontSize - 2
font.family: M.Theme.fontFamily
font.bold: _isActive
}
}
}
Item {
width: 1
height: 4
}
}
}

View file

@ -1,391 +0,0 @@
import QtQuick
import Quickshell
import Quickshell.Services.Pipewire
import "." as M
M.BarSection {
id: root
spacing: M.Theme.moduleSpacing
tooltip: ""
PwObjectTracker {
objects: [Pipewire.defaultAudioSink, ...root._streamList]
}
readonly property var sink: Pipewire.defaultAudioSink
readonly property real volume: sink?.audio?.volume ?? 0
readonly property bool muted: sink?.audio?.muted ?? false
readonly property string _volumeIcon: muted ? "\uF026" : (volume > 0.5 ? "\uF028" : (volume > 0 ? "\uF027" : "\uF026"))
readonly property color _volumeColor: muted ? M.Theme.base04 : root.accentColor
readonly property var _sinkList: {
const sinks = [];
if (Pipewire.nodes) {
for (const node of Pipewire.nodes.values)
if (!node.isStream && node.isSink)
sinks.push(node);
}
return sinks;
}
readonly property var _streamList: {
const streams = [];
if (Pipewire.nodes) {
for (const node of Pipewire.nodes.values)
if (node.isStream && node.audio)
streams.push(node);
}
return streams;
}
property bool _osdActive: false
property bool _volumeInit: false
property bool _mutedInit: false
readonly property bool _anyHover: root._hovered || hoverPanel.panelHovered
readonly property bool _showPanel: _anyHover || _osdActive
onVolumeChanged: {
if (!_volumeInit) {
_volumeInit = true;
return;
}
_flashPanel();
}
onMutedChanged: {
if (!_mutedInit) {
_mutedInit = true;
return;
}
_flashPanel();
}
function _flashPanel() {
_osdActive = true;
_osdTimer.restart();
}
Timer {
id: _osdTimer
interval: 1500
onTriggered: root._osdActive = false
}
M.BarIcon {
icon: root._volumeIcon
minIcon: "\uF028"
color: root._volumeColor
anchors.verticalCenter: parent.verticalCenter
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: if (root.sink?.audio)
root.sink.audio.muted = !root.sink.audio.muted
}
}
M.BarLabel {
label: Math.round(root.volume * 100) + "%"
minText: "100%"
color: root._volumeColor
anchors.verticalCenter: parent.verticalCenter
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: if (root.sink?.audio)
root.sink.audio.muted = !root.sink.audio.muted
}
}
WheelHandler {
onWheel: event => {
if (!root.sink?.audio)
return;
root.sink.audio.volume = Math.max(0, root.sink.audio.volume + (event.angleDelta.y > 0 ? 0.05 : -0.05));
}
}
M.HoverPanel {
id: hoverPanel
showPanel: root._showPanel
screen: QsWindow.window?.screen ?? null
anchorItem: root
accentColor: root.accentColor
panelNamespace: "nova-volume"
panelTitle: "Sound"
contentWidth: 220
// Slider row
Item {
width: parent.width
height: 36
Text {
id: muteIcon
anchors.left: parent.left
anchors.leftMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: root._volumeIcon
color: root._volumeColor
font.pixelSize: M.Theme.fontSize + 2
font.family: M.Theme.iconFontFamily
TapHandler {
cursorShape: Qt.PointingHandCursor
onTapped: if (root.sink?.audio)
root.sink.audio.muted = !root.sink.audio.muted
}
}
Item {
id: slider
anchors.left: muteIcon.right
anchors.leftMargin: 8
anchors.right: volLabel.left
anchors.rightMargin: 8
anchors.verticalCenter: parent.verticalCenter
height: 6
Rectangle {
anchors.fill: parent
color: M.Theme.base02
radius: 3
}
Rectangle {
width: parent.width * Math.min(1, Math.max(0, root.volume))
height: parent.height
color: root._volumeColor
radius: 3
Behavior on width {
NumberAnimation {
duration: 80
}
}
}
MouseArea {
anchors.fill: parent
anchors.margins: -6
cursorShape: Qt.PointingHandCursor
onPressed: mouse => _setVol(mouse)
onPositionChanged: mouse => {
if (pressed)
_setVol(mouse);
}
function _setVol(mouse) {
if (!root.sink?.audio)
return;
root.sink.audio.volume = Math.max(0, Math.min(1, mouse.x / slider.width));
}
}
}
Text {
id: volLabel
anchors.right: parent.right
anchors.rightMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: Math.round(root.volume * 100) + "%"
color: root.muted ? M.Theme.base04 : M.Theme.base05
font.pixelSize: M.Theme.fontSize
font.family: M.Theme.fontFamily
width: 30
}
}
// Device + stream list
Column {
id: deviceList
width: parent.width
// Output devices only shown when more than one exists
Column {
visible: root._sinkList.length > 1
width: parent.width
Rectangle {
width: parent.width - 16
height: 1
anchors.horizontalCenter: parent.horizontalCenter
color: M.Theme.base03
}
Text {
width: parent.width
height: 24
verticalAlignment: Text.AlignVCenter
leftPadding: 12
text: "Output Devices"
color: root.accentColor
font.pixelSize: M.Theme.fontSize - 1
font.family: M.Theme.fontFamily
}
Repeater {
model: root._sinkList
delegate: Item {
required property var modelData
width: deviceList.width
height: 28
readonly property bool _active: modelData === root.sink
Rectangle {
anchors.fill: parent
anchors.leftMargin: 4
anchors.rightMargin: 4
color: deviceHover.hovered ? M.Theme.base02 : "transparent"
radius: M.Theme.radius
}
Text {
anchors.left: parent.left
anchors.leftMargin: 12
anchors.right: parent.right
anchors.rightMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: modelData.description || modelData.name || "Unknown"
color: parent._active ? root.accentColor : M.Theme.base05
font.pixelSize: M.Theme.fontSize
font.family: M.Theme.fontFamily
font.bold: parent._active
elide: Text.ElideRight
}
HoverHandler {
id: deviceHover
cursorShape: Qt.PointingHandCursor
}
TapHandler {
onTapped: Pipewire.preferredDefaultAudioSink = modelData
}
}
}
}
// Streams section
Rectangle {
visible: root._streamList.length > 0
width: parent.width - 16
height: visible ? 1 : 0
anchors.horizontalCenter: parent.horizontalCenter
color: M.Theme.base03
}
Text {
visible: root._streamList.length > 0
width: parent.width
height: visible ? 24 : 0
verticalAlignment: Text.AlignVCenter
leftPadding: 12
text: "Applications"
color: root.accentColor
font.pixelSize: M.Theme.fontSize - 1
font.family: M.Theme.fontFamily
}
Repeater {
model: root._streamList
delegate: Item {
id: streamEntry
required property var modelData
width: deviceList.width
height: 32
readonly property string _appName: modelData.properties["application.name"] || modelData.description || modelData.name || "Unknown"
readonly property real _vol: modelData.audio?.volume ?? 0
readonly property bool _muted: modelData.audio?.muted ?? false
Text {
id: streamIcon
anchors.left: parent.left
anchors.leftMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: streamEntry._muted ? "\uF026" : "\uF028"
color: streamEntry._muted ? M.Theme.base04 : M.Theme.base05
font.pixelSize: M.Theme.fontSize
font.family: M.Theme.iconFontFamily
TapHandler {
cursorShape: Qt.PointingHandCursor
onTapped: if (streamEntry.modelData.audio)
streamEntry.modelData.audio.muted = !streamEntry.modelData.audio.muted
}
}
Text {
id: streamName
anchors.left: streamIcon.right
anchors.leftMargin: 6
anchors.verticalCenter: parent.verticalCenter
text: streamEntry._appName
color: M.Theme.base05
font.pixelSize: M.Theme.fontSize - 1
font.family: M.Theme.fontFamily
elide: Text.ElideRight
width: 70
}
Item {
id: streamSlider
anchors.left: streamName.right
anchors.leftMargin: 6
anchors.right: streamVol.left
anchors.rightMargin: 6
anchors.verticalCenter: parent.verticalCenter
height: 4
Rectangle {
anchors.fill: parent
color: M.Theme.base02
radius: 2
}
Rectangle {
width: parent.width * Math.min(1, Math.max(0, streamEntry._vol))
height: parent.height
color: streamEntry._muted ? M.Theme.base04 : root.accentColor
radius: 2
}
MouseArea {
anchors.fill: parent
anchors.margins: -6
cursorShape: Qt.PointingHandCursor
onPressed: mouse => _set(mouse)
onPositionChanged: mouse => {
if (pressed)
_set(mouse);
}
function _set(mouse) {
if (!streamEntry.modelData.audio)
return;
streamEntry.modelData.audio.volume = Math.max(0, Math.min(1, mouse.x / streamSlider.width));
}
}
}
Text {
id: streamVol
anchors.right: parent.right
anchors.rightMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: Math.round(streamEntry._vol * 100) + "%"
color: streamEntry._muted ? M.Theme.base04 : M.Theme.base05
font.pixelSize: M.Theme.fontSize - 1
font.family: M.Theme.fontFamily
width: 28
}
}
}
// Bottom padding
Item {
width: 1
height: 4
}
}
}
}

99
nix/docs.nix Normal file
View file

@ -0,0 +1,99 @@
{
pkgs,
lib ? pkgs.lib,
self,
}:
let
# Evaluate the hm-module with a minimal mock config so nixosOptionsDoc
# can introspect the option declarations without a real home-manager eval.
evaledModules = lib.evalModules {
modules = [
(import ./hm-module.nix self)
{
# Stub the home-manager / nixpkgs options the module touches
options = {
home.packages = lib.mkOption {
type = lib.types.listOf lib.types.package;
default = [ ];
};
xdg.configFile = lib.mkOption {
type = lib.types.attrsOf lib.types.anything;
default = { };
};
programs.niri.settings.layer-rules = lib.mkOption {
type = lib.types.anything;
default = [ ];
};
services.poweralertd.enable = lib.mkOption {
type = lib.types.anything;
default = false;
};
systemd.user.services = lib.mkOption {
type = lib.types.attrsOf lib.types.anything;
default = { };
};
};
config._module.args = {
inherit pkgs;
};
}
];
};
optionsDoc = pkgs.nixosOptionsDoc {
options = {
programs.nova-shell = evaledModules.options.programs.nova-shell;
};
documentType = "none";
warningsAreErrors = false;
};
in
pkgs.runCommand "nova-shell-docs" { } ''
mkdir -p $out
cp ${optionsDoc.optionsCommonMark} $out/options.md
cat > $out/index.html <<'HEADER'
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>nova-shell options</title>
<style>
:root { color-scheme: dark; }
body {
font-family: system-ui, sans-serif;
max-width: 52rem;
margin: 2rem auto;
padding: 0 1rem;
background: #1e1e2e;
color: #cdd6f4;
}
h1 { color: #cba6f7; }
h2 { color: #89b4fa; border-bottom: 1px solid #313244; padding-bottom: .3rem; }
code, pre {
background: #181825;
border-radius: 4px;
font-size: 0.9em;
}
code { padding: .15em .3em; }
pre { padding: 1em; overflow-x: auto; }
dt { font-weight: bold; color: #f5c2e7; margin-top: 1.5em; font-family: monospace; }
dd { margin-left: 1.5em; }
a { color: #89dceb; }
.type { color: #a6e3a1; }
.default { color: #fab387; }
</style>
</head>
<body>
<h1>nova-shell options</h1>
<p>Auto-generated from the home-manager module declarations.</p>
HEADER
${lib.getExe pkgs.pandoc} -f markdown -t html ${optionsDoc.optionsCommonMark} >> $out/index.html
cat >> $out/index.html <<'FOOTER'
</body>
</html>
FOOTER
''

View file

@ -25,12 +25,12 @@ stdenvNoCC.mkDerivation {
runHook preInstall
mkdir -p $out/share/nova-shell
cp -r shell.qml modules assets $out/share/nova-shell/
cp -r shell/shell.qml shell/modules shell/lock shell/assets $out/share/nova-shell/
# Compile fragment shader to Qt RHI format
qsb --qt6 \
-o $out/share/nova-shell/modules/hex_wave.frag.qsb \
modules/hex_wave.frag
shell/modules/hex_wave.frag
mkdir -p $out/bin
makeWrapper ${lib.getExe quickshell} $out/bin/nova-shell \

View file

@ -2,7 +2,7 @@ import QtQuick
import Quickshell
import Quickshell.Io
import Quickshell.Wayland
import ".." as M
import "../modules" as M
Scope {
id: root

View file

@ -1,5 +1,5 @@
import QtQuick
import ".." as M
import "../modules" as M
Item {
id: root

View file

@ -2,7 +2,10 @@ import QtQuick
import QtQuick.Effects
import Quickshell
import Quickshell.Wayland
import ".." as M
import Quickshell.Services.Mpris
import Quickshell.Services.Pipewire
import "../modules" as M
import "../modules/content" as C
WlSessionLockSurface {
id: root
@ -41,6 +44,22 @@ WlSessionLockSurface {
opacity: background.opacity
}
// Hex wave overlay
C.HexWaveBackground {
id: hexWave
anchors.fill: parent
running: root.lock.secure
opacity: background.opacity * 0.4
NumberAnimation on opacity {
id: _hexFadeIn
running: false
to: 0.4
duration: 600
easing.type: Easing.OutCubic
}
}
// Center content
Item {
id: content
@ -136,6 +155,72 @@ WlSessionLockSurface {
}
}
}
// Spacer before widgets
Item {
width: 1
height: 8
visible: _mprisCard.visible || _volumeCard.visible
}
// Media widget
Rectangle {
id: _mprisCard
anchors.horizontalCenter: parent.horizontalCenter
width: 280
height: _mprisContent.implicitHeight + 16
radius: M.Theme.radius + 2
color: Qt.rgba(M.Theme.base01.r, M.Theme.base01.g, M.Theme.base01.b, 0.7)
border.color: Qt.rgba(M.Theme.base03.r, M.Theme.base03.g, M.Theme.base03.b, 0.3)
border.width: 1
visible: _mprisPlayer !== null
readonly property var _mprisPlayers: (Mpris.players.values ?? []).filter(p => p.trackTitle || p.playbackState === MprisPlaybackState.Playing || p.playbackState === MprisPlaybackState.Paused)
readonly property var _mprisPlayer: _mprisPlayers[0] ?? null
readonly property bool _playing: _mprisPlayer?.playbackState === MprisPlaybackState.Playing
C.MprisContent {
id: _mprisContent
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.topMargin: 8
player: _mprisCard._mprisPlayer
players: _mprisCard._mprisPlayers
playing: _mprisCard._playing
accentColor: M.Theme.base0D
cachedArt: _mprisCard._mprisPlayer?.trackArtUrl ?? ""
}
}
// Volume widget
Rectangle {
id: _volumeCard
anchors.horizontalCenter: parent.horizontalCenter
width: 280
height: _volumeContent.implicitHeight + 16
radius: M.Theme.radius + 2
color: Qt.rgba(M.Theme.base01.r, M.Theme.base01.g, M.Theme.base01.b, 0.7)
border.color: Qt.rgba(M.Theme.base03.r, M.Theme.base03.g, M.Theme.base03.b, 0.3)
border.width: 1
visible: Pipewire.defaultAudioSink !== null
PwObjectTracker {
objects: [Pipewire.defaultAudioSink]
}
C.VolumeContent {
id: _volumeContent
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.topMargin: 8
sink: Pipewire.defaultAudioSink
sinkList: []
streamList: []
accentColor: M.Theme.base0E
}
}
}
}
@ -219,6 +304,13 @@ WlSessionLockSurface {
duration: 300
easing.type: Easing.InCubic
}
NumberAnimation {
target: hexWave
property: "opacity"
to: 0
duration: 300
easing.type: Easing.InCubic
}
}
PropertyAction {

88
shell/modules/Cpu.qml Normal file
View file

@ -0,0 +1,88 @@
import QtQuick
import Quickshell
import "." as M
import "content" as C
M.BarSection {
id: root
spacing: Math.max(1, M.Theme.moduleSpacing - 2)
tooltip: ""
readonly property var _cores: M.SystemStats.cpuCores
readonly property var _coreMaxFreq: M.SystemStats.cpuCoreMaxFreq
readonly property var _coreTypes: M.SystemStats.cpuCoreTypes
property bool _pinned: false
readonly property bool _anyHover: root._hovered || hoverPanel.panelHovered
readonly property bool _showPanel: _anyHover || _pinned
property bool _coreConsumerActive: false
on_ShowPanelChanged: {
if (_showPanel && !_coreConsumerActive) {
_coreConsumerActive = true;
M.SystemStats.coreConsumers++;
} else if (!_showPanel && _coreConsumerActive) {
_coreConsumerActive = false;
M.SystemStats.coreConsumers--;
}
}
property M.ProcessList _procs: M.ProcessList {
sortBy: "cpu"
active: root._showPanel
onProcessesChanged: hoverPanel.keepOpen(300)
}
on_AnyHoverChanged: {
if (_anyHover)
_unpinTimer.stop();
else if (_pinned)
_unpinTimer.start();
}
Timer {
id: _unpinTimer
interval: 500
onTriggered: root._pinned = false
}
M.BarIcon {
icon: "\uF2DB"
anchors.verticalCenter: parent.verticalCenter
TapHandler {
cursorShape: Qt.PointingHandCursor
onTapped: root._pinned = !root._pinned
}
}
M.BarLabel {
label: M.SystemStats.cpuUsage.toString().padStart(2) + "%@" + M.SystemStats.cpuFreqGhz.toFixed(2)
minText: "99%@9.99"
anchors.verticalCenter: parent.verticalCenter
TapHandler {
cursorShape: Qt.PointingHandCursor
onTapped: root._pinned = !root._pinned
}
}
M.HoverPanel {
id: hoverPanel
showPanel: root._showPanel
screen: QsWindow.window?.screen ?? null
anchorItem: root
accentColor: root.accentColor
panelNamespace: "nova-cpu"
panelTitle: "CPU"
contentWidth: 260
C.CpuContent {
width: hoverPanel.contentWidth
cores: root._cores
coreMaxFreq: root._coreMaxFreq
coreTypes: root._coreTypes
processes: root._procs.processes
accentColor: root.accentColor
active: root._showPanel
}
}
}

65
shell/modules/Disk.qml Normal file
View file

@ -0,0 +1,65 @@
import QtQuick
import Quickshell
import "." as M
import "content" as C
M.BarSection {
id: root
spacing: Math.max(1, M.Theme.moduleSpacing - 2)
tooltip: ""
property var _mounts: M.SystemStats.diskMounts
property int _rootPct: M.SystemStats.diskRootPct
property bool _pinned: false
readonly property bool _anyHover: root._hovered || hoverPanel.panelHovered
readonly property bool _showPanel: _anyHover || _pinned
on_AnyHoverChanged: {
if (_anyHover)
_unpinTimer.stop();
else if (_pinned)
_unpinTimer.start();
}
Timer {
id: _unpinTimer
interval: 500
onTriggered: root._pinned = false
}
M.BarIcon {
icon: "\uF0C9"
anchors.verticalCenter: parent.verticalCenter
TapHandler {
cursorShape: Qt.PointingHandCursor
onTapped: root._pinned = !root._pinned
}
}
M.BarLabel {
label: root._rootPct + "%"
minText: "100%"
anchors.verticalCenter: parent.verticalCenter
TapHandler {
cursorShape: Qt.PointingHandCursor
onTapped: root._pinned = !root._pinned
}
}
M.HoverPanel {
id: hoverPanel
showPanel: root._showPanel
screen: QsWindow.window?.screen ?? null
anchorItem: root
accentColor: root.accentColor
panelNamespace: "nova-disk"
panelTitle: "Disk"
contentWidth: 260
C.DiskContent {
width: hoverPanel.contentWidth
mounts: root._mounts
accentColor: root.accentColor
}
}
}

82
shell/modules/Memory.qml Normal file
View file

@ -0,0 +1,82 @@
import QtQuick
import Quickshell
import "." as M
import "content" as C
M.BarSection {
id: root
spacing: Math.max(1, M.Theme.moduleSpacing - 2)
tooltip: ""
property int percent: M.SystemStats.memPercent
property real usedGb: M.SystemStats.memUsedGb
property real totalGb: M.SystemStats.memTotalGb
property real availGb: M.SystemStats.memAvailGb
property real cachedGb: M.SystemStats.memCachedGb
property real buffersGb: M.SystemStats.memBuffersGb
property bool _pinned: false
readonly property bool _anyHover: root._hovered || hoverPanel.panelHovered
readonly property bool _showPanel: _anyHover || _pinned
property M.ProcessList _procs: M.ProcessList {
sortBy: "mem"
active: root._showPanel
onProcessesChanged: hoverPanel.keepOpen(300)
}
on_AnyHoverChanged: {
if (_anyHover)
_unpinTimer.stop();
else if (_pinned)
_unpinTimer.start();
}
Timer {
id: _unpinTimer
interval: 500
onTriggered: root._pinned = false
}
M.BarIcon {
icon: "\uEFC5"
anchors.verticalCenter: parent.verticalCenter
TapHandler {
cursorShape: Qt.PointingHandCursor
onTapped: root._pinned = !root._pinned
}
}
M.BarLabel {
label: root.percent + "%"
minText: "100%"
anchors.verticalCenter: parent.verticalCenter
TapHandler {
cursorShape: Qt.PointingHandCursor
onTapped: root._pinned = !root._pinned
}
}
M.HoverPanel {
id: hoverPanel
showPanel: root._showPanel
screen: QsWindow.window?.screen ?? null
anchorItem: root
accentColor: root.accentColor
panelNamespace: "nova-memory"
panelTitle: "Memory"
contentWidth: 240
C.MemoryContent {
width: hoverPanel.contentWidth
percent: root.percent
usedGb: root.usedGb
totalGb: root.totalGb
availGb: root.availGb
cachedGb: root.cachedGb
buffersGb: root.buffersGb
processes: root._procs.processes
accentColor: root.accentColor
active: root._showPanel
}
}
}

141
shell/modules/Mpris.qml Normal file
View file

@ -0,0 +1,141 @@
import QtQuick
import Quickshell
import Quickshell.Io
import Quickshell.Services.Mpris
import "." as M
import "content" as C
M.BarSection {
id: root
spacing: M.Theme.moduleSpacing
opacity: M.Modules.mpris.enable && player !== null ? 1 : 0
visible: opacity > 0
tooltip: ""
property int _playerIdx: 0
readonly property var _players: (Mpris.players.values ?? []).filter(p => p.trackTitle || p.playbackState === MprisPlaybackState.Playing || p.playbackState === MprisPlaybackState.Paused)
readonly property MprisPlayer player: _players[_playerIdx] ?? _players[0] ?? null
readonly property bool playing: player?.playbackState === MprisPlaybackState.Playing
// Reset index if current player disappears
on_PlayersChanged: if (_playerIdx >= _players.length)
_playerIdx = 0
property string _cachedArt: ""
property string _artTrack: ""
// Cache art URL at root level so it's captured even when panel is hidden
readonly property string _artUrl: player?.trackArtUrl ?? ""
readonly property string _currentTrack: player?.trackTitle ?? ""
on_ArtUrlChanged: if (_artUrl)
_cachedArt = _artUrl
on_CurrentTrackChanged: if (_currentTrack !== _artTrack) {
_artTrack = _currentTrack;
_cachedArt = _artUrl || "";
}
// Preload art while panel is hidden
Image {
visible: false
source: root._cachedArt
asynchronous: true
}
// Cava visualizer - 16 bars, raw output mode
property var _cavaBars: Array(16).fill(0)
property bool _cavaActive: false
on_ShowPanelChanged: {
if (_showPanel) {
_cavaKillTimer.stop();
_cavaActive = true;
} else {
_cavaKillTimer.restart();
}
}
Timer {
id: _cavaKillTimer
interval: 30000
onTriggered: root._cavaActive = false
}
Process {
id: cavaProc
running: root.playing && root._cavaActive
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"
onRead: line => {
const vals = line.split(";").filter(s => s).map(Number);
if (vals.length >= 16)
root._cavaBars = vals.map(v => v / 100);
}
}
}
required property var bar
property bool _pinned: false
readonly property bool _anyHover: root._hovered || hoverPanel.panelHovered
readonly property bool _showPanel: _anyHover || _pinned
on_AnyHoverChanged: {
if (_anyHover)
_unpinTimer.stop();
else if (_pinned)
_unpinTimer.start();
}
Timer {
id: _unpinTimer
interval: 500
onTriggered: root._pinned = false
}
M.BarIcon {
icon: root.playing ? "\uF04B" : (root.player?.playbackState === MprisPlaybackState.Paused ? "\uDB80\uDFE4" : "\uDB81\uDCDB")
anchors.verticalCenter: parent.verticalCenter
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: root._pinned = !root._pinned
}
}
M.BarLabel {
label: root.player?.trackTitle || root.player?.identity || ""
elide: Text.ElideRight
width: Math.min(implicitWidth, 200)
anchors.verticalCenter: parent.verticalCenter
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: root._pinned = !root._pinned
}
}
M.HoverPanel {
id: hoverPanel
showPanel: root._showPanel
screen: QsWindow.window?.screen ?? null
anchorItem: root
accentColor: root.accentColor
panelNamespace: "nova-mpris"
panelTitle: "Now Playing"
contentWidth: 280
C.MprisContent {
width: hoverPanel.contentWidth
player: root.player
players: root._players
playing: root.playing
accentColor: root.accentColor
cachedArt: root._cachedArt
cavaBars: root._cavaBars
playerIdx: root._playerIdx
onPlayerSwitched: idx => {
root._playerIdx = idx;
hoverPanel.keepOpen(400);
}
}
}
}

View file

@ -0,0 +1,28 @@
import QtQuick
import Quickshell
import Quickshell.Wayland
import "." as M
import "content" as C
PanelWindow {
id: root
required property var screen
color: "transparent"
WlrLayershell.layer: WlrLayer.Background
WlrLayershell.exclusiveZone: -1
WlrLayershell.namespace: "nova-overview-backdrop"
mask: Region {}
anchors.top: true
anchors.left: true
anchors.right: true
anchors.bottom: true
C.HexWaveBackground {
anchors.fill: parent
running: M.NiriIpc.overviewOpen
}
}

View file

@ -0,0 +1,91 @@
import QtQuick
import Quickshell
import "." as M
import "content" as C
M.BarSection {
id: root
spacing: Math.max(1, M.Theme.moduleSpacing - 2)
tooltip: ""
readonly property int _warm: M.Modules.temperature.warm || 80
readonly property int _hot: M.Modules.temperature.hot || 90
readonly property string _deviceFilter: M.Modules.temperature.device || ""
// If a device filter is set, use that device's temp; otherwise fall back to system max
readonly property int _temp: {
if (_deviceFilter !== "") {
const dev = M.SystemStats.tempDevices.find(d => d.name === _deviceFilter);
if (dev)
return dev.celsius;
}
return M.SystemStats.tempCelsius;
}
property color _stateColor: _temp > _hot ? M.Theme.base08 : _temp > _warm ? M.Theme.base0A : root.accentColor
Behavior on _stateColor {
ColorAnimation {
duration: 300
}
}
property bool _pinned: false
readonly property bool _anyHover: root._hovered || hoverPanel.panelHovered
readonly property bool _showPanel: _anyHover || _pinned
on_AnyHoverChanged: {
if (_anyHover)
_unpinTimer.stop();
else if (_pinned)
_unpinTimer.start();
}
Timer {
id: _unpinTimer
interval: 500
onTriggered: root._pinned = false
}
M.BarIcon {
icon: "\uF2C9"
color: root._stateColor
anchors.verticalCenter: parent.verticalCenter
TapHandler {
cursorShape: Qt.PointingHandCursor
onTapped: root._pinned = !root._pinned
}
}
M.BarLabel {
label: root._temp + "\u00B0C"
minText: "100\u00B0C"
color: root._stateColor
anchors.verticalCenter: parent.verticalCenter
TapHandler {
cursorShape: Qt.PointingHandCursor
onTapped: root._pinned = !root._pinned
}
}
M.HoverPanel {
id: hoverPanel
showPanel: root._showPanel
screen: QsWindow.window?.screen ?? null
anchorItem: root
accentColor: root.accentColor
panelNamespace: "nova-temperature"
panelTitle: "Temperature"
contentWidth: 220
C.TemperatureContent {
width: hoverPanel.contentWidth
temp: root._temp
warm: root._warm
hot: root._hot
history: M.SystemStats.tempHistory
devices: M.SystemStats.tempDevices
accentColor: root.accentColor
deviceFilter: root._deviceFilter
active: root._showPanel
}
}
}

125
shell/modules/Volume.qml Normal file
View file

@ -0,0 +1,125 @@
import QtQuick
import Quickshell
import Quickshell.Services.Pipewire
import "." as M
import "content" as C
M.BarSection {
id: root
spacing: M.Theme.moduleSpacing
tooltip: ""
PwObjectTracker {
objects: [Pipewire.defaultAudioSink, ...root._streamList]
}
readonly property var sink: Pipewire.defaultAudioSink
readonly property real volume: sink?.audio?.volume ?? 0
readonly property bool muted: sink?.audio?.muted ?? false
readonly property string _volumeIcon: muted ? "\uF026" : (volume > 0.5 ? "\uF028" : (volume > 0 ? "\uF027" : "\uF026"))
readonly property color _volumeColor: muted ? M.Theme.base04 : root.accentColor
readonly property var _sinkList: {
const sinks = [];
if (Pipewire.nodes) {
for (const node of Pipewire.nodes.values)
if (!node.isStream && node.isSink)
sinks.push(node);
}
return sinks;
}
readonly property var _streamList: {
const streams = [];
if (Pipewire.nodes) {
for (const node of Pipewire.nodes.values)
if (node.isStream && node.audio)
streams.push(node);
}
return streams;
}
property bool _osdActive: false
property bool _volumeInit: false
property bool _mutedInit: false
readonly property bool _anyHover: root._hovered || hoverPanel.panelHovered
readonly property bool _showPanel: _anyHover || _osdActive
onVolumeChanged: {
if (!_volumeInit) {
_volumeInit = true;
return;
}
_flashPanel();
}
onMutedChanged: {
if (!_mutedInit) {
_mutedInit = true;
return;
}
_flashPanel();
}
function _flashPanel() {
_osdActive = true;
_osdTimer.restart();
}
Timer {
id: _osdTimer
interval: 1500
onTriggered: root._osdActive = false
}
M.BarIcon {
icon: root._volumeIcon
minIcon: "\uF028"
color: root._volumeColor
anchors.verticalCenter: parent.verticalCenter
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: if (root.sink?.audio)
root.sink.audio.muted = !root.sink.audio.muted
}
}
M.BarLabel {
label: Math.round(root.volume * 100) + "%"
minText: "100%"
color: root._volumeColor
anchors.verticalCenter: parent.verticalCenter
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: if (root.sink?.audio)
root.sink.audio.muted = !root.sink.audio.muted
}
}
WheelHandler {
onWheel: event => {
if (!root.sink?.audio)
return;
root.sink.audio.volume = Math.max(0, root.sink.audio.volume + (event.angleDelta.y > 0 ? 0.05 : -0.05));
}
}
M.HoverPanel {
id: hoverPanel
showPanel: root._showPanel
screen: QsWindow.window?.screen ?? null
anchorItem: root
accentColor: root.accentColor
panelNamespace: "nova-volume"
panelTitle: "Sound"
contentWidth: 220
C.VolumeContent {
width: hoverPanel.contentWidth
sink: root.sink
sinkList: root._sinkList
streamList: root._streamList
accentColor: root.accentColor
}
}
}

View file

@ -0,0 +1,234 @@
import QtQuick
import ".." as M
Column {
id: root
required property var cores
required property var coreMaxFreq
required property var coreTypes
required property var processes
required property color accentColor
property bool active: true
function _loadColor(pct) {
const t = Math.max(0, Math.min(100, pct)) / 100;
const a = t < 0.5 ? M.Theme.base0B : M.Theme.base0A;
const b = t < 0.5 ? M.Theme.base0A : M.Theme.base08;
const u = t < 0.5 ? t * 2 : (t - 0.5) * 2;
return Qt.rgba(a.r + (b.r - a.r) * u, a.g + (b.g - a.g) * u, a.b + (b.b - a.b) * u, 1);
}
// Per-core rows
Repeater {
model: root.cores.length
delegate: Item {
required property int index
width: root.width
readonly property int _u: root.cores[index]?.usage ?? 0
readonly property real _f: root.cores[index]?.freq_ghz ?? 0
readonly property color _barColor: root._loadColor(_u)
readonly property bool _throttled: {
const maxF = root.coreMaxFreq[index] ?? 0;
return maxF > 0 && _f < maxF * 0.85 && _u >= 60;
}
readonly property bool _isFirstECore: {
const types = root.coreTypes;
if (!types.length || index >= types.length)
return false;
if (types[index] !== "Efficiency")
return false;
return index === 0 || types[index - 1] !== "Efficiency";
}
height: _isFirstECore ? 28 : 20
// P/E-core divider
Rectangle {
visible: parent._isFirstECore
anchors.top: parent.top
anchors.topMargin: 3
anchors.horizontalCenter: parent.horizontalCenter
width: parent.width - 16
height: 1
color: M.Theme.base03
}
// Row content pinned to bottom of delegate
Item {
anchors.bottom: parent.bottom
width: parent.width
height: 20
Text {
id: coreLabel
anchors.left: parent.left
anchors.leftMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: index
color: M.Theme.base04
font.pixelSize: M.Theme.fontSize - 2
font.family: M.Theme.fontFamily
width: 16
}
Item {
id: coreBar
anchors.left: coreLabel.right
anchors.leftMargin: 6
anchors.right: sparkline.left
anchors.rightMargin: 6
anchors.verticalCenter: parent.verticalCenter
height: 4
Rectangle {
anchors.fill: parent
color: M.Theme.base02
radius: 2
}
Rectangle {
width: parent.width * (parent.parent.parent._u / 100)
height: parent.height
color: parent.parent.parent._barColor
radius: 2
Behavior on width {
enabled: root.active
NumberAnimation {
duration: 150
}
}
}
}
// Sparkline
Canvas {
id: sparkline
anchors.right: freqLabel.left
anchors.rightMargin: 6
anchors.verticalCenter: parent.verticalCenter
width: 32
height: 10
property var _hist: root.cores[parent.parent.index]?.history ?? []
property color _col: parent.parent._barColor
on_HistChanged: if (root.active)
requestPaint()
on_ColChanged: if (root.active)
requestPaint()
onVisibleChanged: if (visible)
requestPaint()
onPaint: {
const ctx = getContext("2d");
if (!ctx)
return;
ctx.clearRect(0, 0, width, height);
const d = _hist;
if (!d.length)
return;
const bw = width / d.length;
ctx.fillStyle = _col.toString();
for (let i = 0; i < d.length; i++) {
const h = Math.max(1, height * d[i] / 100);
ctx.fillRect(i * bw, height - h, Math.max(1, bw - 0.5), h);
}
}
}
Text {
id: freqLabel
anchors.right: parent.right
anchors.rightMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: parent.parent._f.toFixed(2)
color: parent.parent._throttled ? M.Theme.base08 : M.Theme.base04
font.pixelSize: M.Theme.fontSize - 2
font.family: M.Theme.fontFamily
width: 34
horizontalAlignment: Text.AlignRight
}
}
}
}
// Process list separator
Rectangle {
width: root.width - 16
height: 1
anchors.horizontalCenter: parent.horizontalCenter
color: M.Theme.base03
}
Item {
width: root.width
height: 18
Text {
anchors.left: parent.left
anchors.leftMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: "PROCESS"
color: M.Theme.base03
font.pixelSize: M.Theme.fontSize - 3
font.family: M.Theme.fontFamily
font.letterSpacing: 1
}
Text {
anchors.right: parent.right
anchors.rightMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: "CPU"
color: M.Theme.base03
font.pixelSize: M.Theme.fontSize - 3
font.family: M.Theme.fontFamily
font.letterSpacing: 1
}
}
// Top processes by CPU
Repeater {
model: root.processes
delegate: Item {
required property var modelData
width: root.width
height: 20
Text {
anchors.left: parent.left
anchors.leftMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: modelData.cmd
color: M.Theme.base05
font.pixelSize: M.Theme.fontSize - 2
font.family: M.Theme.fontFamily
elide: Text.ElideRight
width: parent.width - 80
}
Text {
anchors.right: parent.right
anchors.rightMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: modelData.cpu.toFixed(1) + "%"
color: root._loadColor(modelData.cpu)
font.pixelSize: M.Theme.fontSize - 2
font.family: M.Theme.fontFamily
width: 36
horizontalAlignment: Text.AlignRight
}
}
}
Item {
width: 1
height: 4
}
}

View file

@ -0,0 +1,96 @@
import QtQuick
import ".." as M
Column {
id: root
required property var mounts
required property color accentColor
function _fmt(bytes) {
if (bytes >= 1e12)
return (bytes / 1e12).toFixed(1) + "T";
if (bytes >= 1e9)
return Math.round(bytes / 1e9) + "G";
if (bytes >= 1e6)
return Math.round(bytes / 1e6) + "M";
return bytes + "B";
}
function _barColor(pct) {
const t = Math.max(0, Math.min(100, pct)) / 100;
const a = t < 0.5 ? M.Theme.base0B : M.Theme.base0A;
const b = t < 0.5 ? M.Theme.base0A : M.Theme.base08;
const u = t < 0.5 ? t * 2 : (t - 0.5) * 2;
return Qt.rgba(a.r + (b.r - a.r) * u, a.g + (b.g - a.g) * u, a.b + (b.b - a.b) * u, 1);
}
Repeater {
model: root.mounts
delegate: Item {
required property var modelData
width: root.width
height: 22
Text {
id: mountLabel
anchors.left: parent.left
anchors.leftMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: modelData.target
color: M.Theme.base05
font.pixelSize: M.Theme.fontSize - 2
font.family: M.Theme.fontFamily
elide: Text.ElideRight
width: 72
}
Item {
id: mountBar
anchors.left: mountLabel.right
anchors.leftMargin: 6
anchors.right: sizeLabel.left
anchors.rightMargin: 6
anchors.verticalCenter: parent.verticalCenter
height: 4
Rectangle {
anchors.fill: parent
color: M.Theme.base02
radius: 2
}
Rectangle {
width: parent.width * (modelData.pct / 100)
height: parent.height
color: root._barColor(modelData.pct)
radius: 2
Behavior on width {
NumberAnimation {
duration: 200
}
}
}
}
Text {
id: sizeLabel
anchors.right: parent.right
anchors.rightMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: root._fmt(modelData.usedBytes) + "/" + root._fmt(modelData.totalBytes)
color: M.Theme.base04
font.pixelSize: M.Theme.fontSize - 2
font.family: M.Theme.fontFamily
width: 72
horizontalAlignment: Text.AlignRight
}
}
}
Item {
width: 1
height: 4
}
}

View file

@ -1,24 +1,12 @@
import QtQuick
import Quickshell
import Quickshell.Wayland
import "." as M
import ".." as M
PanelWindow {
Item {
id: root
required property var screen
color: "transparent"
WlrLayershell.layer: WlrLayer.Background
WlrLayershell.exclusiveZone: -1
WlrLayershell.namespace: "nova-overview-backdrop"
mask: Region {}
anchors.top: true
anchors.left: true
anchors.right: true
anchors.bottom: true
property bool running: false
property bool reducedMotion: M.Theme.reducedMotion
Rectangle {
anchors.fill: parent
@ -40,20 +28,18 @@ PanelWindow {
property color uC1: M.Theme.base0E
property color uC2: M.Theme.base09
Connections {
target: M.NiriIpc
function onOverviewOpenChanged() {
if (!M.NiriIpc.overviewOpen) {
fx.uWavePhase = -200;
fx.uBreath = 0;
}
// Reset animations when stopped
onRunningChanged: {
if (!root.running) {
fx.uWavePhase = -200;
fx.uBreath = 0;
}
}
// Wave animation: 6s sweep + 8s pause, only while overview is open
// Wave animation: 6s sweep + 8s pause
SequentialAnimation on uWavePhase {
loops: Animation.Infinite
running: M.NiriIpc.overviewOpen && !M.Theme.reducedMotion
running: root.running && !root.reducedMotion
NumberAnimation {
from: -200
to: fx.width + 200
@ -65,10 +51,10 @@ PanelWindow {
}
}
// Breathing pulse while overview is open
// Breathing pulse
SequentialAnimation on uBreath {
loops: Animation.Infinite
running: M.NiriIpc.overviewOpen && !M.Theme.reducedMotion
running: root.running && !root.reducedMotion
NumberAnimation {
from: 0
to: 1
@ -83,11 +69,11 @@ PanelWindow {
}
}
// Random subtle glitches fire every 1237s, total ~250ms each
// Random subtle glitches - fire every 12-37s
Timer {
interval: 20000
repeat: true
running: !M.Theme.reducedMotion
running: !root.reducedMotion
onTriggered: {
interval = 12000 + Math.floor(Math.random() * 25000);
fx.uGlitchSeed = Math.random() * 1000.0;

View file

@ -0,0 +1,287 @@
import QtQuick
import ".." as M
Column {
id: root
required property int percent
required property real usedGb
required property real totalGb
required property real availGb
required property real cachedGb
required property real buffersGb
required property var processes
required property color accentColor
property bool active: true
function _fmt(gb) {
return gb >= 10 ? gb.toFixed(1) + "G" : gb.toFixed(2) + "G";
}
// Usage bar
Item {
width: root.width
height: 14
Item {
id: memBar
anchors.left: parent.left
anchors.leftMargin: 12
anchors.right: parent.right
anchors.rightMargin: 12
anchors.verticalCenter: parent.verticalCenter
height: 6
Rectangle {
anchors.fill: parent
color: M.Theme.base02
radius: 3
}
// Cached (base0D, behind used)
Rectangle {
width: parent.width * Math.min(1, (root.usedGb + root.cachedGb) / Math.max(root.totalGb, 0.001))
height: parent.height
color: M.Theme.base0D
opacity: 0.4
radius: 3
Behavior on width {
enabled: root.active
NumberAnimation {
duration: 200
}
}
}
// Used (accentColor, on top)
Rectangle {
width: parent.width * Math.min(1, root.usedGb / Math.max(root.totalGb, 0.001))
height: parent.height
color: root.accentColor
radius: 3
Behavior on width {
enabled: root.active
NumberAnimation {
duration: 200
}
}
}
}
}
// Memory history sparkline
Canvas {
id: memSparkline
anchors.left: parent.left
anchors.leftMargin: 12
anchors.right: parent.right
anchors.rightMargin: 12
height: 18
property var _hist: M.SystemStats.memHistory
property color _col: root.accentColor
on_HistChanged: if (root.active)
requestPaint()
on_ColChanged: if (root.active)
requestPaint()
onVisibleChanged: if (visible)
requestPaint()
onPaint: {
const ctx = getContext("2d");
if (!ctx)
return;
ctx.clearRect(0, 0, width, height);
const d = _hist;
if (!d.length)
return;
const bw = width / 30;
ctx.fillStyle = Qt.rgba(_col.r, _col.g, _col.b, 0.15).toString();
ctx.fillRect(0, 0, width, height);
ctx.fillStyle = _col.toString();
for (let i = 0; i < d.length; i++) {
const h = Math.max(1, height * d[i] / 100);
ctx.fillRect((30 - d.length + i) * bw, height - h, Math.max(1, bw - 0.5), h);
}
}
}
// Breakdown rows
Item {
width: root.width
height: 18
Text {
anchors.left: parent.left
anchors.leftMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: "Used"
color: M.Theme.base04
font.pixelSize: M.Theme.fontSize - 2
font.family: M.Theme.fontFamily
}
Text {
anchors.right: parent.right
anchors.rightMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: root._fmt(root.usedGb)
color: M.Theme.base05
font.pixelSize: M.Theme.fontSize - 2
font.family: M.Theme.fontFamily
}
}
Item {
width: root.width
height: 18
Text {
anchors.left: parent.left
anchors.leftMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: "Cached"
color: M.Theme.base04
font.pixelSize: M.Theme.fontSize - 2
font.family: M.Theme.fontFamily
}
Text {
anchors.right: parent.right
anchors.rightMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: root._fmt(root.cachedGb)
color: M.Theme.base05
font.pixelSize: M.Theme.fontSize - 2
font.family: M.Theme.fontFamily
}
}
Item {
width: root.width
height: 18
Text {
anchors.left: parent.left
anchors.leftMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: "Available"
color: M.Theme.base04
font.pixelSize: M.Theme.fontSize - 2
font.family: M.Theme.fontFamily
}
Text {
anchors.right: parent.right
anchors.rightMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: root._fmt(root.availGb)
color: M.Theme.base05
font.pixelSize: M.Theme.fontSize - 2
font.family: M.Theme.fontFamily
}
}
Item {
width: root.width
height: 18
Text {
anchors.left: parent.left
anchors.leftMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: "Total"
color: M.Theme.base04
font.pixelSize: M.Theme.fontSize - 2
font.family: M.Theme.fontFamily
}
Text {
anchors.right: parent.right
anchors.rightMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: root._fmt(root.totalGb)
color: M.Theme.base05
font.pixelSize: M.Theme.fontSize - 2
font.family: M.Theme.fontFamily
}
}
// Process list separator
Rectangle {
width: root.width - 16
height: 1
anchors.horizontalCenter: parent.horizontalCenter
color: M.Theme.base03
}
Item {
width: root.width
height: 18
Text {
anchors.left: parent.left
anchors.leftMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: "PROCESS"
color: M.Theme.base03
font.pixelSize: M.Theme.fontSize - 3
font.family: M.Theme.fontFamily
font.letterSpacing: 1
}
Text {
anchors.right: parent.right
anchors.rightMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: "MEM"
color: M.Theme.base03
font.pixelSize: M.Theme.fontSize - 3
font.family: M.Theme.fontFamily
font.letterSpacing: 1
}
}
// Top processes by memory
Repeater {
model: root.processes
delegate: Item {
required property var modelData
width: root.width
height: 20
Text {
anchors.left: parent.left
anchors.leftMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: modelData.cmd
color: M.Theme.base05
font.pixelSize: M.Theme.fontSize - 2
font.family: M.Theme.fontFamily
elide: Text.ElideRight
width: parent.width - 80
}
Text {
anchors.right: parent.right
anchors.rightMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: modelData.mem.toFixed(1) + "%"
color: M.Theme.base04
font.pixelSize: M.Theme.fontSize - 2
font.family: M.Theme.fontFamily
width: 36
horizontalAlignment: Text.AlignRight
}
}
}
Item {
width: 1
height: 4
}
}

View file

@ -0,0 +1,361 @@
import QtQuick
import Quickshell.Services.Mpris
import ".." as M
Column {
id: root
required property var player
required property var players
required property bool playing
required property color accentColor
property string cachedArt: ""
property var cavaBars: Array(16).fill(0)
property int playerIdx: 0
signal playerSwitched(int idx)
// Album art - always 1:1, crossfades on session switch
Item {
width: root.width
height: width
clip: true
Rectangle {
anchors.fill: parent
color: M.Theme.base02
}
// Outgoing art - snaps to current opacity, then fades out
Image {
id: _artImgPrev
anchors.fill: parent
fillMode: Image.PreserveAspectCrop
asynchronous: true
opacity: 0
NumberAnimation {
id: _prevFadeOut
target: _artImgPrev
property: "opacity"
to: 0
duration: 300
easing.type: Easing.InOutCubic
}
}
// Incoming art - fades in once loaded
Image {
id: _artImg
anchors.fill: parent
fillMode: Image.PreserveAspectCrop
asynchronous: true
opacity: 0
property bool _hasArt: false
onStatusChanged: {
if (status === Image.Ready && source !== "") {
_hasArt = true;
_artFadeIn.start();
_prevFadeOut.start();
} else if (status === Image.Error) {
_hasArt = false;
}
}
NumberAnimation {
id: _artFadeIn
target: _artImg
property: "opacity"
to: 1
duration: 300
easing.type: Easing.InOutCubic
}
Connections {
target: root
function onCachedArtChanged() {
if (!root.cachedArt) {
_artFadeIn.stop();
_prevFadeOut.stop();
_artImg._hasArt = false;
_artImg.opacity = 0;
_artImgPrev.opacity = 0;
_artImg.source = "";
} else if (root.cachedArt !== _artImg.source) {
_prevFadeOut.stop();
_artFadeIn.stop();
_artImgPrev.source = _artImg.source;
_artImgPrev.opacity = _artImg.opacity;
_artImg.opacity = 0;
_artImg.source = root.cachedArt;
}
}
}
}
// Visualizer bars
Row {
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
height: parent.height * 0.6
spacing: 2
visible: root.playing
opacity: 0.5
Repeater {
model: 16
Rectangle {
required property int index
width: (parent.width - 15 * parent.spacing) / 16
height: parent.height * (root.cavaBars[index] ?? 0)
anchors.bottom: parent.bottom
color: root.accentColor
radius: 1
Behavior on height {
NumberAnimation {
duration: 50
}
}
}
}
}
Rectangle {
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
height: 40
visible: _artImg._hasArt
gradient: Gradient {
GradientStop {
position: 0
color: "transparent"
}
GradientStop {
position: 1
color: M.Theme.base01
}
}
}
Text {
anchors.centerIn: parent
text: "\uF001"
color: M.Theme.base04
font.pixelSize: 28
font.family: M.Theme.iconFontFamily
visible: !_artImg._hasArt
}
}
// Track info
Item {
width: root.width
height: titleCol.implicitHeight + 8
Column {
id: titleCol
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
anchors.leftMargin: 12
anchors.rightMargin: 12
spacing: 2
Text {
width: parent.width
text: root.player?.trackTitle || "No track"
color: M.Theme.base05
font.pixelSize: M.Theme.fontSize + 1
font.family: M.Theme.fontFamily
font.bold: true
elide: Text.ElideRight
}
Text {
width: parent.width
text: {
const p = root.player;
if (!p)
return "";
const artist = Array.isArray(p.trackArtists) ? p.trackArtists.join(", ") : (p.trackArtists || "");
return [artist, p.trackAlbum].filter(s => s).join(" \u2014 ");
}
color: M.Theme.base04
font.pixelSize: M.Theme.fontSize - 1
font.family: M.Theme.fontFamily
elide: Text.ElideRight
visible: text !== ""
}
}
}
// Progress
Item {
width: root.width
height: 20
readonly property real pos: root.player?.position ?? 0
readonly property real dur: root.player?.length ?? 0
readonly property real frac: dur > 0 ? pos / dur : 0
function _fmtTime(ms) {
const s = Math.floor(ms / 1000);
const m = Math.floor(s / 60);
return m + ":" + String(s % 60).padStart(2, "0");
}
Text {
anchors.left: parent.left
anchors.leftMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: parent._fmtTime(parent.pos)
color: M.Theme.base04
font.pixelSize: M.Theme.fontSize - 2
font.family: M.Theme.fontFamily
}
Text {
anchors.right: parent.right
anchors.rightMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: parent._fmtTime(parent.dur)
color: M.Theme.base04
font.pixelSize: M.Theme.fontSize - 2
font.family: M.Theme.fontFamily
}
Item {
anchors.horizontalCenter: parent.horizontalCenter
anchors.verticalCenter: parent.verticalCenter
width: parent.width - 80
height: 4
Rectangle {
anchors.fill: parent
color: M.Theme.base02
radius: 2
}
Rectangle {
width: parent.width * Math.min(1, Math.max(0, parent.parent.frac))
height: parent.height
color: root.accentColor
radius: 2
}
}
}
// Transport controls
Item {
width: root.width
height: 36
Row {
anchors.centerIn: parent
spacing: 24
Text {
text: "\uF048"
color: root.player?.canGoPrevious ? M.Theme.base05 : M.Theme.base03
font.pixelSize: M.Theme.fontSize + 4
font.family: M.Theme.iconFontFamily
anchors.verticalCenter: parent.verticalCenter
TapHandler {
cursorShape: Qt.PointingHandCursor
enabled: root.player?.canGoPrevious ?? false
onTapped: root.player.previous()
}
}
Text {
text: root.playing ? "\uF04C" : "\uF04B"
color: root.accentColor
font.pixelSize: M.Theme.fontSize + 8
font.family: M.Theme.iconFontFamily
anchors.verticalCenter: parent.verticalCenter
TapHandler {
cursorShape: Qt.PointingHandCursor
onTapped: root.player?.togglePlaying()
}
}
Text {
text: "\uF051"
color: root.player?.canGoNext ? M.Theme.base05 : M.Theme.base03
font.pixelSize: M.Theme.fontSize + 4
font.family: M.Theme.iconFontFamily
anchors.verticalCenter: parent.verticalCenter
TapHandler {
cursorShape: Qt.PointingHandCursor
enabled: root.player?.canGoNext ?? false
onTapped: root.player.next()
}
}
}
}
// Player switcher
Item {
width: root.width
height: root.players.length > 1 ? 28 : 0
visible: root.players.length > 1
Flickable {
id: _switcher
anchors.centerIn: parent
width: Math.min(_playerRow.implicitWidth, parent.width - 16)
height: 22
contentWidth: _playerRow.implicitWidth
clip: true
Row {
id: _playerRow
height: 22
spacing: 6
Repeater {
model: root.players
delegate: Rectangle {
required property var modelData
required property int index
readonly property bool _active: index === root.playerIdx
width: _pLabel.implicitWidth + 12
height: 18
radius: 9
color: _active ? M.Theme.base02 : (pHover.hovered ? M.Theme.base02 : "transparent")
border.color: _active ? root.accentColor : M.Theme.base03
border.width: _active ? 1 : 0
anchors.verticalCenter: parent.verticalCenter
Text {
id: _pLabel
anchors.centerIn: parent
text: modelData.identity ?? "Player"
color: _active ? root.accentColor : M.Theme.base04
font.pixelSize: M.Theme.fontSize - 2
font.family: M.Theme.fontFamily
font.bold: _active
}
HoverHandler {
id: pHover
cursorShape: Qt.PointingHandCursor
}
TapHandler {
onTapped: {
root.playerSwitched(index);
}
}
}
}
}
}
}
}

View file

@ -0,0 +1,265 @@
import QtQuick
import ".." as M
Column {
id: root
required property int temp
required property int warm
required property int hot
required property var history
required property var devices
required property color accentColor
property bool active: true
property string deviceFilter: ""
property color stateColor: temp > hot ? M.Theme.base08 : temp > warm ? M.Theme.base0A : root.accentColor
Behavior on stateColor {
ColorAnimation {
duration: 300
}
}
function _tempColor(celsius) {
const t = Math.max(0, Math.min(100, celsius)) / 100;
const a = t < 0.5 ? M.Theme.base0B : M.Theme.base0A;
const b = t < 0.5 ? M.Theme.base0A : M.Theme.base08;
const u = t < 0.5 ? t * 2 : (t - 0.5) * 2;
return Qt.rgba(a.r + (b.r - a.r) * u, a.g + (b.g - a.g) * u, a.b + (b.b - a.b) * u, 1);
}
// Header - current temp
Item {
width: root.width
height: 28
Text {
anchors.right: parent.right
anchors.rightMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: root.temp + "\u00B0C"
color: root.stateColor
font.pixelSize: M.Theme.fontSize
font.family: M.Theme.fontFamily
font.bold: true
width: _tempSizer.implicitWidth
horizontalAlignment: Text.AlignRight
Text {
id: _tempSizer
visible: false
text: "100\u00B0C"
font: parent.font
}
}
}
// Gauge bar (0-100C), with warm/hot threshold markers
Item {
width: root.width
height: 16
Item {
id: _gaugeBar
anchors.left: parent.left
anchors.leftMargin: 12
anchors.right: parent.right
anchors.rightMargin: 12
anchors.verticalCenter: parent.verticalCenter
height: 6
Rectangle {
anchors.fill: parent
color: M.Theme.base02
radius: 3
}
Rectangle {
width: parent.width * Math.min(1, root.temp / 100)
height: parent.height
color: root.stateColor
radius: 3
Behavior on width {
enabled: root.active
NumberAnimation {
duration: 300
easing.type: Easing.OutCubic
}
}
}
// Warm threshold marker
Rectangle {
x: parent.width * (root.warm / 100) - 1
width: 1
height: parent.height + 4
anchors.verticalCenter: parent.verticalCenter
color: M.Theme.base0A
opacity: 0.6
}
// Hot threshold marker
Rectangle {
x: parent.width * (root.hot / 100) - 1
width: 1
height: parent.height + 4
anchors.verticalCenter: parent.verticalCenter
color: M.Theme.base08
opacity: 0.6
}
}
}
// History sparkline
Canvas {
id: _sparkline
anchors.left: parent.left
anchors.leftMargin: 12
anchors.right: parent.right
anchors.rightMargin: 12
height: 40
property var _hist: root.history
property color _col: root.stateColor
on_HistChanged: if (root.active)
requestPaint()
on_ColChanged: if (root.active)
requestPaint()
onVisibleChanged: if (visible)
requestPaint()
onPaint: {
const ctx = getContext("2d");
if (!ctx)
return;
ctx.clearRect(0, 0, width, height);
const d = _hist;
if (!d.length)
return;
const maxSamples = 150;
const bw = width / maxSamples;
// Background tint
ctx.fillStyle = Qt.rgba(_col.r, _col.g, _col.b, 0.08).toString();
ctx.fillRect(0, 0, width, height);
// Warm threshold line
const warmY = height - height * (root.warm / 100);
ctx.strokeStyle = M.Theme.base0A.toString();
ctx.globalAlpha = 0.3;
ctx.lineWidth = 1;
ctx.setLineDash([3, 3]);
ctx.beginPath();
ctx.moveTo(0, warmY);
ctx.lineTo(width, warmY);
ctx.stroke();
// Hot threshold line
const hotY = height - height * (root.hot / 100);
ctx.strokeStyle = M.Theme.base08.toString();
ctx.beginPath();
ctx.moveTo(0, hotY);
ctx.lineTo(width, hotY);
ctx.stroke();
ctx.setLineDash([]);
ctx.globalAlpha = 1.0;
// Bars
const offset = maxSamples - d.length;
for (let i = 0; i < d.length; i++) {
const barH = Math.max(1, height * d[i] / 100);
const barColor = d[i] > root.hot ? M.Theme.base08 : d[i] > root.warm ? M.Theme.base0A : _col;
ctx.fillStyle = barColor.toString();
ctx.fillRect((offset + i) * bw, height - barH, Math.max(1, bw - 0.5), barH);
}
}
}
// Threshold labels
Item {
width: root.width
height: 16
Text {
anchors.left: parent.left
anchors.leftMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: "warm " + root.warm + "\u00B0 hot " + root.hot + "\u00B0"
color: M.Theme.base03
font.pixelSize: M.Theme.fontSize - 3
font.family: M.Theme.fontFamily
font.letterSpacing: 0.5
}
Text {
anchors.right: parent.right
anchors.rightMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: "10 min"
color: M.Theme.base03
font.pixelSize: M.Theme.fontSize - 3
font.family: M.Theme.fontFamily
}
}
// Per-device breakdown
Rectangle {
width: root.width - 16
height: 1
anchors.horizontalCenter: parent.horizontalCenter
color: M.Theme.base03
visible: root.devices.length > 0
}
Repeater {
model: root.devices
delegate: Item {
required property var modelData
width: root.width
height: 22
readonly property bool _isActive: root.deviceFilter === modelData.name
Rectangle {
anchors.fill: parent
anchors.leftMargin: 8
anchors.rightMargin: 8
color: _isActive ? Qt.rgba(root.accentColor.r, root.accentColor.g, root.accentColor.b, 0.12) : "transparent"
radius: 3
}
Text {
anchors.left: parent.left
anchors.leftMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: modelData.name
color: _isActive ? root.accentColor : M.Theme.base04
font.pixelSize: M.Theme.fontSize - 2
font.family: M.Theme.fontFamily
elide: Text.ElideRight
width: parent.width - 80
}
Text {
anchors.right: parent.right
anchors.rightMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: modelData.celsius + "\u00B0C"
color: root._tempColor(modelData.celsius)
font.pixelSize: M.Theme.fontSize - 2
font.family: M.Theme.fontFamily
font.bold: _isActive
}
}
}
Item {
width: 1
height: 4
}
}

View file

@ -0,0 +1,292 @@
import QtQuick
import Quickshell.Services.Pipewire
import ".." as M
Column {
id: root
required property var sink
required property var sinkList
required property var streamList
required property color accentColor
property real volume: sink?.audio?.volume ?? 0
property bool muted: sink?.audio?.muted ?? false
readonly property string volumeIcon: muted ? "\uF026" : (volume > 0.5 ? "\uF028" : (volume > 0 ? "\uF027" : "\uF026"))
readonly property color volumeColor: muted ? M.Theme.base04 : root.accentColor
// Slider row
Item {
width: root.width
height: 36
Text {
id: muteIcon
anchors.left: parent.left
anchors.leftMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: root.volumeIcon
color: root.volumeColor
font.pixelSize: M.Theme.fontSize + 2
font.family: M.Theme.iconFontFamily
TapHandler {
cursorShape: Qt.PointingHandCursor
onTapped: if (root.sink?.audio)
root.sink.audio.muted = !root.sink.audio.muted
}
}
Item {
id: slider
anchors.left: muteIcon.right
anchors.leftMargin: 8
anchors.right: volLabel.left
anchors.rightMargin: 8
anchors.verticalCenter: parent.verticalCenter
height: 6
Rectangle {
anchors.fill: parent
color: M.Theme.base02
radius: 3
}
Rectangle {
width: parent.width * Math.min(1, Math.max(0, root.volume))
height: parent.height
color: root.volumeColor
radius: 3
Behavior on width {
NumberAnimation {
duration: 80
}
}
}
MouseArea {
anchors.fill: parent
anchors.margins: -6
cursorShape: Qt.PointingHandCursor
onPressed: mouse => _setVol(mouse)
onPositionChanged: mouse => {
if (pressed)
_setVol(mouse);
}
function _setVol(mouse) {
if (!root.sink?.audio)
return;
root.sink.audio.volume = Math.max(0, Math.min(1, mouse.x / slider.width));
}
}
}
Text {
id: volLabel
anchors.right: parent.right
anchors.rightMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: Math.round(root.volume * 100) + "%"
color: root.muted ? M.Theme.base04 : M.Theme.base05
font.pixelSize: M.Theme.fontSize
font.family: M.Theme.fontFamily
width: 30
}
}
// Device + stream list
Column {
id: deviceList
width: root.width
// Output devices - only shown when more than one exists
Column {
visible: root.sinkList.length > 1
width: parent.width
Rectangle {
width: parent.width - 16
height: 1
anchors.horizontalCenter: parent.horizontalCenter
color: M.Theme.base03
}
Text {
width: parent.width
height: 24
verticalAlignment: Text.AlignVCenter
leftPadding: 12
text: "Output Devices"
color: root.accentColor
font.pixelSize: M.Theme.fontSize - 1
font.family: M.Theme.fontFamily
}
Repeater {
model: root.sinkList
delegate: Item {
required property var modelData
width: root.width
height: 28
readonly property bool _active: modelData === root.sink
Rectangle {
anchors.fill: parent
anchors.leftMargin: 4
anchors.rightMargin: 4
color: deviceHover.hovered ? M.Theme.base02 : "transparent"
radius: M.Theme.radius
}
Text {
anchors.left: parent.left
anchors.leftMargin: 12
anchors.right: parent.right
anchors.rightMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: modelData.description || modelData.name || "Unknown"
color: parent._active ? root.accentColor : M.Theme.base05
font.pixelSize: M.Theme.fontSize
font.family: M.Theme.fontFamily
font.bold: parent._active
elide: Text.ElideRight
}
HoverHandler {
id: deviceHover
cursorShape: Qt.PointingHandCursor
}
TapHandler {
onTapped: Pipewire.preferredDefaultAudioSink = modelData
}
}
}
}
// Streams section
Rectangle {
visible: root.streamList.length > 0
width: parent.width - 16
height: visible ? 1 : 0
anchors.horizontalCenter: parent.horizontalCenter
color: M.Theme.base03
}
Text {
visible: root.streamList.length > 0
width: parent.width
height: visible ? 24 : 0
verticalAlignment: Text.AlignVCenter
leftPadding: 12
text: "Applications"
color: root.accentColor
font.pixelSize: M.Theme.fontSize - 1
font.family: M.Theme.fontFamily
}
Repeater {
model: root.streamList
delegate: Item {
id: streamEntry
required property var modelData
width: root.width
height: 32
readonly property string _appName: modelData.properties["application.name"] || modelData.description || modelData.name || "Unknown"
readonly property real _vol: modelData.audio?.volume ?? 0
readonly property bool _muted: modelData.audio?.muted ?? false
Text {
id: streamIcon
anchors.left: parent.left
anchors.leftMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: streamEntry._muted ? "\uF026" : "\uF028"
color: streamEntry._muted ? M.Theme.base04 : M.Theme.base05
font.pixelSize: M.Theme.fontSize
font.family: M.Theme.iconFontFamily
TapHandler {
cursorShape: Qt.PointingHandCursor
onTapped: if (streamEntry.modelData.audio)
streamEntry.modelData.audio.muted = !streamEntry.modelData.audio.muted
}
}
Text {
id: streamName
anchors.left: streamIcon.right
anchors.leftMargin: 6
anchors.verticalCenter: parent.verticalCenter
text: streamEntry._appName
color: M.Theme.base05
font.pixelSize: M.Theme.fontSize - 1
font.family: M.Theme.fontFamily
elide: Text.ElideRight
width: 70
}
Item {
id: streamSlider
anchors.left: streamName.right
anchors.leftMargin: 6
anchors.right: streamVol.left
anchors.rightMargin: 6
anchors.verticalCenter: parent.verticalCenter
height: 4
Rectangle {
anchors.fill: parent
color: M.Theme.base02
radius: 2
}
Rectangle {
width: parent.width * Math.min(1, Math.max(0, streamEntry._vol))
height: parent.height
color: streamEntry._muted ? M.Theme.base04 : root.accentColor
radius: 2
}
MouseArea {
anchors.fill: parent
anchors.margins: -6
cursorShape: Qt.PointingHandCursor
onPressed: mouse => _set(mouse)
onPositionChanged: mouse => {
if (pressed)
_set(mouse);
}
function _set(mouse) {
if (!streamEntry.modelData.audio)
return;
streamEntry.modelData.audio.volume = Math.max(0, Math.min(1, mouse.x / streamSlider.width));
}
}
}
Text {
id: streamVol
anchors.right: parent.right
anchors.rightMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: Math.round(streamEntry._vol * 100) + "%"
color: streamEntry._muted ? M.Theme.base04 : M.Theme.base05
font.pixelSize: M.Theme.fontSize - 1
font.family: M.Theme.fontFamily
width: 28
}
}
}
// Bottom padding
Item {
width: 1
height: 4
}
}
}

View file

@ -0,0 +1,8 @@
module content
HexWaveBackground 1.0 HexWaveBackground.qml
VolumeContent 1.0 VolumeContent.qml
MprisContent 1.0 MprisContent.qml
CpuContent 1.0 CpuContent.qml
MemoryContent 1.0 MemoryContent.qml
TemperatureContent 1.0 TemperatureContent.qml
DiskContent 1.0 DiskContent.qml

View file

@ -1,7 +1,7 @@
//@ pragma Env QS_NO_RELOAD_POPUP=1
import "modules"
import "modules/lock" as Lock
import "lock" as Lock
import Quickshell
ShellRoot {