commit f8a4536a02e9ce2d78eabf8df0b7ccc59b8ebb15 Author: Asmir A Date: Sun May 31 09:28:44 2026 +0200 add full proj 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 0000000..07c7006 Binary files /dev/null and b/docs/bar.png differ diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..a2d04d3 --- /dev/null +++ b/flake.lock @@ -0,0 +1,48 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1779560665, + "narHash": "sha256-tpyBcxPpcQb8ukyNF7DoCwfSY3VPsxHoYwj00Cayv5o=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "64c08a7ca051951c8eae34e3e3cb1e202fe36786", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "quickshell": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1779430452, + "narHash": "sha256-zTslhsxLqUlRTML506iougTGzyR38Fzhzn7t4KDEuuE=", + "ref": "refs/heads/master", + "rev": "4b4fca3224ab977dc515ac0bb78d00b3dfa71e00", + "revCount": 819, + "type": "git", + "url": "https://git.outfoxxed.me/quickshell/quickshell" + }, + "original": { + "type": "git", + "url": "https://git.outfoxxed.me/quickshell/quickshell" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs", + "quickshell": "quickshell" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..ae06a40 --- /dev/null +++ b/flake.nix @@ -0,0 +1,85 @@ +{ + description = "A Wayland status bar for Sway, built with Quickshell"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + # Upstream Quickshell, tracked directly so we get layer-surface fixes + # (e.g. clean buffers on output transform / rotation) ahead of nixpkgs. + quickshell = { + url = "git+https://git.outfoxxed.me/quickshell/quickshell"; + inputs.nixpkgs.follows = "nixpkgs"; + }; + }; + + outputs = { self, nixpkgs, quickshell }: + let + systems = [ "x86_64-linux" "aarch64-linux" ]; + forAllSystems = f: + nixpkgs.lib.genAttrs systems (system: f nixpkgs.legacyPackages.${system}); + in + { + packages = forAllSystems (pkgs: + let + # Quickshell from the upstream flake rather than nixpkgs. + qsPkg = quickshell.packages.${pkgs.system}.default; + + # Runtime tools the bar relies on: discover.sh (one-shot hardware + # probe) and the periodic `df` for disk usage. Everything else is + # read from /proc and /sys directly in QML. + runtimeInputs = [ + qsPkg + pkgs.bash + pkgs.coreutils # df, cat + ]; + + # Self-contained font set so Nerd Font glyphs always render, + # regardless of the host's installed fonts. + fontsConf = pkgs.makeFontsConf { + fontDirectories = [ + pkgs.nerd-fonts.jetbrains-mono + pkgs.nerd-fonts.symbols-only + pkgs.inter + ]; + }; + + bar = pkgs.writeShellApplication { + name = "quickshell-bar"; + inherit runtimeInputs; + text = '' + export FONTCONFIG_FILE=${fontsConf} + exec quickshell --path ${self} "$@" + ''; + }; + in + { + default = bar; + quickshell-bar = bar; + }); + + apps = forAllSystems (pkgs: { + default = { + type = "app"; + program = "${self.packages.${pkgs.system}.default}/bin/quickshell-bar"; + }; + }); + + devShells = forAllSystems (pkgs: { + default = pkgs.mkShell { + packages = [ + quickshell.packages.${pkgs.system}.default + pkgs.bash + pkgs.coreutils + pkgs.nerd-fonts.jetbrains-mono + pkgs.inter + pkgs.qt6.qtdeclarative # qmlls / qmlformat for editing + ]; + shellHook = '' + echo "quickshell $(quickshell --version 2>/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() + } + } + } +}