Files
Nomarchy/modules/home/waybar.nix
Bernardo Magri ac7e6bdb4e
Some checks failed
Check / eval (push) Failing after 1m22s
fix(waybar): scope the universal CSS reset to the bar — un-hides tray submenu arrows
BACKLOG #90 root-caused: the arrows were never missing from the icon
theme — Papirus-Dark resolves pan-end-symbolic through its breeze-dark
inheritance (breeze ships inside the nixpkgs papirus output). The real
culprit: Waybar loads its stylesheet screen-wide at application
priority, so the tray menus it hosts for SNI apps (nm-applet & co)
inherit the bar's '* { min-height: 0; }', which out-prioritizes
adw-gtk3's 16px min-height on the menuitem arrow node and collapses
the submenu arrow to zero height. Property bisect confirmed
min-height:0 alone reproduces; the font/border resets don't.

- boreal + executive-slate waybar.css, waybar.nix generatedStyle:
  '*' block scoped to 'window#waybar, window#waybar *'; tooltip rules
  restate the font (tooltips are separate toplevels outside the scope).
- stylix.nix: drop the go-next-symbolic remap — a workaround for the
  misdiagnosis; native pan-end-symbolic resolves fine.
- BACKLOG #90 deleted; JOURNAL entry records the method (live GTK3
  repro + grim screenshots + CSS property bisect, V3 on TuringMachine).

V3 pending: human click on the real nm-applet tray icon post-switch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 07:47:37 +01:00

549 lines
23 KiB
Nix
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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" ''
# Name-agnostic system battery (BAT0, CMB0, ) BACKLOG #60.
has_bat=
for d in /sys/class/power_supply/*/; do
[ "$(cat "$d/type" 2>/dev/null)" = Battery ] || continue
[ "$(cat "$d/scope" 2>/dev/null || echo System)" = Device ] && continue
has_bat=1; break
done
[ -n "$has_bat" ] || exit 0
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"
'';
# Opens calcurse (a lightweight TUI calendar, month view by default) in a
# floating, centered ghostty window — bound to the Waybar clock's on-click.
# A distinct --class gives the window a matchable app-id for the
# float+center+size windowrule (hyprland.nix); --gtk-single-instance=false
# forces a fresh standalone window (ghostty defaults single-instance on).
# calcurse is installed with the bar (home.packages below); the gate is
# only for someone who force-removed it. ghostty is always installed
# (ghostty.nix), so it can be relied on for the --class window.
calendarLauncher = pkgs.writeShellScriptBin "nomarchy-calendar" ''
if ! command -v calcurse >/dev/null 2>&1; then
notify-send "Calendar" "calcurse isn't installed (removed from home.packages?)." 2>/dev/null
exit 0
fi
exec ghostty --class=com.nomarchy.calendar --gtk-single-instance=false -e calcurse
'';
# 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). The tooltip names each
# active VPN (NM connection names, "Tailscale") — without it a forgotten
# tailscaled reads as "the icon is stuck". Click opens the VPN submenu.
vpnStatus = pkgs.writeShellScriptBin "nomarchy-vpn-status" ''
names=$(nmcli -t -f NAME,TYPE connection show --active 2>/dev/null \
| awk -F: '$2=="vpn"||$2=="wireguard"{print $1}')
ts=""
if command -v tailscale >/dev/null 2>&1; then
[ "$(tailscale status --json 2>/dev/null | jq -r '.BackendState // empty')" = Running ] && ts=Tailscale
fi
[ -n "$names$ts" ] || exit 0
list=$(printf '%s\n' "$names" "$ts" | grep -v '^$' | paste -sd ', ' -)
tip=$(printf 'VPN: %s\n(click to manage)' "$list" | jq -Rs 'rtrimstr("\n")')
printf '{"text":"󰦝","tooltip":%s,"class":"on"}\n' "$tip"
'';
# 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.
# Absolute path to the doctor binary: waybar's custom-module env can
# miss system PATH, and `command -v … || exit 0` then self-hides forever.
doctorStatus = pkgs.writeShellScriptBin "nomarchy-doctor-status" ''
out=$(${pkgs.nomarchy-doctor}/bin/nomarchy-doctor 2>/dev/null) && exit 0
# Strip ANSI so the tooltip is plain text (and waybar never chokes on
# control chars); keep only the lines.
tip=$(printf '%s\n' "$out" \
| ${pkgs.gnused}/bin/sed 's/\x1b\[[0-9;]*m//g' \
| grep '' | head -5 \
| ${pkgs.jq}/bin/jq -Rs 'rtrimstr("\n") + "\n(click for the full sheet)"')
# 󰀨 (md-alert-circle): alert shape that survives incomplete Nerd Font
# cuts better than 󰒡; class:bad still paints it @bad.
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 = {
# `bottom`, not `top`: on Hyprland the `top` layer renders above every
# window — so a real fullscreen surface (a browser video gone
# fullscreen) sits *under* the bar. On `bottom` the fullscreen window
# covers the bar, while the exclusive zone still reserves its space in
# normal tiling. Keep in sync with the whole-swap jsoncs (parity rule).
layer = "bottom";
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;
# Logo + powermenu: whole-swaps already ship these (parity was reverse
# — BACKLOG #63). Click targets are existing nomarchy-menu entry points.
modules-left = [ "custom/nomarchy" "hyprland/workspaces" "hyprland/window" ];
modules-center = [ "clock" ];
modules-right = [ "custom/recording" "idle_inhibitor" "tray" "custom/vpn" "pulseaudio" "custom/powerprofile" "custom/nightlight" ]
++ lib.optional showLanguage "hyprland/language"
++ [ "battery" "custom/doctor" "custom/updates" "custom/notification" "custom/powermenu" ];
"custom/nomarchy" = {
interval = "once";
# U+F000 — Nomarchy monogram (literal UTF-8; Nix has no \u escapes).
# CSS pins font-family: Nomarchy so Nerd Fonts' glass glyph does not win.
format = "";
on-click = "nomarchy-menu";
tooltip-format = "Nomarchy menu";
};
"custom/powermenu" = {
format = ""; # U+F011 power symbol
on-click = "nomarchy-menu power";
tooltip = false;
};
"hyprland/workspaces" = {
format = "{icon}";
on-click = "activate";
};
"hyprland/window" = {
max-length = 48;
separate-outputs = true;
};
clock = {
# Single module: time + short date (whole-swaps used to split these
# into clock + clock#date — one click surface for the calendar).
format = "{:%H:%M · %a %d %b}";
# Left-click → the calendar (nomarchy-calendar → calcurse in a floating
# ghostty). Tooltip is plain date/zone only — embedding {calendar}
# produced an empty popup on hardware (2026-07-10); month view is
# one click away.
on-click = "nomarchy-calendar";
tooltip = true;
# One chrono block only: waybar/fmt empties the tooltip when two
# bare `{:%…}` specs are concatenated (bar format works because it
# is a single block). Newline + zone live under auto-timezone
# (tz-watch SIGUSR2 reload).
tooltip-format = "{:%A, %d %B %Y\n%Z (UTC%z)}";
};
# Active keyboard layout (per focused device) — only placed in
# modules-right when showLanguage (see above).
"hyprland/language" = {
# Two spaces after the glyph: the keyboard icon's right bearing
# otherwise kisses the layout code (esp. mono Nerd faces).
format = "<span size='${toString (t.fonts.size + 2)}pt'>󰌌 </span>{short}";
tooltip = false;
};
pulseaudio = {
# Two trailing spaces on every icon level: high-volume glyphs are the
# widest and still kissed the % with a single pad (hardware report).
format = "<span size='${toString (t.fonts.size + 2)}pt'>{icon}</span>{volume}%";
format-muted = "󰝟";
format-icons.default = [ "󰕿 " "󰖀 " "󰕾 " ];
on-click = "wpctl set-mute @DEFAULT_AUDIO_SINK@ toggle";
# Right-click → the full mixer (per-app volumes) in a floating window
# (item 35). pwvucontrol ships in the template suite; the Hyprland
# windowrule floats it. Keep in sync with the whole-swap jsoncs.
on-click-right = "pwvucontrol";
};
# 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.
battery = {
states = { warning = 25; critical = 10; };
# Same double-pad as pulseaudio (single space still tight on some glyphs).
format = "<span size='${toString (t.fonts.size + 2)}pt'>{icon}</span>{capacity}%";
format-charging = "<span size='${toString (t.fonts.size + 2)}pt'>󰂄 </span>{capacity}%";
format-icons = [ "󰁺 " "󰁼 " "󰁾 " "󰂀 " "󰁹 " ];
# The kernel's "Not charging" maps to waybar's Plugged state — on a
# machine with a charge cap that's the hold band doing its job
# (power.nix sets start = cap 10), which reads as "broken charger"
# without an explanation. The cap value isn't baked here: the menu
# can change it live (sysfs + state, no rebuild), so a number would
# go stale.
tooltip-format-plugged = "Plugged in, not charging held at {capacity}% by the battery charge cap\nCharging resumes ~10% below the cap · click to adjust";
# Click either the battery or the power-profile icon → the combined
# power menu (profile + charge cap). The granular System rows stay.
on-click = "nomarchy-menu powermgmt";
};
"custom/powerprofile" = {
exec = "nomarchy-powerprofile-status";
return-type = "json";
interval = 5;
# Opens the same combined power menu as the battery icon (item 36b).
# nomarchy-powerprofile-cycle stays a standalone bin for downstream
# rebinding — it's just no longer the default click.
on-click = "nomarchy-menu powermgmt";
};
# 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";
format = "{}";
interval = 300;
# signal 10: poke after a fix (or from theme-shot) so the tripwire
# doesn't wait a full interval after a first-poll miss.
signal = 10;
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.
# Every *suppressed* state — DND or app-inhibited — maps to the bell-off
# glyph 󰂛 and the muted color below, so "notifications are off right now"
# reads by SHAPE, not color alone (color-blind sweep, item 28): inhibited
# used to reuse the normal 󰂜/󰂚 bells, distinguishable only by color.
"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}
/* Scoped to the bar window: Waybar's stylesheet applies process-wide,
so a bare `*` also hits the SNI tray menus Waybar hosts (nm-applet
& co) `min-height: 0` there collapses the submenu arrow node to
invisible. Whole-swap theme waybar.css files scope the same way. */
window#waybar,
window#waybar * {
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;
}
#custom-nomarchy {
color: @accent;
font-family: Nomarchy;
font-size: ${toString (t.fonts.size + 4)}pt;
padding: 0 10px;
}
#custom-nomarchy:hover { color: @accentAlt; }
#tray, #pulseaudio, #custom-powerprofile, #custom-nightlight, #custom-updates, #custom-vpn, #custom-recording, #idle_inhibitor, #language, #battery, #custom-doctor, #custom-notification, #custom-powermenu {
color: alpha(@text, 0.85);
padding: 0 10px;
}
#custom-powermenu:hover { color: @bad; }
/* 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; }
/* Icon-only status modules carry no text, so they don't get the
(size+2)pt Pango icon span the icon+text modules use bump their
font-size to match, or these glyphs read smaller than the volume /
battery / language icons beside them. */
#custom-recording, #custom-updates, #custom-vpn, #custom-nightlight, #custom-doctor, #custom-notification, #custom-powermenu { font-size: ${toString (t.fonts.size + 2)}pt; }
/* The speedometer + caffeine glyphs render small in their em box
size them up a touch more so they read at a glance. */
#custom-powerprofile, #idle_inhibitor { font-size: ${toString (t.fonts.size + 3)}pt; }
/* notifications waiting accent; suppressed (DND or app-inhibited)
muted bell-off, so it reads by shape+color, never color alone. */
#custom-notification.notification { color: @accent; }
#custom-notification.dnd-none,
#custom-notification.dnd-notification,
#custom-notification.inhibited-none,
#custom-notification.inhibited-notification { color: @muted; }
#pulseaudio.muted { color: @muted; }
#battery.warning { color: @warn; }
#battery.critical { color: @bad; }
#battery.charging { color: @good; }
/* Clock (and other) hover tooltips explicit colors so an empty
looking box is never just text same as background. */
tooltip {
background: @surface;
border: ${toString t.ui.borderSize}px solid alpha(@accent, 0.4);
border-radius: ${toString t.ui.rounding}px;
/* Tooltips are separate toplevels, outside the window#waybar scope
restate the bar font (calendar columns need consistent metrics). */
font-family: "${t.fonts.ui}", "${t.fonts.mono}";
font-size: ${toString t.fonts.size}pt;
}
tooltip label { color: @text; }
'';
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
);
};
# Bar helpers on PATH (whole-swap jsoncs exec them by bare name) +
# feature deps of bar clicks: calcurse (clock), pwvucontrol (volume
# right-click) — not opt-in template packages.
home.packages = lib.optionals config.nomarchy.waybar.enable
[ waybarSupervisor powerProfileStatus powerProfileCycle vpnStatus doctorStatus calendarLauncher
pkgs.calcurse
pkgs.pwvucontrol
];
}