# rofi (2.0, native Wayland on 26.05) — launcher + dmenu renderer for # the menu system, themed from theme-state.json. rofi's .rasi is far more # expressive than a flat scheme, so the generated theme styles each # element (accent border, highlighted selection, rounded inputbar); a # themes//rofi.rasi whole-swap replaces it entirely. # # Home of nomarchy-menu, the dispatcher all menu modules hang off. { config, lib, pkgs, ... }: let cfg = config.nomarchy; t = cfg.theme; c = t.colors; inherit (config.lib.formats.rasi) mkLiteral; px = n: mkLiteral "${toString n}px"; # Per-theme override probe (same convention as waybar.css). rasiOverride = cfg.themesDir + "/${t.slug}/rofi.rasi"; hasRasiOverride = builtins.pathExists rasiOverride; # ── Visual theme picker ────────────────────────────────────────────── # Every preset (themes/.json) is read at eval time to build a grid # of real desktop previews. Grouped dark-first then light — the previews # make the mode obvious at a glance, so no light/dark submenu split — and # the active theme is marked ✓. "Active" is just t.slug: every switch # rebuilds this script, so the baked-in mark always tracks the live theme. themeFiles = lib.filterAttrs (n: ty: ty == "regular" && lib.hasSuffix ".json" n) (builtins.readDir cfg.themesDir); themeData = lib.mapAttrsToList (fname: _: let j = builtins.fromJSON (builtins.readFile (cfg.themesDir + "/${fname}")); slug = j.slug or (lib.removeSuffix ".json" fname); in { inherit slug; name = j.name or slug; mode = j.mode or "dark"; hasPreview = builtins.pathExists (cfg.themesDir + "/${slug}/preview.png"); }) themeFiles; byName = lib.sort (a: b: a.name < b.name); orderedThemes = byName (lib.filter (th: th.mode != "light") themeData) # dark group ++ byName (lib.filter (th: th.mode == "light") themeData); # then light # rofi's element-icon cells are SQUARE, so a 16:9 preview would letterbox # (theme-coloured bands top/bottom). Build-time crop each committed 480×270 # preview to a centred square so it fills the cell edge-to-edge. The source # preview.png stays 480×270 (untouched) — only the displayed thumb is square. themeThumbs = pkgs.runCommand "nomarchy-theme-thumbs" { nativeBuildInputs = [ pkgs.imagemagick ]; } '' mkdir -p $out ${lib.concatMapStringsSep "\n" (th: lib.optionalString th.hasPreview '' magick ${cfg.themesDir + "/${th.slug}/preview.png"} \ -resize 360x360^ -gravity center -extent 360x360 -strip $out/${th.slug}.png '') orderedThemes} ''; # One grid row per theme (square thumb + name, ✓ on the active one), and a # Name→slug map so the picked label resolves back to the preset that # nomarchy-theme-sync applies (pretty names ≠ slugs, hence the map). Themes # with no preview degrade to a plain-name row. themeRows = lib.concatMapStringsSep "\n" (th: let label = th.name + lib.optionalString (th.slug == t.slug) " ✓"; in if th.hasPreview then "row ${lib.escapeShellArg label} ${themeThumbs}/${th.slug}.png" else "printf '%s\\n' ${lib.escapeShellArg label}") orderedThemes; themeSlugMap = lib.concatMapStringsSep "\n" (th: " [${lib.escapeShellArg th.name}]=${lib.escapeShellArg th.slug}") orderedThemes; # Per-invocation grid layout (cards: a 16:9 preview above a centered name), # overriding the single-column list theme just for this menu. # · The icon box is sized 16:9 (rofi 2.0 takes a two-value `size`) to # match the 480×270 previews exactly — they fill it with no letterbox. # · The window is sized to the *content* (3 × icon + paddings), not a # percentage, so a column is no wider than its card — otherwise the # selection highlight balloons out around the image. # · flow: horizontal lays cards out row-by-row (rofi's default Vertical # fills column-by-column, so Down at a column's foot jumped to the top # of the next column instead of scrolling the page). # Grid dial — the previews are as big as the icon px allows; screen *height* # caps it, so showing 2 rows (not 3) lets each card grow a lot. Scrolling # reaches the rest. Tune themeGridIconW for size; the window resizes to fit. # rofi's element-icon `size` is a SINGLE value (the manual only documents # one) — a two-value "WxH" is silently collapsed, which is why the preview # used to render tiny inside a too-wide column. So: size = the card *width*, # rofi fits the 16:9 image tight to it (no square letterbox), and the window # is sized so a column is exactly that width — the preview fills the cell. themeGridCols = 3; themeGridLines = 3; themeGridIconW = 240; # square preview card side (px) — the size knob themeGridPad = 0; # margin around the preview inside its cell (px) themeGridGap = 8; # gap between cards (px) themeGridThemeStr = lib.escapeShellArg (lib.concatStringsSep " " [ # window width = cards + their padding + inter-card gaps + chrome (~20px) "window { width: ${toString (themeGridCols * (themeGridIconW + 2 * themeGridPad) + (themeGridCols - 1) * themeGridGap + 20)}px; }" "listview { columns: ${toString themeGridCols}; lines: ${toString themeGridLines}; spacing: ${toString themeGridGap}px; flow: horizontal; }" "element { orientation: vertical; padding: ${toString themeGridPad}px; spacing: 2px; }" "element-icon { size: ${toString themeGridIconW}px; }" "element-text { horizontal-align: 0.5; }" ]); # Keybindings cheatsheet (SUPER+? → the `keybinds` menu module). Built # from the SAME ./keybinds.nix that hyprland.nix binds, so it can never # drift from the live shortcuts. "$mod" reads as SUPER; rows are padded # into two columns. extra[] carries the generated/mouse binds. keybinds = import ./keybinds.nix; padRight = w: s: let n = w - builtins.stringLength s; in s + (if n > 0 then lib.concatStrings (builtins.genList (_: " ") n) else " "); prettyKeys = b: let m = lib.replaceStrings [ "$mod" ] [ "SUPER" ] b.mods; prefix = if m == "" then "" else lib.concatStringsSep " + " (lib.splitString " " m) + " + "; key = lib.replaceStrings [ "question" "slash" ] [ "?" "/" ] b.key; in prefix + key; cheatRows = map (b: padRight 22 (prettyKeys b) + b.desc) keybinds.binds ++ map (e: padRight 22 e.keys + e.desc) keybinds.extra; cheatsheetFile = pkgs.writeText "nomarchy-keybinds.txt" (lib.concatStringsSep "\n" cheatRows + "\n"); nomarchy-menu = pkgs.writeShellScriptBin "nomarchy-menu" '' # Nomarchy menu dispatcher — thin presentation layer over # `rofi -dmenu`; actions delegate to nomarchy-theme-sync, systemctl, # hyprctl and friends. Menu entries carry real icons from the theme's # icon set (Papirus) via rofi's per-row icon protocol — see row() below. # `nomarchy-menu` with no argument shows the module picker. urlencode() { local s="$1" out="" ch i for ((i = 0; i < ''${#s}; i++)); do ch=''${s:i:1} case "$ch" in [a-zA-Z0-9.~_-]) out+="$ch" ;; ' ') out+="+" ;; *) printf -v ch '%%%02X' "'$ch"; out+="$ch" ;; esac done printf '%s' "$out" } # Emit one dmenu row with a themed icon: `row "Label" icon-name`. The NUL # + 0x1f field separator is rofi's per-row icon protocol; printed straight # to the pipe because bash can't store NUL in a variable. Rendered when # rofi has show-icons (on globally; passed explicitly on the icon menus). row() { printf '%s\0icon\x1f%s\n' "$1" "$2"; } # General menu convention: every list menu ends with a "↩ Back" entry that # returns one level up (so you never have to Esc out and reopen). `back` # emits it with an icon; plain pick-lists append the bare label instead. # Always matched EXACTLY ("↩ Back") so it can't collide with clipboard or # filename content. The arrow + go-previous icon read as "go back". BACK="↩ Back" back() { row "$BACK" go-previous; } case "''${1:-}" in power) choice=$( { row "Lock" system-lock-screen row "Logout" system-log-out row "Suspend" system-suspend row "Hibernate" system-hibernate row "Reboot" system-reboot row "Shutdown" system-shutdown back } | rofi -dmenu -show-icons -p Power) || exit 0 case "$choice" in "$BACK") exec "$0" ;; *Lock) loginctl lock-session ;; *Logout) hyprctl dispatch exit ;; *Suspend) systemctl suspend ;; *Hibernate) systemctl hibernate ;; *Reboot) systemctl reboot ;; *Shutdown) systemctl poweroff ;; esac ;; power-profile) # power-profiles-daemon profile switcher (the system side ships it # by default on laptops — nomarchy.system.power). Switching goes # through polkit, so no root prompt. if ! command -v powerprofilesctl >/dev/null 2>&1; then notify-send "Power profiles" "power-profiles-daemon isn't running on this machine." exit 0 fi cur=$(powerprofilesctl get 2>/dev/null) choice=$( { powerprofilesctl list 2>/dev/null | sed -nE 's/^[* ] ([a-z-]+):$/\1/p'; printf '%s\n' "$BACK"; } \ | rofi -dmenu -p "profile (now: $cur)") || exit 0 [ "$choice" = "$BACK" ] && exec "$0" system [ -n "$choice" ] && powerprofilesctl set "$choice" ;; theme) # Visual picker: a grid of real desktop previews (rows + Name→slug # map generated from the presets at build time). Pick a card → apply # that slug. The grid layout is a per-invocation -theme-str override. declare -A THEME_SLUG=( ${themeSlugMap} ) choice=$( { ${themeRows} back } | rofi -dmenu -show-icons -p Theme -theme-str ${themeGridThemeStr}) || exit 0 [ "$choice" = "$BACK" ] && exec "$0" choice="''${choice% ✓}" # drop the active marker if present slug="''${THEME_SLUG[$choice]:-}" [ -n "$slug" ] && exec nomarchy-theme-sync apply "$slug" ;; clipboard) sel=$( { cliphist list; printf '%s\n' "$BACK"; } | rofi -dmenu -p clip) || exit 0 [ "$sel" = "$BACK" ] && exec "$0" tools printf '%s' "$sel" | cliphist decode | wl-copy ;; calc) # Live calculator (rofi-calc): re-evaluates every keystroke via # libqalculate — which dodges the qalc CLI's "15% of 200" misparse — # with the answer as the top entry. Enter copies the result; the # menu stays open to chain calculations (Esc closes). exec rofi -show calc -modi calc -no-show-match -no-sort \ -calc-command "echo -n '{result}' | wl-copy" ;; files) sel=$( { fd . "$HOME" --type f --hidden --exclude .git --exclude .cache 2>/dev/null \ | head -n 50000 | sed "s|^$HOME/||"; printf '%s\n' "$BACK"; } \ | rofi -dmenu -p file) || exit 0 [ "$sel" = "$BACK" ] && exec "$0" tools [ -n "$sel" ] && exec xdg-open "$HOME/$sel" ;; emoji) # Emoji / symbol picker (rofi-emoji). The default action copies the # glyph to the clipboard through the plugin's Wayland adapter # (wl-copy); the alternate action types it into the focused window. exec rofi -show emoji -modi emoji ;; web) q=$(rofi -dmenu -p search < /dev/null) || exit 0 [ -n "$q" ] || exit 0 exec xdg-open "https://www.google.com/search?q=$(urlencode "$q")" ;; network) # Native rofi wifi/VPN picker (networkmanager_dmenu) over the system # NetworkManager — replaces the old nmtui-in-a-terminal flow. Reads # its config from xdg.configFile below (told to drive rofi). exec networkmanager_dmenu ;; vpn) # VPN setup + management (nomarchy-vpn): NM VPN/WireGuard connect + # config import + Tailscale. Distinct from `network` (the wifi picker). exec nomarchy-vpn ;; bluetooth) # blueman-manager GUI (services.blueman.enable, system-side). exec blueman-manager ;; printers) # system-config-printer GUI (the CUPS admin app), installed by # nomarchy.services.printing. Self-gated in the System menu; guard # here too in case it's invoked directly. command -v system-config-printer >/dev/null 2>&1 \ && exec system-config-printer notify-send "Printers" "Not available (nomarchy.services.printing off?)."; exit 0 ;; audio) # PipeWire (pulse) sink/source switcher via rofi-pulse-select. Self- # gated in the System menu on the pulse socket; guarded here too. if [ ! -e "''${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/pulse/native" ]; then notify-send "Audio" "No PipeWire/PulseAudio socket (nomarchy.audio off?)."; exit 0 fi choice=$( { row "Output device" audio-volume-high row "Input device" audio-input-microphone back } | rofi -dmenu -show-icons -p Audio) || exit 0 case "$choice" in "$BACK") exec "$0" system ;; *Output*) exec rofi-pulse-select sink ;; *Input*) exec rofi-pulse-select source ;; esac ;; display) # Per-output resolution picker. Applies the chosen mode INSTANTLY via # `hyprctl keyword monitor` — keeping the output's current position and # scale, so only the resolution changes — and PERSISTS it to the in-flake # state (settings.monitors., --no-switch = no rebuild), so the # choice survives logout/reboot. A later rebuild bakes the same # resolution into the generated monitor rule (hyprland.nix overlays # settings.monitors onto nomarchy.monitors by name). Self-gated to the # Hyprland session; guard here too in case it's invoked directly. command -v hyprctl >/dev/null 2>&1 \ || { notify-send "Display" "Hyprland is not running."; exit 0; } mons=$(hyprctl monitors -j) # Choose the output (skip the chooser when only one is connected). if [ "$(printf '%s' "$mons" | jq -r '.[].name' | grep -c .)" -gt 1 ]; then name=$( { printf '%s' "$mons" | jq -r '.[] | "\(.name) · \(.width)x\(.height)@\(.refreshRate|round)Hz"' printf '%s\n' "$BACK" } | rofi -dmenu -p Display ) || exit 0 [ "$name" = "$BACK" ] && exec "$0" system name=''${name%% ·*} # strip the " · WxH@R" hint else name=$(printf '%s' "$mons" | jq -r '.[0].name') fi [ -n "$name" ] || exit 0 # Pick a mode: a few Hyprland resolution tokens, then the output's # advertised modes (rounded refresh rate, deduped, highest first). The # prompt shows the current mode. cur=$(printf '%s' "$mons" | jq -r --arg n "$name" \ '.[]|select(.name==$n)|"\(.width)x\(.height)@\(.refreshRate|round)"') mode=$( { printf 'preferred\nhighres\nhighrr\n' printf '%s' "$mons" | jq -r --arg n "$name" '.[]|select(.name==$n)|.availableModes[]' \ | sed 's/Hz$//' | awk -F@ '{ printf "%s@%d\n", $1, $2 + 0.5 }' | sort -rV -u printf '%s\n' "$BACK" } | rofi -dmenu -p "$name (now $cur)" ) || exit 0 [ "$mode" = "$BACK" ] && exec "$0" display [ -n "$mode" ] || exit 0 # Apply live (keep the output's current position + scale), then persist. pos=$(printf '%s' "$mons" | jq -r --arg n "$name" '.[]|select(.name==$n)|"\(.x)x\(.y)"') scale=$(printf '%s' "$mons" | jq -r --arg n "$name" '.[]|select(.name==$n)|.scale') if hyprctl keyword monitor "$name,$mode,$pos,$scale" >/dev/null 2>&1; then nomarchy-theme-sync --quiet set "settings.monitors.$name" "$mode" --no-switch notify-send "Display" "$name → $mode" else notify-send "Display" "Could not set $name to $mode." fi ;; capture) choice=$( { row "Region → clipboard" applets-screenshooter row "Region → file" applets-screenshooter row "Full screen → clipboard" camera-photo row "Full screen → file" camera-photo back } | rofi -dmenu -show-icons -p Capture) || exit 0 dir="$HOME/Pictures/Screenshots" file="$dir/$(date +%Y%m%d-%H%M%S).png" case "$choice" in "$BACK") exec "$0" tools ;; *"Region → clipboard"*) grim -g "$(slurp)" - | wl-copy ;; *"Region → file"*) mkdir -p "$dir"; grim -g "$(slurp)" "$file" \ && notify-send "Screenshot saved" "$file" ;; *"Full screen → clipboard"*) grim - | wl-copy ;; *"Full screen → file"*) mkdir -p "$dir"; grim "$file" \ && notify-send "Screenshot saved" "$file" ;; esac ;; keybinds) # Read-only cheatsheet, generated from keybinds.nix at build time. rofi -dmenu -i -p keys < ${cheatsheetFile} >/dev/null || exit 0 ;; ask) # Free-text question → claude CLI in a terminal (OAuth, no API key). # Pulled fresh from npm via npx rather than nixpkgs' claude-code, # which lags behind model releases; npx caches after first run. The # REPL stays open for follow-ups. q=$(rofi -dmenu -p claude < /dev/null) || exit 0 [ -n "$q" ] || exit 0 exec ${cfg.terminal} -e npx --yes @anthropic-ai/claude-code@latest "$q" ;; dnd) # Toggle swaync Do-Not-Disturb (-d prints the new state). With DND # on, notifications are suppressed — the Waybar bell-off icon is the # cue — so only confirm with a toast when turning it back off. [ "$(swaync-client -d)" = "false" ] \ && notify-send "Do Not Disturb off" "Notifications resumed." exit 0 ;; nightlight) # Force the scheduled blue-light filter on/off for the session by # starting/stopping hyprsunset (nomarchy-nightlight, from nightlight.nix). # Self-gated in the menu; guard here too in case it's invoked directly. command -v nomarchy-nightlight >/dev/null 2>&1 \ && exec nomarchy-nightlight toggle notify-send "Night light" "Not available (nomarchy.nightlight off?)."; exit 0 ;; autotimezone) # Toggle automatic timezone detection (geoclue + automatic-timezoned). # A SYSTEM service, so flipping it writes the in-flake flag and runs a # system rebuild (sudo) + a home switch — in a terminal, like Snapshots. command -v nomarchy-autotimezone >/dev/null 2>&1 \ || { notify-send "Auto timezone" "Unavailable on this machine."; exit 0; } exec ${cfg.terminal} -e nomarchy-autotimezone toggle ;; autocommit) # Toggle opt-in auto-commit: every menu/theme mutation also commits # theme-state.json (that file only) in the downstream flake, so # settings history is `git log`. Nothing in Nix consumes the flag — # the tool reads the live state on each write — so the toggle is # instant, no rebuild. The off-write commits too (the tool fires # when the flag was on before OR after), keeping history consistent. if [ "$(nomarchy-theme-sync get settings.autoCommit 2>/dev/null)" = true ]; then nomarchy-theme-sync --quiet set settings.autoCommit false --no-switch notify-send "Auto-commit" "Off — menu changes stay uncommitted in your flake." else nomarchy-theme-sync --quiet set settings.autoCommit true --no-switch notify-send "Auto-commit" "On — each change commits theme-state.json in your flake." fi exit 0 ;; snapshot) # Snapshot browse/restore — the btrfs-assistant GUI via its pkexec # launcher. The "2.2 segfaults on 26.05" diagnosis was HALF right: # only UNPRIVILEGED runs crash (libbtrfsutil unprivileged subvolume # iteration, btrfs-progs 6.17.1; fixed upstream after) — as root it # runs fine, and the launcher runs it as root. The prompt needs the # session polkit agent (hyprpolkitagent, exec-once in hyprland.nix). # Fallback: nomarchy-snapshots, the keyboard-driven fzf browser in a # terminal (kept installed — also nice over SSH). # Self-gated to nomarchy.system.snapper, so it no-ops elsewhere. command -v btrfs-assistant-launcher >/dev/null 2>&1 \ && exec btrfs-assistant-launcher command -v nomarchy-snapshots >/dev/null 2>&1 \ || { notify-send "Snapshots" "Snapshot tools unavailable (nomarchy.system.snapper off?)."; exit 0; } exec ${cfg.terminal} -e sudo nomarchy-snapshots ;; tools) # Tools submenu — utilities you invoke. Each leaf is also reachable # directly (SUPER+CTRL+); this just keeps the root short. choice=$( { row "Calculator" accessories-calculator row "Clipboard" edit-paste row "Emoji" face-smile-big row "Files" system-file-manager row "Web search" system-search row "Capture" applets-screenshooter row "Ask Claude" internet-chat back } | rofi -dmenu -show-icons -p Tools) || exit 0 case "$choice" in "$BACK") exec "$0" ;; *Calc*) exec "$0" calc ;; *Clipboard*) exec "$0" clipboard ;; *Emoji*) exec "$0" emoji ;; *Files*) exec "$0" files ;; *Web*) exec "$0" web ;; *Capture*) exec "$0" capture ;; *Ask*) exec "$0" ask ;; esac ;; system) # System submenu — connectivity and machine state. Snapshots and the # power-profile picker self-gate (BTRFS+snapper / laptop on ppd), so # the submenu shrinks to match the hardware, like the Waybar modules. bats=(/sys/class/power_supply/BAT*) choice=$( { row "Network" network-wireless row "VPN" network-vpn row "Bluetooth" bluetooth [ -e "''${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/pulse/native" ] \ && row "Audio" audio-volume-high row "Display" video-display command -v system-config-printer >/dev/null 2>&1 && row "Printers" printer row "Do Not Disturb" notification-disabled if systemctl --user is-active --quiet hyprsunset.service 2>/dev/null then row "Night light (on)" weather-clear-night else row "Night light (off)" weather-clear-night fi if [ "$(nomarchy-theme-sync get settings.autoTimezone 2>/dev/null)" = true ] then row "Auto timezone (on)" preferences-system-time else row "Auto timezone (off)" preferences-system-time fi if [ -e "''${NOMARCHY_PATH:-$HOME/.nomarchy}/.git" ]; then if [ "$(nomarchy-theme-sync get settings.autoCommit 2>/dev/null)" = true ] then row "Auto-commit (on)" git else row "Auto-commit (off)" git fi fi command -v nomarchy-snapshots >/dev/null 2>&1 && row "Snapshots" timeshift if [ -e "''${bats[0]}" ] && command -v powerprofilesctl >/dev/null 2>&1; then row "Power profile" preferences-system-power fi back } | rofi -dmenu -show-icons -p System) || exit 0 case "$choice" in "$BACK") exec "$0" ;; *Network*) exec "$0" network ;; *VPN*) exec "$0" vpn ;; *Bluetooth*) exec "$0" bluetooth ;; *Audio*) exec "$0" audio ;; *Display*) exec "$0" display ;; *Printers*) exec "$0" printers ;; *"Do Not Disturb"*) exec "$0" dnd ;; *"Night light"*) exec "$0" nightlight ;; *"Auto timezone"*) exec "$0" autotimezone ;; *"Auto-commit"*) exec "$0" autocommit ;; *Snapshots*) exec "$0" snapshot ;; *"Power profile"*) exec "$0" power-profile ;; esac ;; "") # Root picker — grouped into category submenus so the top level stays # short and scannable. Apps/Theme/Power/Keybindings are frequent enough # to keep at the top; everything else lives under Tools or System. choice=$( { row "Apps" applications-all row "Theme" preferences-desktop-theme row "Tools" applications-utilities row "System" preferences-system row "Power" system-shutdown row "Keybindings" preferences-desktop-keyboard } | rofi -dmenu -show-icons -p Menu) || exit 0 case "$choice" in *Apps*) exec rofi -show drun ;; *Theme*) exec "$0" theme ;; *Tools*) exec "$0" tools ;; *System*) exec "$0" system ;; *Power*) exec "$0" power ;; *Keybindings*) exec "$0" keybinds ;; esac ;; *) echo "usage: nomarchy-menu [tools|system|power|power-profile|theme|clipboard|calc|files|emoji|web|network|bluetooth|audio|display|printers|capture|keybinds|ask|dnd|nightlight|autotimezone|autocommit|vpn|snapshot]" >&2 exit 64 ;; esac ''; # VPN setup + management — a dedicated System submenu (and the custom/vpn # Waybar indicator's click target). Goes past networkmanager_dmenu (which # only connects existing VPNs) to importing configs and driving Tailscale: # · NetworkManager VPN/WireGuard connections, ● active / ○ inactive, toggled # up/down (networkmanager-group users need no sudo). # · Import a WireGuard .conf or OpenVPN .ovpn (nmcli import; OpenVPN needs the # networkmanager-openvpn plugin, shipped system-side). # · Tailscale (self-gated on the CLI being present = nomarchy.services.tailscale, # which makes the login user the operator): status is read-only; up/down/ # exit-node run inline without sudo thanks to the operator grant, falling # back to a sudo terminal if it's absent. First login goes to a terminal. # Secrets stay in NetworkManager's / Tailscale's own store — no new manager. nomarchy-vpn = pkgs.writeShellScriptBin "nomarchy-vpn" '' set -u BACK="↩ Back" conn_rows() { nmcli -t -f NAME,TYPE,STATE connection show 2>/dev/null \ | awk -F: '$2=="vpn"||$2=="wireguard"{ printf "%s %s\n", ($3=="activated"?"●":"○"), $1 }' } toggle_conn() { mark=''${1%% *}; name=''${1#* } if [ "$mark" = "●" ]; then nmcli connection down "$name" >/dev/null 2>&1 \ && notify-send "VPN" "Disconnected $name" || notify-send "VPN" "Could not disconnect $name" else nmcli connection up "$name" >/dev/null 2>&1 \ && notify-send "VPN" "Connected $name" || notify-send "VPN" "Could not connect $name (auth or bad config?)" fi } import_config() { file=$( { fd -e conf -e ovpn . "$HOME" --type f --hidden \ --exclude .git --exclude .cache 2>/dev/null \ | sed "s|^$HOME/||"; printf '%s\n' "$BACK"; } \ | rofi -dmenu -p "Import VPN config" ) || return { [ -z "$file" ] || [ "$file" = "$BACK" ]; } && return case "$file" in *.ovpn) type=openvpn ;; *) type=wireguard ;; esac if nmcli connection import type "$type" file "$HOME/$file" >/dev/null 2>&1; then notify-send "VPN" "Imported $type config: $(basename "$file")" else notify-send "VPN" "Import failed ($type plugin missing, or bad file?)" fi } ts_state() { tailscale status --json 2>/dev/null | jq -r '.BackendState // "Unknown"'; } # Run a privileged tailscale subcommand. nomarchy.services.tailscale makes # the login user the operator, so this runs inline (no sudo); if the operator # grant is absent (downstream dropped it) it falls back to a sudo terminal. ts_priv() { if tailscale "$@" >/dev/null 2>&1; then notify-send "Tailscale" "tailscale $*" else ${cfg.terminal} -e sudo tailscale "$@"; fi } ts_exit_node() { node=$( { printf 'none\n'; tailscale exit-node list 2>/dev/null | awk 'NR>1 && $2 {print $2}'; \ printf '%s\n' "$BACK"; } | rofi -dmenu -p "Exit node" ) || return { [ -z "$node" ] || [ "$node" = "$BACK" ]; } && return [ "$node" = none ] && node="" ts_priv set --exit-node="$node" } tailscale_menu() { while :; do st=$(ts_state) choice=$( { if [ "$st" = Running ]; then printf 'Disconnect\n'; else printf 'Connect\n'; fi printf 'Exit node…\n' printf '%s\n' "$BACK" } | rofi -dmenu -p "Tailscale ($st)" ) || return case "$choice" in "$BACK"|"") return ;; # Authed-but-down → bring it up inline; otherwise it needs an # interactive login, so show it in a terminal (URL visible). Connect) if [ "$st" = Stopped ]; then ts_priv up; else ${cfg.terminal} -e sudo tailscale up; fi ;; Disconnect) ts_priv down ;; "Exit node…") ts_exit_node ;; esac done } while :; do have_ts="" command -v tailscale >/dev/null 2>&1 && have_ts=1 choice=$( { conn_rows printf 'Import config…\n' [ -n "$have_ts" ] && printf 'Tailscale ›\n' printf '%s\n' "$BACK" } | rofi -dmenu -p VPN ) || exit 0 case "$choice" in "$BACK"|"") exec nomarchy-menu system ;; "Import config…") import_config ;; "Tailscale ›") tailscale_menu ;; "● "*|"○ "*) toggle_conn "$choice" ;; esac done ''; in { config = lib.mkIf cfg.rofi.enable { home.packages = [ nomarchy-menu nomarchy-vpn # VPN submenu (NM connect/import + Tailscale) pkgs.fd # files module (fuzzy search over $HOME) pkgs.xdg-utils # xdg-open for the files + web modules pkgs.nodejs # npx, for the Ask Claude module (claude-code from npm) pkgs.networkmanager_dmenu # network module: rofi wifi/VPN picker pkgs.rofi-pulse-select # audio module: sink/source switcher ]; # networkmanager_dmenu drives rofi (not bare dmenu) and uses rofi's # active/urgent row styling for the connected/available networks, so it # inherits the generated theme like every other menu module. Editing a # connection drops to nmtui in the configured terminal. force: the tool # writes a default config on first run, so an unmanaged file may already # sit at this path — we fully own it, so overwrite rather than abort. xdg.configFile."networkmanager-dmenu/config.ini" = { force = true; text = '' [dmenu] dmenu_command = rofi rofi_highlight = True compact = False wifi_chars = ▂▄▆█ [editor] terminal = ${cfg.terminal} gui_if_available = False ''; }; programs.rofi = { enable = true; terminal = cfg.terminal; font = "${t.fonts.ui} 12"; # Native rofi modi backing two menu modules: calc (live, via # libqalculate — no qalc CLI needed) and emoji (glyph picker, copies # via its bundled Wayland adapter). Both render through the # generated/whole-swap theme like every other modi. plugins = [ pkgs.rofi-calc pkgs.rofi-emoji ]; extraConfig = { modi = "drun,run"; show-icons = true; # app icons via the theme's icon set icon-theme = t.iconTheme; # resolved in theme.nix (Papirus-*/per-theme) drun-display-format = "{name}"; display-drun = "apps"; display-run = "run"; # Size the window to the actual number of rows instead of always # reserving `lines` (8) — so a 6-entry menu doesn't leave empty space, # while the app launcher still fills + scrolls past `lines`. fixed-num-lines = false; # Search: case-insensitive fuzzy across every menu + the launcher, so # "system" finds "System" and "fzr" finds "Firefox". fzf sorting ranks # the closest match to the top (the per-keystroke modules — calc/emoji # — opt out per-invocation with -no-sort). matching = "fuzzy"; case-sensitive = false; sort = true; sorting-method = "fzf"; }; # Whole-swap themes bring their own rofi.rasi; otherwise the theme # is generated from the palette. theme = if hasRasiOverride then rasiOverride else { "*" = { bg = mkLiteral c.base; fg = mkLiteral c.text; dim = mkLiteral c.subtext; surface = mkLiteral c.surface; accent = mkLiteral c.accent; background-color = mkLiteral "transparent"; text-color = mkLiteral "@fg"; margin = 0; padding = 0; spacing = 0; }; "window" = { background-color = mkLiteral "@bg"; border = px t.ui.borderSize; border-color = mkLiteral "@accent"; border-radius = px t.ui.rounding; width = mkLiteral "40%"; padding = px 8; }; "mainbox" = { children = map mkLiteral [ "inputbar" "message" "listview" ]; }; "inputbar" = { background-color = mkLiteral "@surface"; border-radius = px t.ui.rounding; padding = mkLiteral "8px 12px"; spacing = px 8; children = map mkLiteral [ "prompt" "entry" ]; }; "prompt" = { text-color = mkLiteral "@accent"; }; "entry" = { placeholder = "Search…"; placeholder-color = mkLiteral "@dim"; }; "listview" = { lines = 8; columns = 1; dynamic = true; scrollbar = false; spacing = px 4; # a little air so the rounded zebra rows separate padding = mkLiteral "8px 0px 0px 0px"; }; # Rounded, roomy rows with subtle zebra striping: alternate rows # lift to @surface, the selected row to @accent. normal/alternate/ # selected are rofi's row parities (the `.normal` suffix is the # match-state, untouched by our menus). "element" = { padding = mkLiteral "10px 14px"; border-radius = px t.ui.rounding; spacing = px 12; # gap between the (larger) icon and the label }; "element normal.normal" = { background-color = mkLiteral "@bg"; text-color = mkLiteral "@fg"; }; "element alternate.normal" = { background-color = mkLiteral "@surface"; text-color = mkLiteral "@fg"; }; "element selected.normal" = { background-color = mkLiteral "@accent"; text-color = mkLiteral "@bg"; }; "element-icon" = { background-color = mkLiteral "transparent"; size = px t.ui.iconSize; # real icons (Papirus), not 1em font glyphs }; "element-text" = { background-color = mkLiteral "transparent"; text-color = mkLiteral "inherit"; }; "message" = { padding = mkLiteral "8px 0px 0px 0px"; }; "textbox" = { background-color = mkLiteral "@surface"; text-color = mkLiteral "@fg"; border-radius = px t.ui.rounding; padding = mkLiteral "8px 12px"; }; }; }; }; }