From 9fde6d4fc60601b12fe4e6c233032fea42a66b3c Mon Sep 17 00:00:00 2001 From: Damocles Date: Fri, 10 Apr 2026 10:49:48 +0200 Subject: [PATCH] initial commit --- flake.lock | 69 ++++++++++++++++++++++++++ flake.nix | 72 +++++++++++++++++++++++++++ modules/Backlight.qml | 38 ++++++++++++++ modules/Bar.qml | 79 +++++++++++++++++++++++++++++ modules/Battery.qml | 32 ++++++++++++ modules/Bluetooth.qml | 38 ++++++++++++++ modules/Clock.qml | 16 ++++++ modules/Cpu.qml | 51 +++++++++++++++++++ modules/Disk.qml | 38 ++++++++++++++ modules/IdleInhibitor.qml | 31 ++++++++++++ modules/Memory.qml | 36 ++++++++++++++ modules/Mpris.qml | 32 ++++++++++++ modules/Network.qml | 51 +++++++++++++++++++ modules/Notifications.qml | 58 ++++++++++++++++++++++ modules/PowerProfile.qml | 35 +++++++++++++ modules/Temperature.qml | 27 ++++++++++ modules/Theme.qml | 53 ++++++++++++++++++++ modules/Tray.qml | 40 +++++++++++++++ modules/Volume.qml | 37 ++++++++++++++ modules/Weather.qml | 37 ++++++++++++++ modules/WindowTitle.qml | 12 +++++ modules/Wlogout.qml | 19 +++++++ modules/Workspaces.qml | 36 ++++++++++++++ modules/qmldir | 22 +++++++++ nix/hm-module.nix | 101 ++++++++++++++++++++++++++++++++++++++ nix/package.nix | 35 +++++++++++++ shell.qml | 15 ++++++ 27 files changed, 1110 insertions(+) create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 modules/Backlight.qml create mode 100644 modules/Bar.qml create mode 100644 modules/Battery.qml create mode 100644 modules/Bluetooth.qml create mode 100644 modules/Clock.qml create mode 100644 modules/Cpu.qml create mode 100644 modules/Disk.qml create mode 100644 modules/IdleInhibitor.qml create mode 100644 modules/Memory.qml create mode 100644 modules/Mpris.qml create mode 100644 modules/Network.qml create mode 100644 modules/Notifications.qml create mode 100644 modules/PowerProfile.qml create mode 100644 modules/Temperature.qml create mode 100644 modules/Theme.qml create mode 100644 modules/Tray.qml create mode 100644 modules/Volume.qml create mode 100644 modules/Weather.qml create mode 100644 modules/WindowTitle.qml create mode 100644 modules/Wlogout.qml create mode 100644 modules/Workspaces.qml create mode 100644 modules/qmldir create mode 100644 nix/hm-module.nix create mode 100644 nix/package.nix create mode 100644 shell.qml diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..185668b --- /dev/null +++ b/flake.lock @@ -0,0 +1,69 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1775423009, + "narHash": "sha256-vPKLpjhIVWdDrfiUM8atW6YkIggCEKdSAlJPzzhkQlw=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "68d8aa3d661f0e6bd5862291b5bb263b2a6595c9", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "quickshell": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1775720097, + "narHash": "sha256-p+vqkCuFfVNyQBo370wr6MebNUvz55RZiC0m8YKUhvQ=", + "ref": "refs/heads/master", + "rev": "d4c92973b53d9fa34cc110d3b974eb6bde5b3027", + "revCount": 800, + "type": "git", + "url": "https://git.outfoxxed.me/outfoxxed/quickshell" + }, + "original": { + "type": "git", + "url": "https://git.outfoxxed.me/outfoxxed/quickshell" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs", + "quickshell": "quickshell", + "treefmt-nix": "treefmt-nix" + } + }, + "treefmt-nix": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1775636079, + "narHash": "sha256-pc20NRoMdiar8oPQceQT47UUZMBTiMdUuWrYu2obUP0=", + "owner": "numtide", + "repo": "treefmt-nix", + "rev": "790751ff7fd3801feeaf96d7dc416a8d581265ba", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "treefmt-nix", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..a7240ca --- /dev/null +++ b/flake.nix @@ -0,0 +1,72 @@ +{ + description = "nova-shell - minimal Quickshell bar for niri"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + + quickshell = { + url = "git+https://git.outfoxxed.me/outfoxxed/quickshell"; + inputs.nixpkgs.follows = "nixpkgs"; + }; + + treefmt-nix = { + url = "github:numtide/treefmt-nix"; + inputs.nixpkgs.follows = "nixpkgs"; + }; + }; + + outputs = + { + self, + nixpkgs, + quickshell, + treefmt-nix, + ... + }: + let + systems = [ + "x86_64-linux" + "aarch64-linux" + ]; + treefmt-config = { + projectRootFile = "flake.nix"; + programs.nixfmt.enable = true; + }; + forAllSystems = + fn: + nixpkgs.lib.genAttrs systems ( + system: + fn rec { + pkgs = nixpkgs.legacyPackages.${system}; + treefmt-eval = treefmt-nix.lib.evalModule pkgs treefmt-config; + } + ); + in + { + formatter = forAllSystems ({ treefmt-eval, ... }: treefmt-eval.config.build.wrapper); + + packages = forAllSystems ( + { pkgs, ... }: + rec { + nova-shell = pkgs.callPackage ./nix/package.nix { + quickshell = quickshell.packages.${pkgs.stdenv.hostPlatform.system}.default.override { + withX11 = false; + withI3 = false; + }; + }; + default = nova-shell; + } + ); + + checks = forAllSystems ( + { pkgs, treefmt-eval }: + { + formatting = treefmt-eval.config.build.check self; + build = self.packages.${pkgs.stdenv.hostPlatform.system}.default; + } + ); + + homeModules.default = import ./nix/hm-module.nix self; + homeManagerModules.default = self.homeModules.default; + }; +} diff --git a/modules/Backlight.qml b/modules/Backlight.qml new file mode 100644 index 0000000..ba90450 --- /dev/null +++ b/modules/Backlight.qml @@ -0,0 +1,38 @@ +import QtQuick +import Quickshell.Io +import "." as M + +Row { + id: root + spacing: 4 + visible: percent > 0 + + property int percent: 0 + + FileView { + id: current + path: "/sys/class/backlight/intel_backlight/brightness" + watchChanges: true + onFileChanged: reload() + onLoaded: root._update() + } + FileView { + id: max + path: "/sys/class/backlight/intel_backlight/max_brightness" + onLoaded: root._update() + } + + function _update() { + const c = parseInt(current.text()); + const m = parseInt(max.text()); + if (m > 0) root.percent = Math.round((c / m) * 100); + } + + Text { + text: root.percent + "% " + color: M.Theme.base05 + font.pixelSize: M.Theme.fontSize + font.family: M.Theme.fontFamily + anchors.verticalCenter: parent.verticalCenter + } +} diff --git a/modules/Bar.qml b/modules/Bar.qml new file mode 100644 index 0000000..6b23515 --- /dev/null +++ b/modules/Bar.qml @@ -0,0 +1,79 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Wayland +import "." as M + +PanelWindow { + id: bar + + required property var screen + + color: "transparent" + + anchors { + top: true + left: true + right: true + } + + implicitHeight: M.Theme.barHeight + exclusiveZone: implicitHeight + + Rectangle { + anchors.fill: parent + color: M.Theme.base00 + opacity: M.Theme.barOpacity + } + + RowLayout { + anchors.fill: parent + anchors.leftMargin: 8 + anchors.rightMargin: 8 + spacing: 8 + + // ---- left ---- + RowLayout { + Layout.alignment: Qt.AlignLeft + spacing: 8 + + M.Workspaces {} + M.Tray { bar: bar } + M.WindowTitle { Layout.maximumWidth: 400 } + } + + Item { Layout.fillWidth: true } + + // ---- center ---- + RowLayout { + Layout.alignment: Qt.AlignHCenter + spacing: 8 + + M.Clock {} + M.Notifications {} + } + + Item { Layout.fillWidth: true } + + // ---- right ---- + RowLayout { + Layout.alignment: Qt.AlignRight + spacing: 12 + + M.Mpris {} + M.Volume {} + M.Bluetooth {} + M.Backlight {} + M.Network {} + M.PowerProfile {} + M.IdleInhibitor {} + M.Weather {} + M.Temperature {} + M.Cpu {} + M.Memory {} + M.Disk {} + M.Battery {} + M.Wlogout {} + } + } +} diff --git a/modules/Battery.qml b/modules/Battery.qml new file mode 100644 index 0000000..ac1649f --- /dev/null +++ b/modules/Battery.qml @@ -0,0 +1,32 @@ +import QtQuick +import Quickshell.Services.UPower +import "." as M + +Row { + id: root + spacing: 4 + visible: UPower.displayDevice?.isLaptopBattery ?? false + + readonly property var dev: UPower.displayDevice + readonly property real pct: (dev?.percentage ?? 0) * 100 + readonly property bool charging: dev?.state === UPowerDeviceState.Charging + + Text { + text: { + if (root.charging) return ""; + const icons = ["󰂎","󰁺","󰁻","󰁼","󰁽","󰁾","󰁿","󰂀","󰂁","󰂂","󱟢"]; + return icons[Math.min(10, Math.floor(root.pct / 10))]; + } + color: root.pct < 15 ? M.Theme.base08 : M.Theme.base05 + font.pixelSize: M.Theme.fontSize + 2 + font.family: M.Theme.fontFamily + anchors.verticalCenter: parent.verticalCenter + } + Text { + text: Math.round(root.pct) + "%" + color: root.pct < 15 ? M.Theme.base08 : M.Theme.base05 + font.pixelSize: M.Theme.fontSize + font.family: M.Theme.fontFamily + anchors.verticalCenter: parent.verticalCenter + } +} diff --git a/modules/Bluetooth.qml b/modules/Bluetooth.qml new file mode 100644 index 0000000..c6e530e --- /dev/null +++ b/modules/Bluetooth.qml @@ -0,0 +1,38 @@ +import QtQuick +import Quickshell.Io +import "." as M + +Row { + id: root + spacing: 4 + + property string status: "off" + property string device: "" + + Process { + id: proc + running: true + command: ["sh", "-c", "bluetoothctl info 2>/dev/null | awk -F': ' '/Name/ {n=$2} /Connected: yes/ {c=1} END {if (c) print n; else print \"\"}'"] + stdout: StdioCollector { + onStreamFinished: { + const t = text.trim(); + root.device = t; + root.status = t ? "connected" : "on"; + } + } + } + Timer { + interval: 10000 + running: true + repeat: true + onTriggered: proc.running = true + } + + Text { + text: root.status === "connected" ? (" " + root.device) : "" + color: M.Theme.base05 + font.pixelSize: M.Theme.fontSize + 1 + font.family: M.Theme.fontFamily + anchors.verticalCenter: parent.verticalCenter + } +} diff --git a/modules/Clock.qml b/modules/Clock.qml new file mode 100644 index 0000000..340da86 --- /dev/null +++ b/modules/Clock.qml @@ -0,0 +1,16 @@ +import QtQuick +import Quickshell +import "." as M + +Text { + SystemClock { + id: clock + precision: SystemClock.Minutes + } + + text: Qt.formatDateTime(clock.date, "ddd, dd. MMM HH:mm") + color: M.Theme.base05 + font.pixelSize: M.Theme.fontSize + 1 + font.family: M.Theme.fontFamily + verticalAlignment: Text.AlignVCenter +} diff --git a/modules/Cpu.qml b/modules/Cpu.qml new file mode 100644 index 0000000..331f9a3 --- /dev/null +++ b/modules/Cpu.qml @@ -0,0 +1,51 @@ +import QtQuick +import Quickshell.Io +import "." as M + +Text { + id: root + + property int usage: 0 + property real freqGhz: 0 + + property var _prev: null + + FileView { + id: stat + path: "/proc/stat" + onLoaded: { + const line = text().split("\n")[0]; + const f = line.trim().split(/\s+/).slice(1).map(Number); + const idle = f[3] + f[4]; + const total = f.reduce((a, b) => a + b, 0); + if (root._prev) { + const dIdle = idle - root._prev.idle; + const dTotal = total - root._prev.total; + if (dTotal > 0) root.usage = Math.round((1 - dIdle / dTotal) * 100); + } + root._prev = { idle, total }; + } + } + FileView { + id: cpuinfo + path: "/proc/cpuinfo" + onLoaded: { + const lines = text().split("\n").filter(l => l.startsWith("cpu MHz")); + if (lines.length === 0) return; + const sum = lines.reduce((a, l) => a + parseFloat(l.split(":")[1]), 0); + root.freqGhz = sum / lines.length / 1000; + } + } + Timer { + interval: 1000 + running: true + repeat: true + onTriggered: { stat.reload(); cpuinfo.reload(); } + } + + text: " " + root.usage.toString().padStart(2) + "%@" + root.freqGhz.toFixed(2) + color: M.Theme.base05 + font.pixelSize: M.Theme.fontSize + font.family: M.Theme.fontFamily + verticalAlignment: Text.AlignVCenter +} diff --git a/modules/Disk.qml b/modules/Disk.qml new file mode 100644 index 0000000..39969e0 --- /dev/null +++ b/modules/Disk.qml @@ -0,0 +1,38 @@ +import QtQuick +import Quickshell.Io +import "." as M + +Text { + id: root + + property int freePct: 0 + property real totalTb: 0 + + Process { + id: proc + running: true + command: ["sh", "-c", "df -B1 --output=size,avail / | tail -1"] + stdout: StdioCollector { + onStreamFinished: { + const parts = text.trim().split(/\s+/).map(Number); + const size = parts[0], avail = parts[1]; + if (size > 0) { + root.freePct = Math.round((avail / size) * 100); + root.totalTb = size / 1e12; + } + } + } + } + Timer { + interval: 30000 + running: true + repeat: true + onTriggered: proc.running = true + } + + text: " " + root.freePct + "% " + root.totalTb.toFixed(1) + color: M.Theme.base05 + font.pixelSize: M.Theme.fontSize + font.family: M.Theme.fontFamily + verticalAlignment: Text.AlignVCenter +} diff --git a/modules/IdleInhibitor.qml b/modules/IdleInhibitor.qml new file mode 100644 index 0000000..171f890 --- /dev/null +++ b/modules/IdleInhibitor.qml @@ -0,0 +1,31 @@ +import QtQuick +import Quickshell.Io +import "." as M + +Text { + id: root + + property bool active: false + + text: root.active ? "" : "" + color: M.Theme.base05 + font.pixelSize: M.Theme.fontSize + 1 + font.family: M.Theme.fontFamily + verticalAlignment: Text.AlignVCenter + + Process { + id: toggle + command: ["sh", "-c", root.active + ? "pkill -x systemd-inhibit || true" + : "systemd-inhibit --what=idle --who=nova-shell --why=user sleep infinity &"] + } + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: { + root.active = !root.active; + toggle.running = true; + } + } +} diff --git a/modules/Memory.qml b/modules/Memory.qml new file mode 100644 index 0000000..e20b0ae --- /dev/null +++ b/modules/Memory.qml @@ -0,0 +1,36 @@ +import QtQuick +import Quickshell.Io +import "." as M + +Text { + id: root + + property int percent: 0 + + FileView { + id: meminfo + path: "/proc/meminfo" + onLoaded: { + const m = {}; + text().split("\n").forEach(l => { + const [k, v] = l.split(":"); + if (v) m[k.trim()] = parseInt(v.trim()); + }); + const total = m.MemTotal; + const avail = m.MemAvailable; + if (total > 0) root.percent = Math.round(((total - avail) / total) * 100); + } + } + Timer { + interval: 2000 + running: true + repeat: true + onTriggered: meminfo.reload() + } + + text: " " + root.percent + "%" + color: M.Theme.base05 + font.pixelSize: M.Theme.fontSize + font.family: M.Theme.fontFamily + verticalAlignment: Text.AlignVCenter +} diff --git a/modules/Mpris.qml b/modules/Mpris.qml new file mode 100644 index 0000000..826f193 --- /dev/null +++ b/modules/Mpris.qml @@ -0,0 +1,32 @@ +import QtQuick +import Quickshell.Services.Mpris +import "." as M + +Row { + id: root + spacing: 4 + visible: player !== null + + readonly property MprisPlayer player: Mpris.players.values[0] ?? null + readonly property bool playing: player?.playbackState === MprisPlaybackState.Playing + + Text { + text: root.playing ? "" : (root.player?.playbackState === MprisPlaybackState.Paused ? "󰏤" : "󰓛") + color: M.Theme.base05 + font.pixelSize: M.Theme.fontSize + 1 + font.family: M.Theme.fontFamily + anchors.verticalCenter: parent.verticalCenter + } + Text { + text: root.player?.identity ?? "" + color: M.Theme.base05 + font.pixelSize: M.Theme.fontSize + font.family: M.Theme.fontFamily + anchors.verticalCenter: parent.verticalCenter + } + + MouseArea { + anchors.fill: parent + onClicked: root.player?.togglePlaying() + } +} diff --git a/modules/Network.qml b/modules/Network.qml new file mode 100644 index 0000000..3950a1d --- /dev/null +++ b/modules/Network.qml @@ -0,0 +1,51 @@ +import QtQuick +import Quickshell.Io +import "." as M + +Row { + id: root + spacing: 4 + + property string ifname: "" + property string essid: "" + property string state: "disconnected" + + Process { + id: proc + running: true + command: ["sh", "-c", "nmcli -t -f NAME,TYPE,DEVICE connection show --active | head -1"] + stdout: StdioCollector { + onStreamFinished: { + const line = text.trim(); + if (!line) { + root.state = "disconnected"; + root.essid = ""; + root.ifname = ""; + return; + } + const parts = line.split(":"); + root.essid = parts[0] || ""; + root.ifname = parts[2] || ""; + root.state = (parts[1] || "").includes("wireless") ? "wifi" : "eth"; + } + } + } + Timer { + interval: 5000 + running: true + repeat: true + onTriggered: proc.running = true + } + + Text { + text: { + if (root.state === "wifi") return " " + root.essid; + if (root.state === "eth") return "󰈀"; + return "󰣽"; + } + color: M.Theme.base05 + font.pixelSize: M.Theme.fontSize + 1 + font.family: M.Theme.fontFamily + anchors.verticalCenter: parent.verticalCenter + } +} diff --git a/modules/Notifications.qml b/modules/Notifications.qml new file mode 100644 index 0000000..2342d1c --- /dev/null +++ b/modules/Notifications.qml @@ -0,0 +1,58 @@ +import QtQuick +import Quickshell.Io +import "." as M + +Row { + id: root + spacing: 4 + + property int count: 0 + property bool dnd: false + + Process { + id: sub + running: true + command: ["swaync-client", "--subscribe-waybar"] + stdout: SplitParser { + splitMarker: "\n" + onRead: (line) => { + try { + const d = JSON.parse(line); + root.count = d.count ?? 0; + root.dnd = (d.class ?? "").includes("dnd"); + } catch (e) {} + } + } + } + + Text { + text: { + if (root.dnd) return root.count > 0 ? "󰂠" : "󰪓"; + return root.count > 0 ? "󱅫" : "󰂜"; + } + color: M.Theme.base05 + font.pixelSize: M.Theme.fontSize + 2 + font.family: M.Theme.fontFamily + anchors.verticalCenter: parent.verticalCenter + } + Text { + text: root.count > 0 ? String(root.count) : "" + color: M.Theme.base05 + font.pixelSize: M.Theme.fontSize + font.family: M.Theme.fontFamily + anchors.verticalCenter: parent.verticalCenter + } + + MouseArea { + anchors.fill: parent + acceptedButtons: Qt.LeftButton | Qt.RightButton + onClicked: (m) => { + const cmd = m.button === Qt.RightButton + ? ["swaync-client", "--toggle-dnd", "--skip-wait"] + : ["swaync-client", "--toggle-panel", "--skip-wait"]; + clicker.command = cmd; + clicker.running = true; + } + } + Process { id: clicker } +} diff --git a/modules/PowerProfile.qml b/modules/PowerProfile.qml new file mode 100644 index 0000000..940e248 --- /dev/null +++ b/modules/PowerProfile.qml @@ -0,0 +1,35 @@ +import QtQuick +import Quickshell.Io +import "." as M + +Text { + id: root + + property string profile: "" + + Process { + id: proc + running: true + command: ["powerprofilesctl", "get"] + stdout: StdioCollector { + onStreamFinished: root.profile = text.trim() + } + } + Timer { + interval: 5000 + running: true + repeat: true + onTriggered: proc.running = true + } + + text: { + if (root.profile === "performance") return ""; + if (root.profile === "power-saver") return ""; + if (root.profile === "balanced") return ""; + return ""; + } + color: M.Theme.base05 + font.pixelSize: M.Theme.fontSize + 1 + font.family: M.Theme.fontFamily + verticalAlignment: Text.AlignVCenter +} diff --git a/modules/Temperature.qml b/modules/Temperature.qml new file mode 100644 index 0000000..59c40b4 --- /dev/null +++ b/modules/Temperature.qml @@ -0,0 +1,27 @@ +import QtQuick +import Quickshell.Io +import "." as M + +Text { + id: root + + property int celsius: 0 + + FileView { + id: thermal + path: "/sys/class/thermal/thermal_zone0/temp" + onLoaded: root.celsius = Math.round(parseInt(text()) / 1000) + } + Timer { + interval: 2000 + running: true + repeat: true + onTriggered: thermal.reload() + } + + text: " " + root.celsius + "°C" + color: root.celsius > 80 ? M.Theme.base08 : M.Theme.base05 + font.pixelSize: M.Theme.fontSize + font.family: M.Theme.fontFamily + verticalAlignment: Text.AlignVCenter +} diff --git a/modules/Theme.qml b/modules/Theme.qml new file mode 100644 index 0000000..e242923 --- /dev/null +++ b/modules/Theme.qml @@ -0,0 +1,53 @@ +pragma Singleton + +import QtQuick +import Quickshell +import Quickshell.Io + +QtObject { + id: root + + // base16 palette, overwritten from ~/.config/nova-shell/theme.json + property color base00: "#1e1e2e" + property color base01: "#181825" + property color base02: "#313244" + property color base03: "#45475a" + property color base04: "#585b70" + property color base05: "#cdd6f4" + property color base06: "#f5e0dc" + property color base07: "#b4befe" + property color base08: "#f38ba8" + property color base09: "#fab387" + property color base0A: "#f9e2af" + property color base0B: "#a6e3a1" + property color base0C: "#94e2d5" + property color base0D: "#89b4fa" + property color base0E: "#cba6f7" + property color base0F: "#f2cdcd" + + property string fontFamily: "sans-serif" + property int fontSize: 12 + property real barOpacity: 0.9 + property int barHeight: 32 + + property FileView _themeFile: FileView { + path: (Quickshell.env("XDG_CONFIG_HOME") || (Quickshell.env("HOME") + "/.config")) + + "/nova-shell/theme.json" + watchChanges: true + onFileChanged: reload() + onLoaded: root._apply(text()) + } + + function _apply(raw) { + let data; + try { data = JSON.parse(raw); } catch (e) { return; } + const c = data.colors || {}; + for (const k of Object.keys(c)) { + if (k in root) root[k] = c[k]; + } + if (data.fontFamily) root.fontFamily = data.fontFamily; + if (data.fontSize) root.fontSize = data.fontSize; + if (data.barOpacity !== undefined) root.barOpacity = data.barOpacity; + if (data.barHeight !== undefined) root.barHeight = data.barHeight; + } +} diff --git a/modules/Tray.qml b/modules/Tray.qml new file mode 100644 index 0000000..830d5f8 --- /dev/null +++ b/modules/Tray.qml @@ -0,0 +1,40 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Services.SystemTray + +RowLayout { + id: root + spacing: 6 + + required property var bar + + Repeater { + model: SystemTray.items + + delegate: Item { + id: iconItem + required property SystemTrayItem modelData + + implicitWidth: 18 + implicitHeight: 18 + + IconImage { + anchors.fill: parent + source: iconItem.modelData.icon + } + + MouseArea { + anchors.fill: parent + acceptedButtons: Qt.LeftButton | Qt.RightButton + onClicked: (mouse) => { + if (mouse.button === Qt.LeftButton) { + iconItem.modelData.activate(); + } else if (mouse.button === Qt.RightButton) { + iconItem.modelData.display(root.bar, mouse.x, mouse.y); + } + } + } + } + } +} diff --git a/modules/Volume.qml b/modules/Volume.qml new file mode 100644 index 0000000..bfa78d2 --- /dev/null +++ b/modules/Volume.qml @@ -0,0 +1,37 @@ +import QtQuick +import Quickshell.Services.Pipewire +import "." as M + +Row { + id: root + spacing: 4 + + PwObjectTracker { + objects: [Pipewire.defaultAudioSink] + } + + readonly property var sink: Pipewire.defaultAudioSink + readonly property real volume: sink?.audio?.volume ?? 0 + readonly property bool muted: sink?.audio?.muted ?? false + + Text { + text: root.muted ? "" : (root.volume > 0.5 ? "" : (root.volume > 0 ? "" : "")) + color: M.Theme.base05 + font.pixelSize: M.Theme.fontSize + 1 + font.family: M.Theme.fontFamily + anchors.verticalCenter: parent.verticalCenter + } + + Text { + text: Math.round(root.volume * 100) + "%" + color: M.Theme.base05 + font.pixelSize: M.Theme.fontSize + font.family: M.Theme.fontFamily + anchors.verticalCenter: parent.verticalCenter + } + + MouseArea { + anchors.fill: parent + onClicked: if (root.sink?.audio) root.sink.audio.muted = !root.sink.audio.muted + } +} diff --git a/modules/Weather.qml b/modules/Weather.qml new file mode 100644 index 0000000..f4cf705 --- /dev/null +++ b/modules/Weather.qml @@ -0,0 +1,37 @@ +import QtQuick +import Quickshell.Io +import "." as M + +Text { + id: root + + property string label: "" + + Process { + id: proc + running: true + command: ["wttrbar", "--nerd"] + stdout: StdioCollector { + onStreamFinished: { + try { + const data = JSON.parse(text); + root.label = data.text ?? ""; + } catch (e) { + root.label = ""; + } + } + } + } + Timer { + interval: 3600000 + running: true + repeat: true + onTriggered: proc.running = true + } + + text: root.label + color: M.Theme.base05 + font.pixelSize: M.Theme.fontSize + font.family: M.Theme.fontFamily + verticalAlignment: Text.AlignVCenter +} diff --git a/modules/WindowTitle.qml b/modules/WindowTitle.qml new file mode 100644 index 0000000..86a2532 --- /dev/null +++ b/modules/WindowTitle.qml @@ -0,0 +1,12 @@ +import QtQuick +import Quickshell.Services.Niri +import "." as M + +Text { + text: Niri.focusedWindow?.title ?? "" + color: M.Theme.base05 + font.pixelSize: M.Theme.fontSize + font.family: M.Theme.fontFamily + elide: Text.ElideRight + verticalAlignment: Text.AlignVCenter +} diff --git a/modules/Wlogout.qml b/modules/Wlogout.qml new file mode 100644 index 0000000..5422986 --- /dev/null +++ b/modules/Wlogout.qml @@ -0,0 +1,19 @@ +import QtQuick +import Quickshell.Io +import "." as M + +Text { + text: "" + color: M.Theme.base05 + font.pixelSize: M.Theme.fontSize + 2 + font.family: M.Theme.fontFamily + verticalAlignment: Text.AlignVCenter + + Process { id: proc; command: ["wlogout"] } + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: proc.running = true + } +} diff --git a/modules/Workspaces.qml b/modules/Workspaces.qml new file mode 100644 index 0000000..1cc1b12 --- /dev/null +++ b/modules/Workspaces.qml @@ -0,0 +1,36 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell.Services.Niri +import "." as M + +RowLayout { + spacing: 4 + + Repeater { + model: Niri.workspaces + + delegate: Rectangle { + required property var modelData + + implicitWidth: 24 + implicitHeight: 20 + radius: 4 + color: modelData.isFocused + ? M.Theme.base0D + : (modelData.isActive ? M.Theme.base03 : M.Theme.base02) + + Text { + anchors.centerIn: parent + text: modelData.idx ?? modelData.id + color: modelData.isFocused ? M.Theme.base00 : M.Theme.base05 + font.pixelSize: M.Theme.fontSize + font.family: M.Theme.fontFamily + } + + MouseArea { + anchors.fill: parent + onClicked: Niri.dispatch(["action", "focus-workspace", String(modelData.id)]) + } + } + } +} diff --git a/modules/qmldir b/modules/qmldir new file mode 100644 index 0000000..f0c494e --- /dev/null +++ b/modules/qmldir @@ -0,0 +1,22 @@ +module modules +singleton Theme 1.0 Theme.qml +Bar 1.0 Bar.qml +Workspaces 1.0 Workspaces.qml +WindowTitle 1.0 WindowTitle.qml +Clock 1.0 Clock.qml +Volume 1.0 Volume.qml +Tray 1.0 Tray.qml +Battery 1.0 Battery.qml +Mpris 1.0 Mpris.qml +Network 1.0 Network.qml +Bluetooth 1.0 Bluetooth.qml +Backlight 1.0 Backlight.qml +Cpu 1.0 Cpu.qml +Memory 1.0 Memory.qml +Disk 1.0 Disk.qml +Temperature 1.0 Temperature.qml +Weather 1.0 Weather.qml +PowerProfile 1.0 PowerProfile.qml +IdleInhibitor 1.0 IdleInhibitor.qml +Notifications 1.0 Notifications.qml +Wlogout 1.0 Wlogout.qml diff --git a/nix/hm-module.nix b/nix/hm-module.nix new file mode 100644 index 0000000..9391307 --- /dev/null +++ b/nix/hm-module.nix @@ -0,0 +1,101 @@ +self: +{ + config, + lib, + pkgs, + ... +}: +let + cfg = config.programs.nova-shell; + + stylixAvailable = config ? lib && config.lib ? stylix; + + stylixTheme = lib.mkIf stylixAvailable ( + let + c = config.lib.stylix.colors.withHashtag; + f = config.stylix.fonts; + in + { + colors = { + inherit (c) + base00 + base01 + base02 + base03 + base04 + base05 + base06 + base07 + base08 + base09 + base0A + base0B + base0C + base0D + base0E + base0F + ; + }; + fontFamily = f.sansSerif.name; + fontSize = f.sizes.desktop; + barOpacity = 1.0 - config.stylix.opacity.desktop; + } + ); +in +{ + options.programs.nova-shell = { + enable = lib.mkEnableOption "nova-shell Quickshell bar"; + + package = lib.mkOption { + type = lib.types.package; + default = self.packages.${pkgs.stdenv.hostPlatform.system}.default; + description = "nova-shell package to use."; + }; + + theme = lib.mkOption { + type = lib.types.attrsOf lib.types.anything; + default = { }; + description = '' + Theme overrides written to `$XDG_CONFIG_HOME/nova-shell/theme.json`. + Keys: colors (base00-base0F), fontFamily, fontSize, barOpacity, barHeight. + Automatically populated from stylix when it is available. + ''; + }; + + systemd = { + enable = lib.mkOption { + type = lib.types.bool; + default = true; + description = "Run nova-shell as a systemd user service."; + }; + target = lib.mkOption { + type = lib.types.str; + default = "graphical-session.target"; + description = "Systemd target to bind the service to."; + }; + }; + }; + + config = lib.mkIf cfg.enable { + programs.nova-shell.theme = lib.mkIf stylixAvailable (lib.mkDefault stylixTheme); + + home.packages = [ cfg.package ]; + + xdg.configFile."nova-shell/theme.json".source = + (pkgs.formats.json { }).generate "nova-shell-theme.json" cfg.theme; + + systemd.user.services.nova-shell = lib.mkIf cfg.systemd.enable { + Unit = { + Description = "nova-shell Quickshell bar"; + PartOf = [ cfg.systemd.target ]; + After = [ cfg.systemd.target ]; + }; + Service = { + ExecStart = lib.getExe cfg.package; + Restart = "on-failure"; + Slice = "session.slice"; + }; + Install.WantedBy = [ cfg.systemd.target ]; + }; + }; +} diff --git a/nix/package.nix b/nix/package.nix new file mode 100644 index 0000000..3fc5a20 --- /dev/null +++ b/nix/package.nix @@ -0,0 +1,35 @@ +{ + lib, + stdenvNoCC, + makeWrapper, + quickshell, +}: +stdenvNoCC.mkDerivation { + pname = "nova-shell"; + version = "0.1.0"; + + src = lib.cleanSource ../.; + + nativeBuildInputs = [ makeWrapper ]; + + dontBuild = true; + + installPhase = '' + runHook preInstall + + mkdir -p $out/share/nova-shell + cp -r shell.qml modules $out/share/nova-shell/ + + mkdir -p $out/bin + makeWrapper ${lib.getExe quickshell} $out/bin/nova-shell \ + --add-flags "-p $out/share/nova-shell/shell.qml" + + runHook postInstall + ''; + + meta = { + description = "Minimal Quickshell bar for niri"; + mainProgram = "nova-shell"; + platforms = lib.platforms.linux; + }; +} diff --git a/shell.qml b/shell.qml new file mode 100644 index 0000000..c7424a5 --- /dev/null +++ b/shell.qml @@ -0,0 +1,15 @@ +//@ pragma Env QS_NO_RELOAD_POPUP=1 + +import "modules" +import Quickshell + +ShellRoot { + Variants { + model: Quickshell.screens + + delegate: Bar { + required property var modelData + screen: modelData + } + } +}