diff --git a/config/bin/hyprcosmic-fan b/config/bin/hyprcosmic-fan new file mode 100755 index 0000000..aa0f99a --- /dev/null +++ b/config/bin/hyprcosmic-fan @@ -0,0 +1,86 @@ +#!/bin/sh +# Report fan speeds for waybar's custom/fan module, as JSON on stdout. +# +# waybar has no fan module, and its temperature module cannot be borrowed for +# this: it divides by 1000 to turn millidegrees into degrees, which would render +# 2700 rpm as 2 C. +# +# Nor can the sensor be named the way "temperature" names its own, with +# hwmon-path-abs. That option points at a *parent* directory and takes the one +# hwmonN inside it, which works for k10temp and amdgpu because each has exactly +# one. The asus platform device has two -- +# +# /sys/devices/platform/asus-nb-wmi/hwmon/hwmon9 name=asus +# /sys/devices/platform/asus-nb-wmi/hwmon/hwmon10 name=asus_custom_fan_curve +# +# -- and only the first has fan*_input; the second holds the curve's set points. +# Which of the two waybar picked would be down to readdir order. +# +# So this resolves by content instead of by path: any hwmon with a fan*_input. +# That also makes it portable off this laptop, which a hardcoded asus path would +# not be. Fans on a desktop's it87 or a thinkpad's thinkpad_hwmon are found the +# same way. +# +# Prints nothing at all when the machine has no readable fan -- a VM, or a +# passively cooled box. waybar renders an empty custom module as nothing, so the +# bar loses the item rather than showing a dead zero. + +set -eu + +max=0 +tooltip='' + +for hwmon in /sys/class/hwmon/hwmon*; do + [ -d "$hwmon" ] || continue + + # Not every hwmon has a name, and a chip is worth naming in the tooltip + # when it does: "cpu_fan" alone does not say which controller reported it. + chip=$(cat "$hwmon/name" 2>/dev/null) || chip='' + [ -n "$chip" ] || chip=$(basename "$hwmon") + + for input in "$hwmon"/fan*_input; do + # The glob is literal when nothing matches, which is the common case: + # most hwmons here are temperature-only. + [ -e "$input" ] || continue + + rpm=$(cat "$input" 2>/dev/null) || continue + # A fan that is stopped reads 0, and a fan that has been unbound reads + # nothing. Neither is an error, but neither belongs in the tooltip. + case $rpm in + '' | *[!0-9]*) continue ;; + esac + + # fanN_label when the driver supplies one -- "cpu_fan", "gpu_fan" -- + # and fanN otherwise. + label=$(cat "${input%_input}_label" 2>/dev/null) || label='' + [ -n "$label" ] || label=$(basename "${input%_input}") + + [ "$rpm" -gt "$max" ] && max=$rpm + + line="$chip $label: $rpm rpm" + if [ -n "$tooltip" ]; then + tooltip="$tooltip\\n$line" + else + tooltip=$line + fi + done +done + +# No fan anywhere. Say nothing rather than reporting a confident 0 rpm. +[ -n "$tooltip" ] || exit 0 + +# The bar shows the fastest fan, because that is the one you can hear and the +# one that says whether the machine is working. The rest are in the tooltip. +# +# The class drives the colour in rules.css. 4000 rpm is where this laptop's fans +# become audible over a quiet room; below 1 rpm every fan is stopped, which is +# worth showing differently from a slow one. +if [ "$max" -ge 4000 ]; then + class=high +elif [ "$max" -eq 0 ]; then + class=idle +else + class=normal +fi + +printf '{"text":"%s","tooltip":"%s","class":"%s"}\n' "$max" "$tooltip" "$class" diff --git a/config/bin/hyprcosmic-keybinds b/config/bin/hyprcosmic-keybinds new file mode 100755 index 0000000..d9c8112 --- /dev/null +++ b/config/bin/hyprcosmic-keybinds @@ -0,0 +1,205 @@ +#!/usr/bin/bash +# +# Show every keyboard shortcut in the session, and what each one runs. +# +# WHY THIS EXISTS +# --------------- +# COSMIC has no keyboard reference. cosmic-settings lists shortcuts in a +# scrolling pane behind three clicks, which is where you go to *change* one, not +# where you go to remember one mid-task. Hyprland desktops all ship a cheatsheet +# on a key and a bar click for exactly that reason, and this is that. +# +# WHERE THE BINDINGS COME FROM, AND WHY NOT cosmic.conf +# ----------------------------------------------------- +# The obvious implementation reads cosmic.conf, and it would be wrong. This +# machine's cosmic.conf declares six bindings. The session answers to 122. The +# other 116 are COSMIC's own defaults, which the fork does not restate because +# it has no reason to -- Super+Q closes a window whether or not anybody wrote it +# down. A cheatsheet showing six entries would not look broken; it would look +# complete, and it would be missing every window, workspace and media key on the +# machine. That is a worse failure than not having the feature. +# +# So the sources are the three RON files the compositor actually reads: +# +# /usr/share/cosmic/.../Shortcuts/v1/defaults 116 COSMIC built-ins +# ~/.config/cosmic/.../Shortcuts/v1/custom 6 ours, projected from +# cosmic.conf by cosmic-conf +# /usr/share/cosmic/.../Shortcuts/v1/system_actions +# what System(X) actually runs +# +# custom wins over defaults on a collision, matching the order the compositor +# merges them, so a rebound key is listed once with the binding in force. +# +# system_actions is what makes this answer the question that was asked. A +# default reads `System(Screenshot)`, which names an action and not a program; +# system_actions maps that to `cosmic-screenshot`. Without it a third of the +# list would name internal actions rather than the applications they launch. +# +# THE PARSER +# ---------- +# These are RON files, and this reads them with a regex, which deserves a +# defence. The alternative is a `cosmic-conf binds` subcommand, which would be +# the principled home for this -- cosmic-conf already has the schema and the +# bind grammar. That is a compiled change and a full package build, and this +# feature does not need one: the files are one binding per line, and the shape +# has not moved. The cost of being wrong here is a mangled row in a reference +# window, not a broken keybinding, because nothing downstream consumes this. +# +# If the format does move, the failure is visible immediately and loudly: the +# count check below refuses to show a list it could not parse. + +set -uo pipefail + +SYS_DIR=/usr/share/cosmic/com.system76.CosmicSettings.Shortcuts/v1 +USER_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/cosmic/com.system76.CosmicSettings.Shortcuts/v1" + +command -v rofi >/dev/null 2>&1 || { + printf 'hyprcosmic-keybinds: rofi is not installed\n' >&2 + exit 1 +} + +# Errors go to a rofi dialog as well as stderr: the two ways in are a keybinding +# and a bar click, and neither has a terminal to read stderr from. Same reasoning +# as hyprcosmic-powermenu. +fail() { + printf 'hyprcosmic-keybinds: %s\n' "$*" >&2 + rofi -e "$*" >/dev/null 2>&1 || : + exit 1 +} + +[[ -r "$SYS_DIR/defaults" ]] || + fail "cannot read $SYS_DIR/defaults, so the built-in shortcuts are unknown" + +# Resolve System(X) to the command it runs, as "Xcommand" pairs. +# +# system_actions carries /// doc comments and commented-out entries for actions +# that exist in the enum but are not wired up (KeyboardBrightnessUp is one), so +# this matches only lines that are a bare name followed by a quoted string. +system_actions() { + [[ -r "$SYS_DIR/system_actions" ]] || return 0 + sed -nE 's/^[[:space:]]*([A-Za-z]+):[[:space:]]*"(.*)",?[[:space:]]*$/\1\t\2/p' \ + "$SYS_DIR/system_actions" +} + +# One binding per line, as "modifierskeyaction". +# +# Two shapes appear. The usual one carries a key: +# (modifiers: [Super, Shift], key: "Escape"): System(LogOut), +# and a modifier-only binding has none at all: +# (modifiers: [Super]): Spawn("rofi -show drun"), +# The second is how Super alone opens the launcher, so dropping it would lose +# the single most used binding on the machine. +bindings() { + sed -nE \ + -e 's/^[[:space:]]*\(modifiers:[[:space:]]*\[([^]]*)\],[[:space:]]*key:[[:space:]]*"([^"]*)"\):[[:space:]]*(.*),[[:space:]]*$/\1\t\2\t\3/p' \ + -e 's/^[[:space:]]*\(modifiers:[[:space:]]*\[([^]]*)\]\):[[:space:]]*(.*),[[:space:]]*$/\1\t\t\2/p' \ + "$1" +} + +actions_tsv="$(system_actions)" + +# custom is read second so that its rows overwrite defaults in the map below. +# Missing is normal, not an error: a session with no custom bindings has no such +# file, and every default still applies. +raw="$(bindings "$SYS_DIR/defaults")" +if [[ -r "$USER_DIR/custom" ]]; then + raw+=$'\n'"$(bindings "$USER_DIR/custom")" +fi + +[[ -n "${raw//[[:space:]]/}" ]] || + fail "parsed no shortcuts at all from $SYS_DIR/defaults; the file format has changed" + +rendered="$( + LC_ALL=C awk -F'\t' -v actions="$actions_tsv" ' + BEGIN { + # System(X) -> command, from system_actions. + n = split(actions, lines, "\n") + for (i = 1; i <= n; i++) { + if (split(lines[i], kv, "\t") == 2) cmd[kv[1]] = kv[2] + } + } + + # Turn CamelCase into words so an action reads as a description rather than + # an identifier: MoveToWorkspace -> "move to workspace". Applied only to the + # action name, never to a command, which must stay verbatim to be typed. + function words(s, out) { + out = s + gsub(/([a-z0-9])([A-Z])/, "\\1 \\2", out) + return tolower(out) + } + + # Super is written first and Shift last, so that chords sort and read the + # way they are pressed rather than the way the RON happened to list them. + function order(m) { + if (m == "Super") return 1 + if (m == "Ctrl") return 2 + if (m == "Alt") return 3 + if (m == "Shift") return 4 + return 5 + } + + { + mods = $1; key = $2; action = $3 + if (action == "") next + + # "Super, Alt" -> "Super + Alt", in a fixed order. + cnt = split(mods, m, /,[[:space:]]*/) + for (i = 1; i <= cnt; i++) gsub(/^[[:space:]]+|[[:space:]]+$/, "", m[i]) + for (i = 1; i < cnt; i++) + for (j = i + 1; j <= cnt; j++) + if (order(m[j]) < order(m[i])) { t = m[i]; m[i] = m[j]; m[j] = t } + + chord = "" + for (i = 1; i <= cnt; i++) chord = chord (chord == "" ? "" : " + ") m[i] + if (key != "") chord = chord (chord == "" ? "" : " + ") key + + # Spawn("cmd") is a command as written in cosmic.conf. + # System(X) is an action name that system_actions turns into a command. + # Everything else is internal to the compositor and has no command; the + # action itself is the honest answer, and inventing a program name for + # Focus(Left) would be a lie. + what = "" + if (action ~ /^Spawn\(".*"\)$/) { + what = substr(action, 8, length(action) - 9) + } else if (action ~ /^System\(/) { + name = action + sub(/^System\(/, "", name); sub(/\)$/, "", name) + what = (name in cmd) ? cmd[name] : words(name) + } else if (action ~ /\(/) { + name = action; arg = action + sub(/\(.*$/, "", name) + sub(/^[^(]*\(/, "", arg); sub(/\)$/, "", arg) + what = words(name) " " arg + } else { + what = words(action) + } + + # Last write wins, which is why custom is appended after defaults. + seen[chord] = what + if (!(chord in orderkey)) orderkey[chord] = ++seq + } + + END { + for (c in seen) printf "%d\t%s\t%s\n", orderkey[c], c, seen[c] + } + ' <<<"$raw" | sort -n | cut -f2- +)" + +count="$(grep -c . <<<"$rendered")" +((count > 0)) || fail "every shortcut line failed to parse; the RON format has changed" + +# Column-aligned so the chords form a readable left edge. Done here rather than +# in awk because the width has to be measured across the whole set first. +width="$(cut -f1 <<<"$rendered" | LC_ALL=C awk '{ if (length($0) > w) w = length($0) } END { print w }')" + +# -dmenu rather than a static window, so the list is filterable: 122 bindings is +# more than fits on a screen, and typing "workspace" is how you actually use +# this. No theme arguments, so ~/.config/rofi/config.rasi applies and it matches +# the launcher and the power menu. +# +# The selection is discarded. This is a reference, not a menu -- there is no +# sensible action for "you picked Super + Q", and running the bound command on +# Return would make an accidental keypress close a window from a help screen. +LC_ALL=C awk -F'\t' -v w="$width" '{ printf "%-*s %s\n", w, $1, $2 }' <<<"$rendered" | + rofi -dmenu -i -no-custom -no-show-icons -p "keys" \ + -mesg "$count shortcuts" >/dev/null 2>&1 || : diff --git a/config/cosmic.conf b/config/cosmic.conf index f3efca5..0da53fd 100644 --- a/config/cosmic.conf +++ b/config/cosmic.conf @@ -111,3 +111,22 @@ bind = $mainMod, Return, exec, cosmic-term # Super+Shift+E is Hyprland's own spelling for "exit". The menu also offers # lock and suspend, which is why the binding is not named after logout. bind = $mainMod SHIFT, E, exec, hyprcosmic-powermenu + +# --- Help --------------------------------------------------------------- +# +# Every shortcut in the session, in one searchable window, with the command +# each one runs. cosmic-settings can show these, but it is where you go to +# change a binding, not where you go to remember one -- and the hyprcosmic +# profile does not put it one click away. +# +# The script reads COSMIC's own Shortcuts files rather than this one. That is +# deliberate and it matters: this file declares six bindings and the session +# answers to 122, the rest being COSMIC defaults there is no reason to restate +# here. A reference built from this file alone would look complete and be +# missing every window, workspace and media key on the machine. +# +# Super+Shift+/ is Super+? on this keyboard, which is the usual spelling for +# help, and it is one of the few chords in that corner COSMIC leaves free -- +# Super+K and Super+I are both focus actions in the defaults. The same script +# backs waybar's keyboard button. +bind = $mainMod SHIFT, slash, exec, hyprcosmic-keybinds diff --git a/config/waybar/config.jsonc b/config/waybar/config.jsonc index 53a6b95..fa73592 100644 --- a/config/waybar/config.jsonc +++ b/config/waybar/config.jsonc @@ -50,15 +50,19 @@ "height": 34, "spacing": 4, - "modules-left": ["ext/workspaces", "wlr/taskbar"], + "modules-left": ["custom/keybinds", "ext/workspaces", "wlr/taskbar", "hyprland/window"], "modules-center": ["mpris", "clock", "privacy"], "modules-right": [ + "systemd-failed-units", "idle_inhibitor", "custom/swaync", "bluetooth", "pulseaudio", "network", + "disk", "temperature", + "temperature#gpu", + "custom/fan", "cpu", "memory", "power-profiles-daemon", @@ -67,6 +71,23 @@ "custom/power" ], + // Opens the keyboard reference. First on the bar because it is the thing + // you look for when you do not yet know where anything is. + // + // No "exec": this is a button, not a readout. A custom module with only a + // format and an on-click runs nothing on an interval, so it costs a static + // glyph and nothing else. + // + // The same script is on Super + Shift + / -- the binding is named in the + // tooltip because a help button you have to reach for with the mouse has + // not finished helping. + "custom/keybinds": { + "format": "\udb80\udf0c", + "tooltip": true, + "tooltip-format": "keyboard shortcuts\nSuper + Shift + /", + "on-click": "hyprcosmic-keybinds" + }, + "ext/workspaces": { "format": "{name}", "on-click": "activate" @@ -80,6 +101,56 @@ "on-click-middle": "close" }, + // The taskbar above shows which windows exist; this shows which one has the + // keyboard. Nothing else on the bar answered that. + // + // This is the one module here that talks to the fork's Hyprland IPC rather + // than to a Wayland protocol, and it needs no compositor change: waybar + // reads `j/activewindow` on startup and then follows `activewindow>>` on + // .socket2.sock, both of which hypr_ipc already serves. A retitle of the + // focused window propagates too -- src/hypr_ipc/sync.rs snapshots the + // focused window as (class, title) and emits on any difference, which is + // why upstream's separate `windowtitle` event is not needed here. Upstream + // Hyprland emits from call sites and so must announce that case + // separately; this fork diffs state on a 150 ms tick and cannot miss it. + // + // `separate-outputs` is left off deliberately: there is one bar on one + // output, and with it on, the module reports that monitor's active window + // rather than the focused one, which on a single head is the same answer by + // a longer route. + "hyprland/window": { + "format": "\uf2d0 {title}", + "max-length": 60, + "tooltip": true, + "tooltip-format": "{title}\n\nclass: {class}", + // Titles are written for a window's own title bar, not for a 60-column + // slot on a bar, so the worst offenders are trimmed to the part that + // identifies the window. ECMAScript regex; the value is a replacement + // string where $1 is the first capture. + // + // The separator is an em dash, and it is written as an escape + // for the same reason every glyph in this file is: the template is read + // as ASCII and the generator refuses to emit anything that is not, so a + // literal em dash pasted here fails the build rather than reaching the + // bar. COSMIC's applications really do use an em dash and not a hyphen + // -- `j/clients` reports "dingo@fedora:~ COSMIC Terminal" -- so + // a rule written with a hyphen matches nothing. + // + // There is deliberately no catch-all for the empty title. waybar's + // documentation does not say whether a rule is applied on a full match + // or a search, so an empty pattern is either "matches nothing but the + // empty title" or "matches every title", and the difference is the + // whole bar. The no-window case is handled in rules.css instead, the + // same way #mpris already handles having nothing to say. + "rewrite": { + "(.*) [-\u2014] Vivaldi": "$1", + "(.*) [-\u2014] COSMIC Terminal": "$1", + "(.*) [-\u2014] COSMIC Text Editor": "$1", + "(.*) [-\u2014] COSMIC Files": "$1", + "(.*) [-\u2014] Mozilla Firefox": "$1" + } + }, + // Renders nothing at all when no player is running, which is what we want // from a centre module: it takes no space until there is something to say. // playerctl backs every action here and is installed. @@ -120,6 +191,23 @@ ] }, + // First on the right, and silent unless something is wrong. A unit that + // failed at boot otherwise stays failed until the day you happen to run + // `systemctl --failed` -- there is no other surface for it in a COSMIC + // session, which has no equivalent of GNOME's abrt notification. + // + // Both scopes are watched. The user scope is where a session's own units + // fail, which is the half that matters here and the half a system-only + // check misses. + "systemd-failed-units": { + "format": "\udb80\udc26 {nr_failed}", + "format-ok": "", + "system": true, + "user": true, + "hide-on-ok": true, + "on-click": "cosmic-term -e sh -c 'systemctl --failed; systemctl --user --failed; exec $SHELL'" + }, + "idle_inhibitor": { "format": "{icon}", "format-icons": { @@ -205,6 +293,19 @@ "on-click": "cosmic-settings network" }, + // Root only. /home is on the same filesystem here, and a second entry for + // it would be the same number twice. + // + // No warning threshold is set: waybar's disk module has no `states`, so the + // colour comes from rules.css, which cannot see the percentage. The number + // is the warning. + "disk": { + "path": "/", + "format": "\udb80\udeca {percentage_used}%", + "interval": 60, + "tooltip-format": "{used} used of {total} on {path}, {free} free" + }, + // Tctl from k10temp, not thermal_zone0. The two read within a degree of // each other here, but the hwmon path names the CPU sensor explicitly. // @@ -220,6 +321,43 @@ "tooltip-format": "CPU package: {temperatureC} C" }, + // A second instance of the same module. `temperature#gpu` is waybar's alias + // syntax: everything before the # selects the module, everything after is + // just a name, and it becomes #temperature.gpu in CSS. + // + // The dGPU's own sensor, not the CPU's. On this machine the two diverge by + // 30 degrees under load, so one reading cannot stand for both. + // + // temp1_label reads "edge" -- the die edge, which is the sensor amdgpu + // exposes on this card. There is no junction or memory sensor here to + // prefer over it. + // + // hwmon-path-abs is safe for this one: the PCI device has exactly one + // hwmonN inside it, unlike the asus platform device that custom/fan below + // has to work around. + "temperature#gpu": { + "hwmon-path-abs": "/sys/devices/pci0000:00/0000:00:08.1/0000:05:00.0/hwmon", + "input-filename": "temp1_input", + "format": "\udb82\udcae {temperatureC} C", + "critical-threshold": 95, + "interval": 5, + "tooltip-format": "GPU edge: {temperatureC} C" + }, + + // Speed of the fastest fan, with every fan in the tooltip. See the script + // for why this is not a temperature module with a different divisor and not + // an hwmon-path-abs: both were tried and neither can name this sensor. + // + // Emits nothing on a machine with no readable fan, and waybar draws an + // empty custom module as nothing, so this costs no space on hardware that + // has none. + "custom/fan": { + "exec": "hyprcosmic-fan", + "return-type": "json", + "format": "\udb80\ude10 {} rpm", + "interval": 5 + }, + "cpu": { "format": "\udb80\udf5b {usage}%", "interval": 5 diff --git a/config/waybar/config.jsonc.in b/config/waybar/config.jsonc.in index 77c955a..520b451 100644 --- a/config/waybar/config.jsonc.in +++ b/config/waybar/config.jsonc.in @@ -50,15 +50,19 @@ "height": 34, "spacing": 4, - "modules-left": ["ext/workspaces", "wlr/taskbar"], + "modules-left": ["custom/keybinds", "ext/workspaces", "wlr/taskbar", "hyprland/window"], "modules-center": ["mpris", "clock", "privacy"], "modules-right": [ + "systemd-failed-units", "idle_inhibitor", "custom/swaync", "bluetooth", "pulseaudio", "network", + "disk", "temperature", + "temperature#gpu", + "custom/fan", "cpu", "memory", "power-profiles-daemon", @@ -67,6 +71,23 @@ "custom/power" ], + // Opens the keyboard reference. First on the bar because it is the thing + // you look for when you do not yet know where anything is. + // + // No "exec": this is a button, not a readout. A custom module with only a + // format and an on-click runs nothing on an interval, so it costs a static + // glyph and nothing else. + // + // The same script is on Super + Shift + / -- the binding is named in the + // tooltip because a help button you have to reach for with the mouse has + // not finished helping. + "custom/keybinds": { + "format": "@@KEYBOARD@@", + "tooltip": true, + "tooltip-format": "keyboard shortcuts\nSuper + Shift + /", + "on-click": "hyprcosmic-keybinds" + }, + "ext/workspaces": { "format": "{name}", "on-click": "activate" @@ -80,6 +101,56 @@ "on-click-middle": "close" }, + // The taskbar above shows which windows exist; this shows which one has the + // keyboard. Nothing else on the bar answered that. + // + // This is the one module here that talks to the fork's Hyprland IPC rather + // than to a Wayland protocol, and it needs no compositor change: waybar + // reads `j/activewindow` on startup and then follows `activewindow>>` on + // .socket2.sock, both of which hypr_ipc already serves. A retitle of the + // focused window propagates too -- src/hypr_ipc/sync.rs snapshots the + // focused window as (class, title) and emits on any difference, which is + // why upstream's separate `windowtitle` event is not needed here. Upstream + // Hyprland emits from call sites and so must announce that case + // separately; this fork diffs state on a 150 ms tick and cannot miss it. + // + // `separate-outputs` is left off deliberately: there is one bar on one + // output, and with it on, the module reports that monitor's active window + // rather than the focused one, which on a single head is the same answer by + // a longer route. + "hyprland/window": { + "format": "@@WINDOW@@ {title}", + "max-length": 60, + "tooltip": true, + "tooltip-format": "{title}\n\nclass: {class}", + // Titles are written for a window's own title bar, not for a 60-column + // slot on a bar, so the worst offenders are trimmed to the part that + // identifies the window. ECMAScript regex; the value is a replacement + // string where $1 is the first capture. + // + // The separator is an em dash, and it is written as an escape + // for the same reason every glyph in this file is: the template is read + // as ASCII and the generator refuses to emit anything that is not, so a + // literal em dash pasted here fails the build rather than reaching the + // bar. COSMIC's applications really do use an em dash and not a hyphen + // -- `j/clients` reports "dingo@fedora:~ COSMIC Terminal" -- so + // a rule written with a hyphen matches nothing. + // + // There is deliberately no catch-all for the empty title. waybar's + // documentation does not say whether a rule is applied on a full match + // or a search, so an empty pattern is either "matches nothing but the + // empty title" or "matches every title", and the difference is the + // whole bar. The no-window case is handled in rules.css instead, the + // same way #mpris already handles having nothing to say. + "rewrite": { + "(.*) [-\u2014] Vivaldi": "$1", + "(.*) [-\u2014] COSMIC Terminal": "$1", + "(.*) [-\u2014] COSMIC Text Editor": "$1", + "(.*) [-\u2014] COSMIC Files": "$1", + "(.*) [-\u2014] Mozilla Firefox": "$1" + } + }, + // Renders nothing at all when no player is running, which is what we want // from a centre module: it takes no space until there is something to say. // playerctl backs every action here and is installed. @@ -120,6 +191,23 @@ ] }, + // First on the right, and silent unless something is wrong. A unit that + // failed at boot otherwise stays failed until the day you happen to run + // `systemctl --failed` -- there is no other surface for it in a COSMIC + // session, which has no equivalent of GNOME's abrt notification. + // + // Both scopes are watched. The user scope is where a session's own units + // fail, which is the half that matters here and the half a system-only + // check misses. + "systemd-failed-units": { + "format": "@@ALERT@@ {nr_failed}", + "format-ok": "", + "system": true, + "user": true, + "hide-on-ok": true, + "on-click": "cosmic-term -e sh -c 'systemctl --failed; systemctl --user --failed; exec $SHELL'" + }, + "idle_inhibitor": { "format": "{icon}", "format-icons": { @@ -205,6 +293,19 @@ "on-click": "cosmic-settings network" }, + // Root only. /home is on the same filesystem here, and a second entry for + // it would be the same number twice. + // + // No warning threshold is set: waybar's disk module has no `states`, so the + // colour comes from rules.css, which cannot see the percentage. The number + // is the warning. + "disk": { + "path": "/", + "format": "@@DISK@@ {percentage_used}%", + "interval": 60, + "tooltip-format": "{used} used of {total} on {path}, {free} free" + }, + // Tctl from k10temp, not thermal_zone0. The two read within a degree of // each other here, but the hwmon path names the CPU sensor explicitly. // @@ -220,6 +321,43 @@ "tooltip-format": "CPU package: {temperatureC} C" }, + // A second instance of the same module. `temperature#gpu` is waybar's alias + // syntax: everything before the # selects the module, everything after is + // just a name, and it becomes #temperature.gpu in CSS. + // + // The dGPU's own sensor, not the CPU's. On this machine the two diverge by + // 30 degrees under load, so one reading cannot stand for both. + // + // temp1_label reads "edge" -- the die edge, which is the sensor amdgpu + // exposes on this card. There is no junction or memory sensor here to + // prefer over it. + // + // hwmon-path-abs is safe for this one: the PCI device has exactly one + // hwmonN inside it, unlike the asus platform device that custom/fan below + // has to work around. + "temperature#gpu": { + "hwmon-path-abs": "/sys/devices/pci0000:00/0000:00:08.1/0000:05:00.0/hwmon", + "input-filename": "temp1_input", + "format": "@@GPU@@ {temperatureC} C", + "critical-threshold": 95, + "interval": 5, + "tooltip-format": "GPU edge: {temperatureC} C" + }, + + // Speed of the fastest fan, with every fan in the tooltip. See the script + // for why this is not a temperature module with a different divisor and not + // an hwmon-path-abs: both were tried and neither can name this sensor. + // + // Emits nothing on a machine with no readable fan, and waybar draws an + // empty custom module as nothing, so this costs no space on hardware that + // has none. + "custom/fan": { + "exec": "hyprcosmic-fan", + "return-type": "json", + "format": "@@FAN@@ {} rpm", + "interval": 5 + }, + "cpu": { "format": "@@CPU@@ {usage}%", "interval": 5 diff --git a/config/waybar/generate-config.py b/config/waybar/generate-config.py index 0e3bfe1..2c99729 100755 --- a/config/waybar/generate-config.py +++ b/config/waybar/generate-config.py @@ -55,6 +55,12 @@ ICONS = { "BAT_80": 0xF0081, "BAT_90": 0xF0082, "BAT_100": 0xF0079, + "WINDOW": 0xF2D0, + "ALERT": 0xF0026, + "DISK": 0xF02CA, + "GPU": 0xF08AE, + "FAN": 0xF0210, + "KEYBOARD": 0xF030C, } diff --git a/config/waybar/rules.css b/config/waybar/rules.css index ca55dbb..496ed14 100644 --- a/config/waybar/rules.css +++ b/config/waybar/rules.css @@ -77,15 +77,20 @@ window#waybar { /* Every status module gets the same pill. Listed one per line because waybar * takes the CSS id from the module name with `/` turned into `-`, so these are * not guessable from the config and are worth being able to read down. */ +#custom-keybinds, +#window, #mpris, #clock, #privacy, +#systemd-failed-units, #idle_inhibitor, #custom-swaync, #bluetooth, #pulseaudio, #network, +#disk, #temperature, +#custom-fan, #cpu, #memory, #power-profiles-daemon, @@ -106,6 +111,12 @@ window#waybar { color: @critical; } +/* The other button on the bar. Accent rather than critical, because this one is + * safe to press. */ +#custom-keybinds:hover { + color: @accent; +} + #clock { font-weight: bold; } @@ -137,12 +148,35 @@ window#waybar { color: @warning; } +/* Same reasoning as mpris, but the class is not on the widget. waybar puts + * `empty` on the whole bar rather than on #window, so the selector has to + * descend into it -- waybar-hyprland-window(5) gives it as exactly this: + * + * window#waybar.empty #window When no windows are in the workspace + * + * The first `window` is the GTK window, the second is our module; they are + * unrelated names that collide, which is why this reads so oddly. */ +window#waybar.empty #window { + padding: 0; + background: transparent; +} + /* States. These recolour the text inside the pill rather than the pill, so a * warning cannot make a module unreadable against its own background. */ #battery.warning { color: @warning; } #battery.critical { color: @critical; } #temperature.critical { color: @critical; } #network.disconnected { color: @critical; } + +/* No state class needed: hide-on-ok removes the module entirely at zero, so it + * is on screen only when there is something to report and can be coloured + * unconditionally. */ +#systemd-failed-units { color: @critical; } + +/* Set by hyprcosmic-fan: `high` above 4000 rpm, `idle` when every fan has + * stopped. `normal` is left alone. */ +#custom-fan.high { color: @warning; } +#custom-fan.idle { color: @muted; } #pulseaudio.muted { color: @muted; } #idle_inhibitor.activated { color: @accent; } #custom-swaync.notification { color: @accent; } diff --git a/tools/install-assets.sh b/tools/install-assets.sh index 80ae85e..2e9f7b3 100755 --- a/tools/install-assets.sh +++ b/tools/install-assets.sh @@ -76,6 +76,8 @@ SHARED=( "config/rofi/palette.rasi:share/hyprcosmic/rofi/palette.rasi:644" "config/rofi/rules.rasi:share/hyprcosmic/rofi/rules.rasi:644" "config/bin/hyprcosmic-powermenu:bin/hyprcosmic-powermenu:755" + "config/bin/hyprcosmic-fan:bin/hyprcosmic-fan:755" + "config/bin/hyprcosmic-keybinds:bin/hyprcosmic-keybinds:755" ) # The session entry point. Kept apart from SHARED because it is versioned in the @@ -116,7 +118,13 @@ audit_config_tree() { while IFS= read -r -d '' f; do rel="${f#"$REPO"/}" [[ "$known" == *" $rel "* ]] || unclassified+=("$rel") - done < <(find "$REPO/config" -type f -print0 | sort -z) + # Dot-directories are pruned. Nothing shipped lives in one, and tooling + # drops state inside the tree without asking -- .omc/ appeared under + # config/waybar/ and failed this audit with six files that are gitignored + # and are not assets. Failing on those trains you to ignore the one message + # that catches a genuinely unclassified file. Dot *files* are still walked; + # only directories are pruned. + done < <(find "$REPO/config" -name '.?*' -type d -prune -o -type f -print0 | sort -z) ((${#unclassified[@]} == 0)) || die "not listed as shared or per-user: ${unclassified[*]} Add each to SHARED or PER_USER in $(basename "${BASH_SOURCE[0]}") and say which."