# 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; # Whether the keyboard-layout indicator is worth bar space, and the filter # that holds a whole-swap to the same answer. Contract + rationale (and # checks.waybar-language) live in ./waybar-language.nix. langLib = import ./waybar-language.nix { inherit lib; }; showLanguage = langLib.show { layout = config.nomarchy.keyboard.layout; devices = config.nomarchy.keyboard.devices; remembered = config.nomarchy.settings.keyboard.devices or { }; }; gateLanguage = langLib.gate showLanguage; # 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 = "󰌌 {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 = "{icon}{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 = "{icon}{capacity}%"; format-charging = "󰂄 {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} /* NB: this `*` reset reaches the SNI tray menus Waybar hosts too (its stylesheet applies process-wide, at a priority user gtk.css cannot out-rank). The menu block right below undoes the damage — keep the two in sync. Do NOT scope `*` to window#waybar instead: the id's specificity would beat the class rules below and wreck the bar. Whole-swap theme waybar.css files carry the same pair. */ * { font-family: "${t.fonts.ui}", "${t.fonts.mono}"; font-size: ${toString t.fonts.size}pt; min-height: 0; } /* Tray menus: undo the `*` reset — same stylesheet, higher specificity. Arrows/checks/separators are CSS-sized nodes; min-height 0 makes them invisible. */ menu menuitem arrow { min-width: 16px; min-height: 16px; } menu check, menu radio { min-width: 14px; min-height: 14px; } menu separator { min-height: 1px; margin: 5px 0; background: alpha(@text, 0.15); } 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 muted≈base, 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; } 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//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 gateLanguage (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 ]; }