From f8a4536a02e9ce2d78eabf8df0b7ccc59b8ebb15 Mon Sep 17 00:00:00 2001 From: Asmir A Date: Sun, 31 May 2026 09:28:44 +0200 Subject: [PATCH] add full proj --- .gitignore | 19 +++ CLAUDE.md | 80 ++++++++++++ README.md | 103 +++++++++++++++ config/Icons.qml | 38 ++++++ config/Theme.qml | 49 +++++++ config/qmldir | 2 + docs/bar.png | Bin 0 -> 14459 bytes flake.lock | 48 +++++++ flake.nix | 85 ++++++++++++ services/SysStats.qml | 290 +++++++++++++++++++++++++++++++++++++++++ services/discover.sh | 59 +++++++++ services/qmldir | 1 + shell.qml | 13 ++ widgets/Bar.qml | 69 ++++++++++ widgets/Battery.qml | 17 +++ widgets/Clock.qml | 52 ++++++++ widgets/CpuGraph.qml | 66 ++++++++++ widgets/CpuTemp.qml | 13 ++ widgets/Disk.qml | 9 ++ widgets/MetricPill.qml | 40 ++++++ widgets/Network.qml | 43 ++++++ widgets/Pill.qml | 23 ++++ widgets/Ram.qml | 9 ++ widgets/Tray.qml | 62 +++++++++ widgets/Volume.qml | 63 +++++++++ widgets/Workspaces.qml | 54 ++++++++ 26 files changed, 1307 insertions(+) create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 README.md create mode 100644 config/Icons.qml create mode 100644 config/Theme.qml create mode 100644 config/qmldir create mode 100644 docs/bar.png create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 services/SysStats.qml create mode 100755 services/discover.sh create mode 100644 services/qmldir create mode 100644 shell.qml create mode 100644 widgets/Bar.qml create mode 100644 widgets/Battery.qml create mode 100644 widgets/Clock.qml create mode 100644 widgets/CpuGraph.qml create mode 100644 widgets/CpuTemp.qml create mode 100644 widgets/Disk.qml create mode 100644 widgets/MetricPill.qml create mode 100644 widgets/Network.qml create mode 100644 widgets/Pill.qml create mode 100644 widgets/Ram.qml create mode 100644 widgets/Tray.qml create mode 100644 widgets/Volume.qml create mode 100644 widgets/Workspaces.qml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..023c4cd --- /dev/null +++ b/.gitignore @@ -0,0 +1,19 @@ +# Nix build outputs +result +result-* + +# direnv / nix develop +.direnv/ +.envrc.local + +# Editor / IDE +*.swp +*.swo +*~ +.vscode/ +.idea/ +.qmlls.ini + +# OS cruft +.DS_Store +Thumbs.db diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..eee4dea --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,80 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +A Wayland status bar for Sway/i3, written entirely in **QML** and driven by +[Quickshell](https://quickshell.outfoxxed.me/), packaged with a Nix flake. + +## Commands + +```sh +# run without building (hot-reloads on file save) +nix develop # shell with quickshell + qmlls/qmlformat on PATH +quickshell --path . # or: qs -p . + +nix run . # run straight from the flake +nix build . && ./result/bin/quickshell-bar # build the wrapper, then run +``` + +There is **no test suite, no linter, and no build step** for the QML itself — +it's interpreted and hot-reloaded. The only "build" is the Nix wrapper that +pins fonts and runtime tools. `qmlformat`/`qmlls` (from `qt6.qtdeclarative`, +available in the dev shell) are the only static tooling. Verify changes by +running the bar and looking at it. + +## Architecture + +**Entry point.** `shell.qml` → `ShellRoot` with `Variants { model: Quickshell.screens }` +spawns one `widgets/Bar.qml` (`PanelWindow`) per monitor. `Bar.qml` is a fixed +three-zone layout: workspaces (left), clock (center), a `RowLayout` of metric +modules (right). Changing module order/presence is purely editing that RowLayout. + +**Three import roots, registered via `qmldir`:** +- `config/` — `Theme` and `Icons`, both **singletons** (`pragma Singleton` + + `singleton` line in `config/qmldir`). Global palette/geometry/fonts and Nerd + Font glyphs. Import with `import "../config"`, reference as `Theme.x` / `Icons.x`. +- `services/` — `SysStats`, a **singleton** that is the single source of truth + for all `/proc` + `/sys` metrics. Import with `import "../services"`. +- `widgets/` — the visual modules. `Pill.qml` is the rounded container every + module reuses (`default property alias content` lets children sit inside a + centered RowLayout); `MetricPill.qml` adds the icon + value convention. + +**The metrics pipeline (`services/SysStats.qml`) is the core design.** One `Timer` +(1 Hz, `interval` property) calls `_tickOnce()`, which `reload()`s a set of +`FileView`s and parses them into reactive properties (`cpu`, `cpuFreq`, `mem`, +`temp`, rates, battery, …). Widgets just bind to those properties. Key conventions: +- `FileView { blockLoading: true }` makes `reload()` synchronous so `text()` is + fresh on the same tick. Use this for tiny virtual files in `/proc`/`/sys`. +- Add a metric by: declaring a property, adding a `FileView` (or reusing one by + reassigning its `path`, as `_parseNet` does for the active interface), writing + a `_parseX()`, and calling `reload()` + `_parseX()` inside `_tickOnce()`. +- Deltas (CPU%, net rates) keep a `_prev*` field and diff against the last tick. +- **Two metrics escape the pure-virtual-file rule:** disk usage runs `df` via a + `Process` only every 30 ticks (`_tick % 30`), and a one-shot `Process` runs + `services/discover.sh` at startup. The bar avoids per-tick subprocess spawns + by design — keep new metrics reading virtual files, not shelling out. + +**`discover.sh` handshake.** At startup it prints `TEMP ` and `BAT ` +(hwmon temp sensor + main battery, skipping `scope=Device` peripherals) on stdout; +the QML side parses those lines into `tempPath`/`batPath`, which then feed +`FileView`s. sysfs paths are stable for a boot, so this runs once, not per tick. + +## Conventions & gotchas + +- **Icons are referenced by Unicode codepoint** (`config/Icons.qml`, + `String.fromCharCode(0x...)`) so source stays ASCII. The glyph **must exist in + the bundled Nerd Font** — `flake.nix` bundles `nerd-fonts.jetbrains-mono` + + `nerd-fonts.symbols-only` (Font Awesome 4 range, plus Material Design). Font + Awesome 5+ codepoints are NOT present and render as tofu squares; verify a + codepoint is covered before using it. Codepoints above `0xFFFF` (e.g. Material + Design `0xf0xxx`) need `String.fromCodePoint`, not `String.fromCharCode`. +- **Fonts are pinned via `FONTCONFIG_FILE`** in the flake wrapper, so glyphs + render regardless of the host's installed fonts. Adding a font means editing + `fontsConf` in `flake.nix`. +- **`Bar.qml` remaps its layer surface on geometry change** (`geomKey` → + toggle `visible`). This is a deliberate workaround for stale-buffer garbage + after output rotation/transform under Sway — don't remove it. +- **Quickshell upstream is tracked directly** in `flake.nix` (git, not nixpkgs) + to pick up layer-surface fixes ahead of release. +- Native Quickshell services back some modules: `Quickshell.I3` (workspaces), + `Quickshell.Services.SystemTray` (tray), PipeWire (volume). diff --git a/README.md b/README.md new file mode 100644 index 0000000..d0fbacb --- /dev/null +++ b/README.md @@ -0,0 +1,103 @@ +# quickshell-bar + +A Wayland status bar for [Sway](https://swaywm.org/), built with +[Quickshell](https://quickshell.outfoxxed.me/) and packaged with Nix flakes. + +![bar](docs/bar.png) + +## Modules + +Left → right: + +| Module | Source | +|-------------|------------------------------------------| +| Workspaces | Sway/i3 IPC (`Quickshell.I3`) | +| Clock | local time (center) | +| CPU | `/proc/stat` (per-core load bars + overall %) | +| CPU temp | `coretemp`/`k10temp` hwmon, else thermal | +| RAM | `/proc/meminfo` | +| Disk | `df /` (every 30s) | +| Network | `/sys/class/net//statistics` ↓↑ | +| Volume | PipeWire default sink (scroll/click) | +| Tray | StatusNotifier (`Quickshell.Services.SystemTray`) | +| Battery | first non-peripheral `power_supply` | + +System metrics are read straight from `/proc` and `/sys` in QML via +`FileView`, polled once a second (`services/SysStats.qml`) — no persistent +helper process and no per-tick `awk`/`df`/`cat` spawns. Two exceptions have +no virtual-file equivalent: hardware discovery runs once at startup +(`services/discover.sh`, locating the hwmon temp sensor and main battery), +and disk usage uses `df` refreshed every 30s (the kernel exposes free space +only via the `statvfs` syscall). Workspaces, volume, tray and battery icon +use native Quickshell services. + +## Requirements + +- Sway (or i3) — the workspace module talks to `$SWAYSOCK`/`$I3SOCK`. +- A running PipeWire session for the volume module. +- Nix with flakes enabled (`experimental-features = nix-command flakes`). + +Fonts (JetBrains Mono Nerd Font + Inter) are bundled into the package via +`FONTCONFIG_FILE`, so glyphs render even if they aren't installed system-wide. + +## Run + +```sh +# run directly from the flake +nix run . + +# or build a wrapper and run it +nix build . +./result/bin/quickshell-bar +``` + +During development you can also run it without building: + +```sh +nix develop # drops you into a shell with quickshell on PATH +quickshell --path . # or: qs -p . +``` + +## Use it from Sway + +Add to `~/.config/sway/config`: + +``` +# hide the built-in swaybar +bar { mode invisible } + +# launch the Quickshell bar (adjust the path to this repo) +exec nix run /path/to/quickshell_bar +``` + +Or, if you install the package (e.g. into your system/Home-Manager profile), +just `exec quickshell-bar`. The bar anchors to the top edge and reserves an +exclusive zone, so windows tile beneath it automatically. + +## Configuration + +Everything is plain QML — edit and the bar hot-reloads. + +- **Colours, sizes, fonts** — `config/Theme.qml` (Catppuccin Mocha by default). +- **Icons** — `config/Icons.qml` (Nerd Font codepoints). +- **Poll interval** — `interval` in `services/SysStats.qml` (kept in sync with + the argument passed to `stats.sh`). +- **Module order / layout** — `widgets/Bar.qml`. + +## Layout + +``` +shell.qml entry point — one Bar per monitor +config/ + Theme.qml colours, sizes, fonts (singleton) + Icons.qml Nerd Font glyphs (singleton) +services/ + SysStats.qml reads /proc & /sys via FileView into reactive props (singleton) + discover.sh one-shot hwmon/battery path discovery at startup +widgets/ + Bar.qml the PanelWindow + layout + Pill.qml rounded container used by every module + MetricPill.qml icon + value helper + Workspaces.qml Clock.qml CpuGraph.qml CpuTemp.qml + Ram.qml Disk.qml Network.qml Volume.qml Tray.qml Battery.qml +``` diff --git a/config/Icons.qml b/config/Icons.qml new file mode 100644 index 0000000..4a7b354 --- /dev/null +++ b/config/Icons.qml @@ -0,0 +1,38 @@ +pragma Singleton + +import Quickshell + +// Nerd Font glyphs referenced by codepoint so the source stays ASCII and the +// exact glyph is unambiguous (independent of editor/font rendering). +// Codepoints are from the Font Awesome (FA) range bundled in Nerd Fonts. +Singleton { + readonly property string cpu: String.fromCharCode(0xf2db) // microchip + readonly property string memory: String.fromCodePoint(0xf035b) // md-memory (FA5 0xf538 is absent from the bundled Nerd Fonts) + readonly property string thermo: String.fromCharCode(0xf2c9) // thermometer-half + readonly property string disk: String.fromCharCode(0xf0a0) // hdd-o + readonly property string wifi: String.fromCharCode(0xf1eb) // wifi + readonly property string ethernet: String.fromCharCode(0xf6ff) // network-wired + readonly property string down: String.fromCharCode(0xf063) // arrow-down + readonly property string up: String.fromCharCode(0xf062) // arrow-up + readonly property string clock: String.fromCharCode(0xf017) // clock-o + readonly property string volHigh: String.fromCharCode(0xf028) // volume-up + readonly property string volLow: String.fromCharCode(0xf027) // volume-down + readonly property string volMute: String.fromCharCode(0xf026) // volume-off + readonly property string bolt: String.fromCharCode(0xf0e7) // bolt (charging) + readonly property string plug: String.fromCharCode(0xf1e6) // plug (AC) + + // battery glyphs full -> empty (FA battery-4 .. battery-0) + readonly property var batterySteps: [ + String.fromCharCode(0xf244), // empty + String.fromCharCode(0xf243), // quarter + String.fromCharCode(0xf242), // half + String.fromCharCode(0xf241), // three-quarters + String.fromCharCode(0xf240) // full + ] + + function battery(pct) { + var i = Math.round((pct / 100) * 4); + if (i < 0) i = 0; if (i > 4) i = 4; + return batterySteps[i]; + } +} diff --git a/config/Theme.qml b/config/Theme.qml new file mode 100644 index 0000000..e7f1718 --- /dev/null +++ b/config/Theme.qml @@ -0,0 +1,49 @@ +pragma Singleton + +import Quickshell +import QtQuick + +Singleton { + // --- geometry --- + readonly property int barHeight: 34 + readonly property int radius: 8 + readonly property int spacing: 6 + readonly property int padding: 10 + readonly property int gap: 4 + + // --- palette (Catppuccin Mocha, darkened toward OLED black) --- + readonly property color base: "#0d0d12" + readonly property color mantle: "#08080b" + readonly property color surface0: "#1c1c28" + readonly property color surface1: "#2a2a3a" + readonly property color overlay: "#52526a" + readonly property color text: "#cdd6f4" + readonly property color subtext: "#a6adc8" + + readonly property color rosewater: "#f5e0dc" + readonly property color red: "#f38ba8" + readonly property color peach: "#fab387" + readonly property color yellow: "#f9e2af" + readonly property color green: "#a6e3a1" + readonly property color teal: "#94e2d5" + readonly property color sky: "#89dceb" + readonly property color blue: "#89b4fa" + readonly property color mauve: "#cba6f7" + readonly property color lavender: "#b4befe" + + // bar background, slightly translucent + readonly property color barColor: Qt.rgba(0.051, 0.051, 0.071, 0.94) + + // --- typography --- + readonly property string font: "Inter, sans-serif" + readonly property string monoFont: "JetBrainsMono Nerd Font, monospace" + readonly property int fontSize: 15 + + // map a 0..100 load value to a colour (green -> yellow -> red) + function loadColor(pct) { + if (pct >= 85) return red; + if (pct >= 60) return peach; + if (pct >= 35) return yellow; + return green; + } +} diff --git a/config/qmldir b/config/qmldir new file mode 100644 index 0000000..b255122 --- /dev/null +++ b/config/qmldir @@ -0,0 +1,2 @@ +singleton Theme 1.0 Theme.qml +singleton Icons 1.0 Icons.qml diff --git a/docs/bar.png b/docs/bar.png new file mode 100644 index 0000000000000000000000000000000000000000..07c70066f2845e7bd11ca1aef2fa7807453013ee GIT binary patch literal 14459 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/dev/null | head -1)" + echo "Run the bar with: quickshell --path ." + ''; + }; + }); + + formatter = forAllSystems (pkgs: pkgs.nixpkgs-fmt); + }; +} diff --git a/services/SysStats.qml b/services/SysStats.qml new file mode 100644 index 0000000..508b41c --- /dev/null +++ b/services/SysStats.qml @@ -0,0 +1,290 @@ +pragma Singleton + +import Quickshell +import Quickshell.Io +import QtQuick + +// System metrics gathered by reading /proc and /sys directly from QML via +// FileView, polled by a Timer. No persistent helper process and no per-tick +// subprocess churn (awk/df/cat). Two exceptions that have no virtual-file +// equivalent: hardware discovery (one-shot at startup) and disk usage +// (df, refreshed infrequently). +Singleton { + id: root + + // --- live values, updated each tick --- + property real cpu: 0 // % + property real cpuFreq: 0 // MHz, max across all cores + property real mem: 0 // % + property int temp: 0 // °C + property int disk: 0 // % used on / + property string iface: "" // active interface name + property real rxRate: 0 // bytes/sec down + property real txRate: 0 // bytes/sec up + property int battery: -1 // %, -1 if none + property string batteryStatus: "unknown" + + // per-core utilization, 0..100, index = core number + property var coreLoads: [] + property int coreCount: 0 + + readonly property bool hasBattery: battery >= 0 + + // poll interval in seconds + readonly property int interval: 1 + + // discovered sysfs paths (filled once by discover.sh) + property string tempPath: "" + property string batPath: "" + + // --- previous-sample state for delta computations --- + property double _prevCpuTotal: 0 + property double _prevCpuIdle: 0 + property var _prevCoreTotal: [] + property var _prevCoreIdle: [] + property double _prevRx: 0 + property double _prevTx: 0 + property string _prevIface: "" + property int _tick: 0 + + // ---- file readers ------------------------------------------------------ + // blockLoading makes reload() synchronous so text() is fresh on the + // same tick. These are tiny virtual files, so the cost is negligible. + + FileView { id: statView; path: "/proc/stat"; blockLoading: true } + FileView { id: cpuInfoView; path: "/proc/cpuinfo"; blockLoading: true } + FileView { id: memView; path: "/proc/meminfo"; blockLoading: true } + FileView { id: routeView; path: "/proc/net/route"; blockLoading: true } + FileView { + id: tempView + path: root.tempPath + blockLoading: true + } + FileView { + id: batCapView + path: root.batPath ? root.batPath + "/capacity" : "" + blockLoading: true + } + FileView { + id: batStatusView + path: root.batPath ? root.batPath + "/status" : "" + blockLoading: true + } + // network counters: path follows the active interface + FileView { id: rxView; blockLoading: true } + FileView { id: txView; blockLoading: true } + + // ---- parsing ----------------------------------------------------------- + + // Utilization from a /proc/stat "cpu..." field array, given the previous + // total/idle for that line. Returns { load, total, idle }. + function _cpuLineLoad(f, prevTotal, prevIdle) { + let total = 0; + for (let i = 0; i < f.length; i++) total += f[i]; + const idle = f[3] + f[4]; // idle + iowait + const dt = total - prevTotal; + const di = idle - prevIdle; + let load = 0; + if (prevTotal !== 0 && dt > 0) + load = Math.max(0, Math.min(100, (1 - di / dt) * 100)); + return { load: load, total: total, idle: idle }; + } + + function _parseCpu() { + const text = statView.text(); + if (!text) return; + const lines = text.split("\n"); + const loads = []; + const prevT = root._prevCoreTotal.slice(); + const prevI = root._prevCoreIdle.slice(); + for (let li = 0; li < lines.length; li++) { + const ln = lines[li]; + if (!ln.startsWith("cpu")) break; // cpu lines lead /proc/stat + const parts = ln.trim().split(/\s+/); + const f = parts.slice(1).map(Number); + if (f.length < 5) continue; + if (parts[0] === "cpu") { // aggregate -> overall % + const r = root._cpuLineLoad(f, root._prevCpuTotal, root._prevCpuIdle); + root.cpu = r.load; + root._prevCpuTotal = r.total; + root._prevCpuIdle = r.idle; + } else { // "cpuN" -> per-core + const idx = parseInt(parts[0].slice(3)); + if (isNaN(idx)) continue; + const r = root._cpuLineLoad(f, prevT[idx] || 0, prevI[idx] || 0); + loads[idx] = Math.round(r.load); // integer %: stable, lets the redraw guard below short-circuit + prevT[idx] = r.total; + prevI[idx] = r.idle; + } + } + root._prevCoreTotal = prevT; + root._prevCoreIdle = prevI; + // coreLoads is a var (array): reassigning always fires the change + // signal and re-evaluates every bar binding. Only assign when a core's + // value actually changed so unchanged ticks trigger no redraw. + if (!_sameLoads(loads, root.coreLoads)) { + root.coreLoads = loads; + root.coreCount = loads.length; + } + } + + function _sameLoads(a, b) { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false; + return true; + } + + // Highest current per-core frequency from /proc/cpuinfo's "cpu MHz" lines. + function _parseFreq() { + const text = cpuInfoView.text(); + if (!text) return; + let max = 0; + const lines = text.split("\n"); + for (let i = 0; i < lines.length; i++) { + if (!lines[i].startsWith("cpu MHz")) continue; + const v = parseFloat(lines[i].split(":")[1]); + if (!isNaN(v) && v > max) max = v; + } + root.cpuFreq = max; + } + + function _parseMem() { + const text = memView.text(); + if (!text) return; + let mt = 0, ma = 0; + const lines = text.split("\n"); + for (let i = 0; i < lines.length; i++) { + const ln = lines[i]; + if (ln.startsWith("MemTotal:")) mt = parseInt(ln.split(/\s+/)[1]); + else if (ln.startsWith("MemAvailable:")) { ma = parseInt(ln.split(/\s+/)[1]); break; } + } + if (mt > 0) root.mem = (mt - ma) / mt * 100; + } + + function _parseTemp() { + if (!root.tempPath) { root.temp = 0; return; } + const text = tempView.text(); + if (!text) return; + const v = parseInt(text.trim()); + if (!isNaN(v)) root.temp = Math.round(v / 1000); + } + + function _parseBattery() { + if (!root.batPath) { root.battery = -1; root.batteryStatus = "unknown"; return; } + const cap = batCapView.text(); + const st = batStatusView.text(); + if (cap) { const v = parseInt(cap.trim()); if (!isNaN(v)) root.battery = v; } + if (st) root.batteryStatus = st.trim() || "unknown"; + } + + // Active interface = device of the default route (destination 00000000). + function _activeIface() { + const text = routeView.text(); + if (!text) return ""; + const lines = text.split("\n"); + for (let i = 1; i < lines.length; i++) { // skip header + const cols = lines[i].trim().split(/\s+/); + if (cols.length >= 2 && cols[1] === "00000000") return cols[0]; + } + return ""; + } + + function _parseNet() { + const ifc = root._activeIface(); + root.iface = ifc; + if (!ifc) { root.rxRate = 0; root.txRate = 0; root._prevIface = ""; return; } + + const base = "/sys/class/net/" + ifc + "/statistics/"; + rxView.path = base + "rx_bytes"; + txView.path = base + "tx_bytes"; + rxView.reload(); + txView.reload(); + + const rx = parseInt((rxView.text() || "0").trim()); + const tx = parseInt((txView.text() || "0").trim()); + if (ifc === root._prevIface && root._prevRx > 0) { + root.rxRate = Math.max(0, (rx - root._prevRx) / root.interval); + root.txRate = Math.max(0, (tx - root._prevTx) / root.interval); + } else { + root.rxRate = 0; + root.txRate = 0; + } + root._prevRx = rx; + root._prevTx = tx; + root._prevIface = ifc; + } + + function _tickOnce() { + statView.reload(); + cpuInfoView.reload(); + memView.reload(); + routeView.reload(); + if (root.tempPath) tempView.reload(); + if (root.batPath) { batCapView.reload(); batStatusView.reload(); } + + _parseCpu(); + _parseFreq(); + _parseMem(); + _parseTemp(); + _parseBattery(); + _parseNet(); + + // disk has no virtual file; refresh via df every 30 ticks. + if (root._tick % 30 === 0) dfProc.running = true; + root._tick++; + } + + Timer { + interval: root.interval * 1000 + running: true + repeat: true + triggeredOnStart: true + onTriggered: root._tickOnce() + } + + // ---- one-shot hardware discovery -------------------------------------- + + Process { + id: discover + command: ["bash", Quickshell.shellPath("services/discover.sh")] + running: true + stdout: SplitParser { + splitMarker: "\n" + onRead: line => { + if (!line) return; + const sp = line.indexOf(" "); + if (sp < 0) return; + const key = line.slice(0, sp); + const val = line.slice(sp + 1).trim(); + if (key === "TEMP") root.tempPath = val; + else if (key === "BAT") root.batPath = val; + } + } + } + + // ---- disk usage (df, low cadence) ------------------------------------- + + Process { + id: dfProc + command: ["df", "-P", "/"] + stdout: StdioCollector { + onStreamFinished: { + const lines = (this.text || "").trim().split("\n"); + if (lines.length < 2) return; + const cols = lines[lines.length - 1].trim().split(/\s+/); + if (cols.length >= 5) { + const v = parseInt(cols[4].replace("%", "")); + if (!isNaN(v)) root.disk = v; + } + } + } + } + + // Human-readable byte rate with kB as the smallest unit, e.g. "1.2M", "8K". + function fmtRate(bytes) { + const u = ["K", "M", "G"]; + let i = 0, v = bytes / 1024; // start in kB + while (v >= 1024 && i < u.length - 1) { v /= 1024; i++; } + return (v >= 100 ? v.toFixed(0) : v.toFixed(1)) + u[i]; + } +} diff --git a/services/discover.sh b/services/discover.sh new file mode 100755 index 0000000..b85fc72 --- /dev/null +++ b/services/discover.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +# One-shot hardware discovery for the Quickshell bar. +# Prints stable sysfs paths the QML side then reads directly via FileView, +# so no per-tick subprocess is needed. Output (one key per line): +# +# TEMP +# BAT +# +# Re-run only when hardware topology might change (rare); paths are stable +# across a boot. + +set -u + +# CPU package temperature sensor (coretemp / k10temp / zenpower ...). +discover_temp_input() { + local hw name lbl base + for hw in /sys/class/hwmon/hwmon*; do + name=$(cat "$hw/name" 2>/dev/null) || continue + case "$name" in + coretemp|k10temp|zenpower) + # Prefer the package/Tctl/Tdie label if present. + for lbl in "$hw"/temp*_label; do + [ -e "$lbl" ] || continue + case "$(cat "$lbl" 2>/dev/null)" in + "Package id 0"|Tctl|Tdie) + base="${lbl%_label}" + echo "${base}_input" + return 0 + ;; + esac + done + [ -e "$hw/temp1_input" ] && { echo "$hw/temp1_input"; return 0; } + ;; + esac + done + # Fallback: x86_pkg_temp thermal zone, else first thermal zone. + local z t + for z in /sys/class/thermal/thermal_zone*; do + t=$(cat "$z/type" 2>/dev/null) + [ "$t" = "x86_pkg_temp" ] && { echo "$z/temp"; return 0; } + done + [ -e /sys/class/thermal/thermal_zone0/temp ] && echo /sys/class/thermal/thermal_zone0/temp +} + +# Main system battery (skip peripherals like wacom/mouse which set scope=Device). +discover_battery() { + local ps t scope + for ps in /sys/class/power_supply/*; do + t=$(cat "$ps/type" 2>/dev/null) + [ "$t" = "Battery" ] || continue + scope=$(cat "$ps/scope" 2>/dev/null) + [ "$scope" = "Device" ] && continue + echo "$ps" + return 0 + done +} + +printf 'TEMP %s\n' "$(discover_temp_input)" +printf 'BAT %s\n' "$(discover_battery)" diff --git a/services/qmldir b/services/qmldir new file mode 100644 index 0000000..d2dbca6 --- /dev/null +++ b/services/qmldir @@ -0,0 +1 @@ +singleton SysStats 1.0 SysStats.qml diff --git a/shell.qml b/shell.qml new file mode 100644 index 0000000..942de68 --- /dev/null +++ b/shell.qml @@ -0,0 +1,13 @@ +//@ pragma UseQApplication + +import Quickshell +import "widgets" + +// Entry point: spawn one Bar on every connected monitor. +ShellRoot { + Variants { + model: Quickshell.screens + + Bar {} + } +} diff --git a/widgets/Bar.qml b/widgets/Bar.qml new file mode 100644 index 0000000..ff59fb5 --- /dev/null +++ b/widgets/Bar.qml @@ -0,0 +1,69 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell +import "../config" + +// One bar instance per monitor. +PanelWindow { + id: panel + required property var modelData + screen: modelData + + color: "transparent" + + // Auto-rotation flips the output transform and resizes this layer surface + // in place; the old buffer bleeds through the area that isn't fully + // repainted, leaving stale "garbage" pixels. Remapping the surface on any + // geometry change forces Sway to hand us a fresh, fully-painted buffer. + readonly property string geomKey: modelData + ? (modelData.width + "x" + modelData.height) + : "" + onGeomKeyChanged: Qt.callLater(function () { + if (!panel.visible) return; + panel.visible = false; + panel.visible = true; + }) + + anchors { + top: true + left: true + right: true + } + implicitHeight: Theme.barHeight + + // background strip + Rectangle { + anchors.fill: parent + color: Theme.barColor + } + + // left: workspaces + Workspaces { + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + anchors.leftMargin: Theme.gap + 2 + screen: panel.modelData + } + + // center: clock + Clock { + anchors.centerIn: parent + } + + // right: battery, system metrics, volume, tray + RowLayout { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.rightMargin: Theme.gap + 2 + spacing: Theme.gap + + Battery {} + CpuGraph {} + CpuTemp {} + Ram {} + Disk {} + Network {} + Volume {} + Tray { panelWindow: panel } + } +} diff --git a/widgets/Battery.qml b/widgets/Battery.qml new file mode 100644 index 0000000..bb7acd6 --- /dev/null +++ b/widgets/Battery.qml @@ -0,0 +1,17 @@ +import "../config" +import "../services" + +MetricPill { + readonly property bool charging: SysStats.batteryStatus === "Charging" + || SysStats.batteryStatus === "Full" + + visible: SysStats.hasBattery + + icon: charging ? Icons.bolt : Icons.battery(SysStats.battery) + iconColor: charging ? Theme.green + : SysStats.battery <= 15 ? Theme.red + : SysStats.battery <= 30 ? Theme.peach + : Theme.green + value: SysStats.battery + "%" + reserve: "100%" +} diff --git a/widgets/Clock.qml b/widgets/Clock.qml new file mode 100644 index 0000000..eb36307 --- /dev/null +++ b/widgets/Clock.qml @@ -0,0 +1,52 @@ +import QtQuick +import QtQuick.Layouts +import "../config" + +Pill { + id: root + background: Theme.surface1 + property var now: new Date() + + Timer { + interval: 1000 + running: true + repeat: true + triggeredOnStart: true + onTriggered: root.now = new Date() + } + + Text { + text: Icons.clock + font.family: Theme.monoFont + font.pixelSize: Theme.fontSize + 1 + color: Theme.lavender + Layout.alignment: Qt.AlignVCenter + } + + TextMetrics { + id: dateMetrics + font: dateText.font + // widest day/month abbreviations in this locale + text: "Wed 00 MMM" + } + + Text { + id: dateText + text: Qt.formatDateTime(root.now, "ddd dd MMM") + font.family: Theme.font + font.pixelSize: Theme.fontSize + color: Theme.subtext + horizontalAlignment: Text.AlignHCenter + Layout.alignment: Qt.AlignVCenter + Layout.preferredWidth: dateMetrics.advanceWidth + } + + Text { + text: Qt.formatDateTime(root.now, "HH:mm") + font.family: Theme.font + font.pixelSize: Theme.fontSize + font.bold: true + color: Theme.text + Layout.alignment: Qt.AlignVCenter + } +} diff --git a/widgets/CpuGraph.qml b/widgets/CpuGraph.qml new file mode 100644 index 0000000..7629a3b --- /dev/null +++ b/widgets/CpuGraph.qml @@ -0,0 +1,66 @@ +import QtQuick +import QtQuick.Layouts +import "../config" +import "../services" + +// CPU usage as a live filled-area graph plus the current percentage. +Pill { + id: root + spacing: Theme.spacing + + Text { + text: Icons.cpu + font.family: Theme.monoFont + font.pixelSize: Theme.fontSize + 1 + color: Theme.loadColor(SysStats.cpu) + Layout.alignment: Qt.AlignVCenter + } + + // One vertical bar per core, filled from the bottom by that core's load. + Row { + id: cores + Layout.alignment: Qt.AlignVCenter + spacing: 1 + readonly property int barH: Theme.barHeight - Theme.gap * 2 - 12 + + Repeater { + model: SysStats.coreCount + delegate: Rectangle { + readonly property real load: SysStats.coreLoads[index] || 0 + width: 3 + height: cores.barH + radius: 1 + color: Qt.rgba(1, 1, 1, 0.08) // unfilled track + + Rectangle { + anchors.bottom: parent.bottom + width: parent.width + radius: 1 + height: Math.max(1, parent.height * (parent.load / 100)) + color: Theme.loadColor(parent.load) + } + } + } + } + + Text { + text: SysStats.cpu.toFixed(0) + "%" + font.family: Theme.font + font.pixelSize: Theme.fontSize + color: Theme.text + Layout.alignment: Qt.AlignVCenter + Layout.preferredWidth: 42 // fits "100%" at fontSize 15 + horizontalAlignment: Text.AlignRight + } + + // Max current core frequency, shown in GHz. + Text { + text: (SysStats.cpuFreq / 1000).toFixed(1) + "GHz" + font.family: Theme.font + font.pixelSize: Theme.fontSize + color: Theme.subtext + Layout.alignment: Qt.AlignVCenter + Layout.preferredWidth: 56 // fits "0.0GHz" at fontSize 15 + horizontalAlignment: Text.AlignRight + } +} diff --git a/widgets/CpuTemp.qml b/widgets/CpuTemp.qml new file mode 100644 index 0000000..089681d --- /dev/null +++ b/widgets/CpuTemp.qml @@ -0,0 +1,13 @@ +import "../config" +import "../services" + +MetricPill { + icon: Icons.thermo + // temperature thresholds differ from load: warm >70, hot >85 + iconColor: SysStats.temp >= 85 ? Theme.red + : SysStats.temp >= 70 ? Theme.peach + : SysStats.temp >= 55 ? Theme.yellow + : Theme.green + value: SysStats.temp + "°C" + reserve: "100°C" +} diff --git a/widgets/Disk.qml b/widgets/Disk.qml new file mode 100644 index 0000000..dbc514b --- /dev/null +++ b/widgets/Disk.qml @@ -0,0 +1,9 @@ +import "../config" +import "../services" + +MetricPill { + icon: Icons.disk + iconColor: Theme.loadColor(SysStats.disk) + value: SysStats.disk + "%" + reserve: "100%" +} diff --git a/widgets/MetricPill.qml b/widgets/MetricPill.qml new file mode 100644 index 0000000..f564596 --- /dev/null +++ b/widgets/MetricPill.qml @@ -0,0 +1,40 @@ +import QtQuick +import QtQuick.Layouts +import "../config" + +// A pill showing a coloured Nerd Font icon followed by a value string. +Pill { + id: root + property string icon: "" + property string value: "" + property color iconColor: Theme.text + property alias label: valueText + // Widest value the pill will ever show, e.g. "100%". When set, the value + // column reserves that width so changing values never shift other widgets. + property string reserve: "" + + Text { + text: root.icon + font.family: Theme.monoFont + font.pixelSize: Theme.fontSize + 1 + color: root.iconColor + Layout.alignment: Qt.AlignVCenter + } + + TextMetrics { + id: reserveMetrics + font: valueText.font + text: root.reserve + } + + Text { + id: valueText + text: root.value + font.family: Theme.font + font.pixelSize: Theme.fontSize + color: Theme.text + horizontalAlignment: Text.AlignRight + Layout.alignment: Qt.AlignVCenter + Layout.preferredWidth: root.reserve ? reserveMetrics.advanceWidth : implicitWidth + } +} diff --git a/widgets/Network.qml b/widgets/Network.qml new file mode 100644 index 0000000..7c9cb89 --- /dev/null +++ b/widgets/Network.qml @@ -0,0 +1,43 @@ +import QtQuick +import QtQuick.Layouts +import "../config" +import "../services" + +// Down/up throughput on the active interface, with a wifi/ethernet icon. +Pill { + Text { + text: SysStats.iface.startsWith("wl") ? Icons.wifi : Icons.ethernet + font.family: Theme.monoFont + font.pixelSize: Theme.fontSize + 1 + color: Theme.sky + Layout.alignment: Qt.AlignVCenter + } + + // reserve the width of the widest rate string so values never shift + TextMetrics { + id: rateMetrics + font: rxText.font + text: Icons.down + " 99.9M" + } + + Text { + id: rxText + text: Icons.down + " " + SysStats.fmtRate(SysStats.rxRate) + font.family: Theme.monoFont + font.pixelSize: Theme.fontSize - 1 + color: Theme.green + horizontalAlignment: Text.AlignRight + Layout.alignment: Qt.AlignVCenter + Layout.preferredWidth: rateMetrics.advanceWidth + } + + Text { + text: Icons.up + " " + SysStats.fmtRate(SysStats.txRate) + font.family: Theme.monoFont + font.pixelSize: Theme.fontSize - 1 + color: Theme.peach + horizontalAlignment: Text.AlignRight + Layout.alignment: Qt.AlignVCenter + Layout.preferredWidth: rateMetrics.advanceWidth + } +} diff --git a/widgets/Pill.qml b/widgets/Pill.qml new file mode 100644 index 0000000..cf62203 --- /dev/null +++ b/widgets/Pill.qml @@ -0,0 +1,23 @@ +import QtQuick +import QtQuick.Layouts +import "../config" + +// Rounded background container used by every bar module for a consistent look. +Rectangle { + id: pill + + default property alias content: row.data + property int spacing: Theme.spacing + property color background: Theme.surface0 + + implicitWidth: row.implicitWidth + Theme.padding * 2 + implicitHeight: Theme.barHeight - Theme.gap * 2 + radius: Theme.radius + color: background + + RowLayout { + id: row + anchors.centerIn: parent + spacing: pill.spacing + } +} diff --git a/widgets/Ram.qml b/widgets/Ram.qml new file mode 100644 index 0000000..23d4879 --- /dev/null +++ b/widgets/Ram.qml @@ -0,0 +1,9 @@ +import "../config" +import "../services" + +MetricPill { + icon: Icons.memory + iconColor: Theme.loadColor(SysStats.mem) + value: SysStats.mem.toFixed(0) + "%" + reserve: "100%" +} diff --git a/widgets/Tray.qml b/widgets/Tray.qml new file mode 100644 index 0000000..7151b70 --- /dev/null +++ b/widgets/Tray.qml @@ -0,0 +1,62 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Services.SystemTray +import "../config" + +// StatusNotifier system tray. Left-click activates, middle-click does the +// secondary action, right-click opens the item's native menu. +Pill { + id: root + // the PanelWindow this tray lives in, needed to anchor context menus + property var panelWindow + + visible: SystemTray.items.values.length > 0 + spacing: Theme.spacing + 2 + + Repeater { + model: SystemTray.items + + Item { + id: entry + required property var modelData + Layout.alignment: Qt.AlignVCenter + implicitWidth: 18 + implicitHeight: 18 + + Image { + anchors.fill: parent + source: entry.modelData.icon + sourceSize.width: 18 + sourceSize.height: 18 + fillMode: Image.PreserveAspectFit + smooth: true + } + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + acceptedButtons: Qt.LeftButton | Qt.MiddleButton | Qt.RightButton + onClicked: mouse => { + const item = entry.modelData; + if (mouse.button === Qt.LeftButton) { + if (item.onlyMenu) openMenu(); + else item.activate(); + } else if (mouse.button === Qt.MiddleButton) { + item.secondaryActivate(); + } else if (mouse.button === Qt.RightButton) { + openMenu(); + } + } + function openMenu() { + const item = entry.modelData; + if (!item.hasMenu || !root.panelWindow) return; + const p = entry.mapToItem(null, 0, entry.height + Theme.gap); + item.display(root.panelWindow, p.x, p.y); + } + onWheel: wheel => + entry.modelData.scroll(wheel.angleDelta.y, false) + } + } + } +} diff --git a/widgets/Volume.qml b/widgets/Volume.qml new file mode 100644 index 0000000..3fdd5ab --- /dev/null +++ b/widgets/Volume.qml @@ -0,0 +1,63 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell.Services.Pipewire +import "../config" + +// PipeWire default sink volume. Scroll to adjust, click to mute. +MouseArea { + id: root + + readonly property var sink: Pipewire.defaultAudioSink + readonly property var audio: sink ? sink.audio : null + readonly property bool muted: audio ? audio.muted : true + readonly property int volPct: audio ? Math.round(audio.volume * 100) : 0 + + implicitWidth: pill.implicitWidth + implicitHeight: pill.implicitHeight + cursorShape: Qt.PointingHandCursor + acceptedButtons: Qt.LeftButton + + onClicked: { if (audio) audio.muted = !audio.muted; } + onWheel: wheel => { + if (!audio) return; + audio.volume = Math.max(0, Math.min(1, + audio.volume + (wheel.angleDelta.y > 0 ? 0.05 : -0.05))); + } + + // keep the sink's audio properties live + PwObjectTracker { objects: root.sink ? [root.sink] : [] } + + Pill { + id: pill + anchors.fill: parent + + Text { + text: root.muted ? Icons.volMute + : root.volPct < 50 ? Icons.volLow + : Icons.volHigh + font.family: Theme.monoFont + font.pixelSize: Theme.fontSize + 1 + color: root.muted ? Theme.overlay : Theme.teal + Layout.alignment: Qt.AlignVCenter + Layout.preferredWidth: Theme.fontSize + 4 + horizontalAlignment: Text.AlignHCenter + } + + TextMetrics { + id: volMetrics + font: volText.font + text: "muted" + } + + Text { + id: volText + text: root.muted ? "muted" : root.volPct + "%" + font.family: Theme.font + font.pixelSize: Theme.fontSize + color: Theme.text + horizontalAlignment: Text.AlignRight + Layout.alignment: Qt.AlignVCenter + Layout.preferredWidth: volMetrics.advanceWidth + } + } +} diff --git a/widgets/Workspaces.qml b/widgets/Workspaces.qml new file mode 100644 index 0000000..7e255a0 --- /dev/null +++ b/widgets/Workspaces.qml @@ -0,0 +1,54 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.I3 +import "../config" + +// Sway/i3 workspace switcher for a single monitor. +Row { + id: root + required property var screen // ShellScreen this bar lives on + spacing: Theme.gap + + // Name of the i3/sway output this bar is on, used to filter workspaces. + readonly property string monitorName: screen ? screen.name : "" + + Repeater { + model: I3.workspaces + + Rectangle { + id: chip + required property var modelData + // only show workspaces belonging to this monitor + // I3Workspace.monitor is an I3Monitor object, so compare its name + visible: modelData.monitor && modelData.monitor.name === root.monitorName + width: visible ? Math.max(height, label.implicitWidth + 16) : 0 + height: Theme.barHeight - Theme.gap * 2 + radius: Theme.radius + + readonly property bool isFocused: modelData.focused + readonly property bool isUrgent: modelData.urgent + + color: isUrgent ? Theme.red + : isFocused ? Theme.blue + : modelData.active ? Theme.surface1 + : Theme.surface0 + + Text { + id: label + anchors.centerIn: parent + text: chip.modelData.name + font.family: Theme.font + font.pixelSize: Theme.fontSize + font.bold: chip.isFocused || chip.isUrgent + color: (chip.isFocused || chip.isUrgent) ? Theme.mantle : Theme.text + } + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: chip.modelData.activate() + } + } + } +}