Clicking the CPU pill opens a popup listing the highest-CPU processes, refreshed live while open. services/topproc.sh computes top-style per-process CPU% and mem% from two /proc snapshots and prints one ranked frame per run; SysStats streams it only while procPollEnabled (drawer open), so the bar pays nothing at rest. CLAUDE.md documents the architecture and the runtime constraints discovered here (helper scripts must be git-tracked for `nix run`; single-shot + exit to flush stdout; bash+coreutils only; StdioCollector/Timer don't fire in this Quickshell build; fixed-size popup to avoid stale-buffer on resize under Sway). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
7.2 KiB
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, packaged with a Nix flake.
Commands
# 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/—ThemeandIcons, both singletons (pragma Singleton+singletonline inconfig/qmldir). Global palette/geometry/fonts and Nerd Font glyphs. Import withimport "../config", reference asTheme.x/Icons.x.services/—SysStats, a singleton that is the single source of truth for all/proc+/sysmetrics. Import withimport "../services".widgets/— the visual modules.Pill.qmlis the rounded container every module reuses (default property alias contentlets children sit inside a centered RowLayout);MetricPill.qmladds 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
FileViews and parses them into reactive properties (cpu, cpuFreq, mem,
temp, rates, battery, …). Widgets just bind to those properties. Key conventions:
FileView { blockLoading: true }makesreload()synchronous sotext()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 itspath, as_parseNetdoes for the active interface), writing a_parseX(), and callingreload()+_parseX()inside_tickOnce(). - Deltas (CPU%, net rates) keep a
_prev*field and diff against the last tick. - Three things escape the pure-virtual-file rule: disk usage runs
dfvia aProcessonly every 30 ticks (_tick % 30); a one-shotProcessrunsservices/discover.shat startup; and the top-processes list (topProcs) is fed byservices/topproc.sh, re-run for each frame only whileprocPollEnabled(true while the CPU drawer is open) — at rest the bar still spawns nothing. The bar avoids per-tick subprocess spawns by design — keep new always-on metrics reading virtual files, not shelling out, and gate anything heavier behind an opt-in flag likeprocPollEnabled. topproc.shdoes the work, QML just displays. Each run takes two/procsnapshotsprocIntervalapart, computes top-style per-process CPU% and mem% in the shell, prints one ranked frame ofP <cpu> <mem> <name>lines, and exits.SysStatsaccumulates stdout via aSplitParser, parses it in_parseProcFrameononExited, then re-arms (Qt.callLater(running=true)) while the drawer stays open. Hard-won constraints — violate any one and you get silent empty frames / a stuck "sampling…":- The helper script must be tracked by git.
nix runbuilds from the flake snapshot (${self}), which excludes untracked files (git statuswill warn "tree is dirty") — an untracked script simply isn't found at runtime. For iterative work preferquickshell --path ., which reads the working dir (untracked files included) and hot-reloads. - Single-shot + exit, not a long-running loop. A looping script's output stuck in bash's block buffer (pipe stdout is block-buffered), so the QML side never saw a frame; exiting per run forces a flush.
- bash builtins + coreutils only (
nproc/sort/head/sleep/cat). The flake wrapper'sruntimeInputsis justbash+coreutils, soawkandgetconfare absent at runtime. StdioCollector.streamFinishednever fired in this Quickshell build, and aTimer-driven re-arm didn't repeat — butProcess.onStarted/onExitedandSplitParser.onReadall work (discover.shis the reference). Invoke helpers as["bash", Quickshell.shellPath("services/x.sh")].
- The helper script must be tracked by git.
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
FileViews. 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.nixbundlesnerd-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 above0xFFFF(e.g. Material Design0xf0xxx) needString.fromCodePoint, notString.fromCharCode. - Fonts are pinned via
FONTCONFIG_FILEin the flake wrapper, so glyphs render regardless of the host's installed fonts. Adding a font means editingfontsConfinflake.nix. Bar.qmlremaps its layer surface on geometry change (geomKey→ togglevisible). 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). - Making a module interactive (popups/drawers): wrap the module's
Pillin anItem(forwardimplicitWidth/implicitHeightfrom the pill so the bar's RowLayout still sizes it), add aMouseArea, and open aQuickshell.PopupWindow;CpuGraph.qmlis the reference for a full drawer. Anchor it withanchor.item: <the wrapper Item>(notanchor.window— the module is nested inBar.qml's RowLayout, so its coordinates aren't window coordinates, and a window-relative rect lands in the screen corner).anchor.itemmakes the rect item-relative and derives the window automatically; position the dropdown withanchor.edges/anchor.gravity(Edges.*) and let Quickshell slide it on-screen. Adding aMouseAreadirectly to aPill's default content would place it in the layout — wrapping in anItemavoids that.