# 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 # //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' ''; # 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 = [ "tray" "custom/vpn" "pulseaudio" "cpu" "memory" "custom/powerprofile" "custom/nightlight" ] ++ lib.optional showLanguage "hyprland/language" ++ [ "battery" "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}"; tooltip-format = "{calendar}"; }; # 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"; }; # 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"; }; # 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/secondary shades are alpha(@text) tints, NOT the palette's subtext/muted roles — those mean "on-surface" in some palettes (flexoki-light: subtext==base, gruvbox: muted≈base) and vanish against the bar (item 27; tools/check-theme-contrast.py). */ #workspaces button { padding: 0 8px; color: alpha(@text, 0.5); 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, #language, #battery, #custom-notification { color: alpha(@text, 0.85); padding: 0 10px; } /* 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; } /* 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: alpha(@text, 0.5); } #pulseaudio.muted { color: alpha(@text, 0.5); } #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//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 ]; }