add full proj

This commit is contained in:
2026-05-31 09:28:44 +02:00
commit f8a4536a02
26 changed files with 1307 additions and 0 deletions

19
.gitignore vendored Normal file
View File

@@ -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

80
CLAUDE.md Normal file
View File

@@ -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 <path>` and `BAT <path>`
(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).

103
README.md Normal file
View File

@@ -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/<active>/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
```

38
config/Icons.qml Normal file
View File

@@ -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];
}
}

49
config/Theme.qml Normal file
View File

@@ -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;
}
}

2
config/qmldir Normal file
View File

@@ -0,0 +1,2 @@
singleton Theme 1.0 Theme.qml
singleton Icons 1.0 Icons.qml

BIN
docs/bar.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

48
flake.lock generated Normal file
View File

@@ -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
}

85
flake.nix Normal file
View File

@@ -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);
};
}

290
services/SysStats.qml Normal file
View File

@@ -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];
}
}

59
services/discover.sh Executable file
View File

@@ -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 <path to hwmon/thermal temp*_input>
# BAT <path to power_supply battery dir>
#
# 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)"

1
services/qmldir Normal file
View File

@@ -0,0 +1 @@
singleton SysStats 1.0 SysStats.qml

13
shell.qml Normal file
View File

@@ -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 {}
}
}

69
widgets/Bar.qml Normal file
View File

@@ -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 }
}
}

17
widgets/Battery.qml Normal file
View File

@@ -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%"
}

52
widgets/Clock.qml Normal file
View File

@@ -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
}
}

66
widgets/CpuGraph.qml Normal file
View File

@@ -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
}
}

13
widgets/CpuTemp.qml Normal file
View File

@@ -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"
}

9
widgets/Disk.qml Normal file
View File

@@ -0,0 +1,9 @@
import "../config"
import "../services"
MetricPill {
icon: Icons.disk
iconColor: Theme.loadColor(SysStats.disk)
value: SysStats.disk + "%"
reserve: "100%"
}

40
widgets/MetricPill.qml Normal file
View File

@@ -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
}
}

43
widgets/Network.qml Normal file
View File

@@ -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
}
}

23
widgets/Pill.qml Normal file
View File

@@ -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
}
}

9
widgets/Ram.qml Normal file
View File

@@ -0,0 +1,9 @@
import "../config"
import "../services"
MetricPill {
icon: Icons.memory
iconColor: Theme.loadColor(SysStats.mem)
value: SysStats.mem.toFixed(0) + "%"
reserve: "100%"
}

62
widgets/Tray.qml Normal file
View File

@@ -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)
}
}
}
}

63
widgets/Volume.qml Normal file
View File

@@ -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
}
}
}

54
widgets/Workspaces.qml Normal file
View File

@@ -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()
}
}
}
}