Files
Nomarchy/modules/home/waybar.nix
Bernardo Magri 01ee847490
All checks were successful
Check / eval (push) Successful in 3m3s
feat(bar): timezone line in the clock tooltip (LATER item)
Hovering the clock now leads with the zone — "{:%Z (UTC%z)}" above the
calendar — on the generated bar and both summer whole-swaps (parity).
Under auto-timezone the existing tz-watch SIGUSR2 reload keeps it
showing the currently detected zone. %Z is the abbreviation (BST), not
the IANA name — strftime has no code for that, and it satisfies the
"(optional)" nicety.

Verified: V0; V1 — the rendered generated config carries the new
tooltip-format and both summer jsonc parse with it. Hover render
queued for hardware.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 21:56:44 +01:00

418 lines
16 KiB
Nix

# Waybar — two-tier theming:
#
# 1. Default: structure, fonts, geometry AND palette baked from
# theme-state.json (colors as GTK named colors, @define-color).
#
# 2. Whole-swap: themes with their own visual identity ship
# <themesDir>/<slug>/waybar.css (and optionally waybar.jsonc) which
# replace the generated style/layout entirely. Probed at eval time —
# pure, it's flake source.
{ config, lib, pkgs, ... }:
let
t = config.nomarchy.theme;
# Show the active-keyboard-layout indicator only when more than one layout
# is in play — multiple session layouts (comma-separated) or per-device
# overrides (nomarchy.keyboard.devices) — so single-layout bars stay clean.
showLanguage = lib.hasInfix "," config.nomarchy.keyboard.layout
|| config.nomarchy.keyboard.layouts != [ ]
|| config.nomarchy.keyboard.devices != { };
# Power-profile indicator (power-profiles-daemon). Both scripts self-
# gate: they exit silently unless this is a laptop (a battery is
# present) running PPD, so the module hides itself on desktops and
# under the TLP backend — no system→home coupling. Speedometer glyphs
# (slow/medium/fast) map to power-saver/balanced/performance.
#
# Named writeShellScriptBins (put on PATH via home.packages below) rather
# than bare writeShellScript store paths, so the whole-swap themes' static
# waybar.jsonc can exec them by name too — same as the swaync bell.
# Waybar supervisor — exec-once has no restart, so a crashed bar used to
# leave the session bar-less until relogin (seen on hardware: a theme
# switch crashed waybar mid-reload). Respawns on ANY exit — a plain
# `pkill -x waybar` is now a clean restart, which nomarchy-theme-sync
# uses instead of the crash-prone in-place SIGUSR2 reload when it sees
# this supervisor running. Crash-loop guard: 5 exits within 10s of
# their start → give up with a critical notification instead of
# spinning. Stop the bar for real: pkill -f nomarchy-waybar (TERM is
# trapped to take the child down too).
waybarSupervisor = pkgs.writeShellScriptBin "nomarchy-waybar" ''
child=
trap '[ -n "$child" ] && kill "$child" 2>/dev/null; exit 0' TERM INT
fails=0
while :; do
start=$(date +%s)
waybar & child=$!
wait "$child"; code=$?
if [ $(( $(date +%s) - start )) -lt 10 ]; then
fails=$((fails + 1))
if [ "$fails" -ge 5 ]; then
notify-send -u critical "Waybar" \
"Crashing on start (exit $code) check ~/.config/waybar. Giving up." 2>/dev/null
exit 1
fi
else
fails=0
fi
sleep 1
done
'';
powerProfileStatus = pkgs.writeShellScriptBin "nomarchy-powerprofile-status" ''
case "$(ls /sys/class/power_supply/ 2>/dev/null)" in *BAT*) ;; *) exit 0 ;; esac
command -v powerprofilesctl >/dev/null 2>&1 || exit 0
prof=$(powerprofilesctl get 2>/dev/null) || exit 0
case "$prof" in
power-saver) icon="󰾆" ;;
performance) icon="󰓅" ;;
*) icon="󰾅" ;;
esac
printf '{"text":"%s","tooltip":"Power profile: %s","class":"%s"}\n' "$icon" "$prof" "$prof"
'';
# Cycle to the next profile this machine actually offers (wraps around).
powerProfileCycle = pkgs.writeShellScriptBin "nomarchy-powerprofile-cycle" ''
command -v powerprofilesctl >/dev/null 2>&1 || exit 0
cur=$(powerprofilesctl get 2>/dev/null) || exit 0
avail=$(powerprofilesctl list 2>/dev/null | sed -nE 's/^[* ] ([a-z-]+):$/\1/p')
next=$(printf '%s\n' "$avail" | awk -v cur="$cur" '
{ a[NR] = $0 }
END { for (i = 1; i <= NR; i++) if (a[i] == cur) { print a[i % NR + 1]; f = 1 }
if (!f && NR) print a[1] }')
[ -n "$next" ] && powerprofilesctl set "$next"
'';
# VPN indicator — shows a shield when a NetworkManager VPN/WireGuard
# connection is active OR Tailscale is up; prints nothing otherwise so the
# module self-hides (like nightlight/updates). Click opens the VPN submenu.
vpnStatus = pkgs.writeShellScriptBin "nomarchy-vpn-status" ''
active=$(nmcli -t -f TYPE connection show --active 2>/dev/null | grep -Exm1 'vpn|wireguard')
ts=""
if command -v tailscale >/dev/null 2>&1; then
[ "$(tailscale status --json 2>/dev/null | jq -r '.BackendState // empty')" = Running ] && ts=1
fi
[ -n "$active" ] || [ -n "$ts" ] || exit 0
printf '{"text":"󰦝","tooltip":"VPN active (click to manage)","class":"on"}\n'
'';
# Health warning — self-gates: prints nothing while nomarchy-doctor
# exits 0, so the module only appears when the sheet has a ✖ (the
# same self-hide discipline as vpn/nightlight/updates). The tooltip
# carries the first failing lines; click opens the full sheet.
doctorStatus = pkgs.writeShellScriptBin "nomarchy-doctor-status" ''
command -v nomarchy-doctor >/dev/null 2>&1 || exit 0
out=$(nomarchy-doctor 2>/dev/null) && exit 0
tip=$(printf '%s\n' "$out" | grep '' | head -5 \
| ${pkgs.jq}/bin/jq -Rs 'rtrimstr("\n") + "\n(click for the full sheet)"')
printf '{"text":"","tooltip":%s,"class":"bad"}\n' "$tip"
'';
# Per-theme override probe.
assetDir = config.nomarchy.themesDir + "/${t.slug}";
styleOverride = assetDir + "/waybar.css";
configOverride = assetDir + "/waybar.jsonc";
hasStyleOverride = builtins.pathExists styleOverride;
hasConfigOverride = builtins.pathExists configOverride;
# The palette as GTK named colors, straight from the JSON.
colorDefs = lib.concatStringsSep "\n"
(lib.mapAttrsToList (name: value: "@define-color ${name} ${value};") t.colors);
generatedSettings = {
layer = "top";
position = "top";
height = 34;
margin-top = t.ui.gapsOut;
margin-left = t.ui.gapsOut;
margin-right = t.ui.gapsOut;
spacing = 8;
# waybar re-reads style.css when it changes on disk, so a
# home-manager switch restyles the running bar without a restart.
reload_style_on_change = true;
modules-left = [ "hyprland/workspaces" "hyprland/window" ];
modules-center = [ "clock" ];
modules-right = [ "custom/recording" "idle_inhibitor" "tray" "custom/vpn" "pulseaudio" "cpu" "memory" "custom/powerprofile" "custom/nightlight" ]
++ lib.optional showLanguage "hyprland/language"
++ [ "battery" "custom/doctor" "custom/updates" "custom/notification" ];
"hyprland/workspaces" = {
format = "{icon}";
on-click = "activate";
};
"hyprland/window" = {
max-length = 48;
separate-outputs = true;
};
clock = {
format = "{:%H:%M}";
format-alt = "{:%A %d %B %Y}";
# Zone line first (LATER nicety): under auto-timezone the tz-watch
# SIGUSR2 reload keeps it showing the currently *detected* zone.
tooltip-format = "{:%Z (UTC%z)}\n<tt><small>{calendar}</small></tt>";
};
# Active keyboard layout (per focused device) — only placed in
# modules-right when showLanguage (see above).
"hyprland/language" = {
format = "󰌌 {short}";
tooltip = false;
};
pulseaudio = {
format = "{icon} {volume}%";
format-muted = "󰝟";
format-icons.default = [ "󰕿" "󰖀" "󰕾" ];
on-click = "wpctl set-mute @DEFAULT_AUDIO_SINK@ toggle";
};
# Idle inhibitor (caffeine): click 󰾪 → 󰅶 to hold the screen awake —
# blocks hypridle's lock / display-off / suspend during video or a
# presentation. Waybar holds a Wayland idle-inhibit while activated;
# the state resets with the bar (deliberate — caffeine shouldn't
# survive a relogin). Summer-night's whole-swap bar had this first
# (reverse parity gap, 2026-07-04).
idle_inhibitor = {
format = "{icon}";
format-icons = { activated = "󰅶"; deactivated = "󰾪"; };
tooltip-format-activated = "Screen held awake click to release";
tooltip-format-deactivated = "Keep the screen awake (caffeine)";
};
# No network module: nm-applet lives in the tray (the GUI path), so the
# bar's wifi/ethernet indicator would just duplicate it.
cpu.format = "󰍛 {usage}%";
memory.format = "󰾆 {percentage}%";
battery = {
states = { warning = 25; critical = 10; };
format = "{icon} {capacity}%";
format-charging = "󰂄 {capacity}%";
format-icons = [ "󰁺" "󰁼" "󰁾" "󰂀" "󰁹" ];
};
"custom/powerprofile" = {
exec = "nomarchy-powerprofile-status";
return-type = "json";
interval = 5;
on-click = "nomarchy-powerprofile-cycle";
};
# VPN shield — self-hides unless a NM VPN/WireGuard tunnel or Tailscale is
# up. Click opens the VPN submenu (nomarchy-vpn). 5s poll like powerprofile.
"custom/vpn" = {
exec = "nomarchy-vpn-status";
return-type = "json";
interval = 5;
on-click = "nomarchy-vpn";
};
# Health check is real work (systemctl/disk/git sweeps), so a long
# interval — this is a tripwire, not a monitor.
"custom/doctor" = {
exec = "nomarchy-doctor-status";
return-type = "json";
interval = 300;
on-click = "nomarchy-menu doctor";
};
# Screen recording indicator. Self-gates: visible only while
# nomarchy-record runs (status prints nothing otherwise), and the
# click IS the stop surface — the menu only starts recordings.
# signal 8: the recorder pokes the bar on start/stop so the ⏺
# appears/vanishes instantly (the interval is just a safety net).
"custom/recording" = {
exec = "nomarchy-record status";
return-type = "json";
interval = 10;
signal = 8;
on-click = "nomarchy-record stop";
};
# Night-light (hyprsunset) indicator. Self-gates: the moon shows only while
# the schedule runs; otherwise the status helper prints nothing => hidden
# (enable / re-enable from the System menu). Click toggles instantly — writes
# the in-flake on/off (settings.nightlight.on, no rebuild) and flips the unit
# with systemctl, so the choice lands in the flake and survives reboot.
"custom/nightlight" = {
exec = "nomarchy-nightlight status";
return-type = "json";
interval = 3;
on-click = "nomarchy-nightlight toggle";
};
# Update awareness. Self-gates: hidden unless nomarchy.updates is enabled
# AND the periodic check found something (the helper prints nothing then).
# signal 9 lets the checker refresh it instantly; click opens the upgrade
# flow in a terminal.
"custom/updates" = {
exec = "nomarchy-updates status";
return-type = "json";
interval = 1800;
signal = 9;
on-click = "${config.nomarchy.terminal} -e nomarchy-updates upgrade";
};
# swaync notification bell + Do-Not-Disturb state. `-swb` streams JSON
# (text/tooltip/class) on every change, so it tracks count and DND with
# no polling. Left-click toggles the panel; right-click toggles DND.
# The `dnd-*` classes (bell-off glyph) are styled muted below.
"custom/notification" = {
exec = "swaync-client -swb";
return-type = "json";
exec-if = "which swaync-client";
format = "{icon}";
format-icons = {
none = "󰂜";
notification = "󰂚";
dnd-none = "󰂛";
dnd-notification = "󰂛";
inhibited-none = "󰂜";
inhibited-notification = "󰂚";
dnd-inhibited-none = "󰂛";
dnd-inhibited-notification = "󰂛";
};
on-click = "swaync-client -t -sw";
on-click-right = "swaync-client -d -sw";
tooltip = true;
escape = true;
};
tray.spacing = 8;
};
generatedStyle = ''
/* Palette baked from theme-state.json */
${colorDefs}
* {
font-family: "${t.fonts.ui}", "${t.fonts.mono}";
font-size: ${toString t.fonts.size}pt;
min-height: 0;
}
window#waybar {
background: alpha(@base, 0.85);
color: @text;
border: ${toString t.ui.borderSize}px solid alpha(@accent, 0.4);
border-radius: ${toString t.ui.rounding}px;
}
/* Dim states use the palette's @muted role: since item 28b it is
floor-guaranteed legible on @base in every theme (muted/base >=
2.0, gated by tools/check-theme-contrast.py) the palettes that
once made it vanish (gruvbox mutedbase, item 27) were retuned.
Secondary-but-not-dim stays alpha(@text, 0.85). */
#workspaces button {
padding: 0 8px;
color: @muted;
border-radius: ${toString t.ui.rounding}px;
}
#workspaces button.active {
color: @base;
background: @accent;
}
#workspaces button.urgent {
color: @base;
background: @bad;
}
#window {
color: alpha(@text, 0.85);
padding: 0 12px;
}
#clock {
color: @text;
font-weight: bold;
}
#tray, #pulseaudio, #cpu, #memory, #custom-powerprofile, #custom-nightlight, #custom-updates, #custom-vpn, #custom-recording, #idle_inhibitor, #language, #battery, #custom-doctor, #custom-notification {
color: alpha(@text, 0.85);
padding: 0 10px;
}
/* Group rhythm (item 28c): a wider breath before each functional
group of the right cluster media/stats · toggles · status
on top of the uniform module spacing. A group head can self-hide
(e.g. no battery on desktops); grouping then degrades to the
uniform spacing, never breaks. */
#pulseaudio, #custom-powerprofile, #battery { margin-left: 14px; }
/* Caffeine engaged warm tone (the screen is being held awake). */
#idle_inhibitor.activated { color: @warn; }
/* Recording in progress the alert red; the is also the stop button. */
#custom-recording.recording { color: @bad; }
/* VPN active accent green, reading as "connected / protected". */
#custom-vpn.on { color: @good; }
/* Night-light active warm tone, matching the filter it represents. */
#custom-nightlight.on { color: @warn; }
/* Updates pending accent, to draw the eye. */
#custom-updates.available { color: @accent; }
/* Doctor tripwire only rendered when something is , so it is
always the alert color. */
#custom-doctor { color: @bad; }
/* The power-profile speedometer glyph renders small in its em box
size it up so it reads at a glance like the other indicators. */
#custom-powerprofile { font-size: ${toString (t.fonts.size + 3)}pt; }
/* notifications waiting accent; Do-Not-Disturb muted bell-off */
#custom-notification.notification { color: @accent; }
#custom-notification.dnd-none,
#custom-notification.dnd-notification { color: @muted; }
#pulseaudio.muted { color: @muted; }
#battery.warning { color: @warn; }
#battery.critical { color: @bad; }
#battery.charging { color: @good; }
'';
in
{
programs.waybar = lib.mkIf config.nomarchy.waybar.enable {
enable = true;
# Launched from Hyprland's exec-once (hyprland.nix), NOT a systemd user
# service. Bound to graphical-session.target the unit raced Hyprland's IPC
# on a warm relogin — it started before the socket was up, exited, landed
# in `failed`, and was never retried, so the bar vanished. exec-once only
# fires once Hyprland is up, dodging the race; theme switches reload the
# running bar via SIGUSR2 (nomarchy-theme-sync). No uwsm here to manage the
# session target, so we don't depend on its lifecycle.
systemd.enable = false;
# mkDefault so downstream can replace the whole bar config/style with
# a plain home.nix assignment. For per-theme identity, prefer the
# whole-swap assets (themes/<slug>/waybar.{jsonc,css}); for one-off
# tweaks of the generated bar, lib.mkForce a sub-key. See
# docs/OVERRIDES.md.
settings.mainBar = lib.mkDefault (
if hasConfigOverride
then builtins.fromJSON (builtins.readFile configOverride)
else generatedSettings
);
style = lib.mkDefault (
if hasStyleOverride
then builtins.readFile styleOverride
else generatedStyle
);
};
# The power-profile helpers on PATH, so both the generated bar and the
# whole-swap themes' static waybar.jsonc can exec them by name — plus
# the supervisor hyprland.nix exec-onces.
home.packages = lib.optionals config.nomarchy.waybar.enable
[ waybarSupervisor powerProfileStatus powerProfileCycle vpnStatus doctorStatus ];
}