add full proj
This commit is contained in:
19
.gitignore
vendored
Normal file
19
.gitignore
vendored
Normal 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
80
CLAUDE.md
Normal 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
103
README.md
Normal 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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 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
38
config/Icons.qml
Normal 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
49
config/Theme.qml
Normal 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
2
config/qmldir
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
singleton Theme 1.0 Theme.qml
|
||||||
|
singleton Icons 1.0 Icons.qml
|
||||||
BIN
docs/bar.png
Normal file
BIN
docs/bar.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
48
flake.lock
generated
Normal file
48
flake.lock
generated
Normal 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
85
flake.nix
Normal 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
290
services/SysStats.qml
Normal 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
59
services/discover.sh
Executable 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
1
services/qmldir
Normal file
@@ -0,0 +1 @@
|
|||||||
|
singleton SysStats 1.0 SysStats.qml
|
||||||
13
shell.qml
Normal file
13
shell.qml
Normal 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
69
widgets/Bar.qml
Normal 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
17
widgets/Battery.qml
Normal 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
52
widgets/Clock.qml
Normal 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
66
widgets/CpuGraph.qml
Normal 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
13
widgets/CpuTemp.qml
Normal 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
9
widgets/Disk.qml
Normal 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
40
widgets/MetricPill.qml
Normal 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
43
widgets/Network.qml
Normal 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
23
widgets/Pill.qml
Normal 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
9
widgets/Ram.qml
Normal 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
62
widgets/Tray.qml
Normal 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
63
widgets/Volume.qml
Normal 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
54
widgets/Workspaces.qml
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user