# 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; # 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) choice=$( { nomarchy-theme-sync list; printf '%s\n' "$BACK"; } | rofi -dmenu -p theme) || exit 0 [ "$choice" = "$BACK" ] && exec "$0" [ -n "$choice" ] && exec nomarchy-theme-sync apply "$choice" ;; 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) # nmtui in a terminal (NetworkManager is the system network stack). exec ${cfg.terminal} -e nmtui ;; bluetooth) # blueman-manager GUI (services.blueman.enable, system-side). exec blueman-manager ;; 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 ;; snapshot) # btrfs-assistant 2.2 segfaults on 26.05 (libbtrfsutil ABI), so launch # the keyboard-driven snapper browser instead: a terminal running it # via sudo (snapper is root-only here — one password prompt), with # browse/diff, restore (undochange) and rollback, each confirmed. # Self-gated to nomarchy.system.snapper, so it no-ops elsewhere. 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 "Bluetooth" bluetooth row "Do Not Disturb" notification-disabled systemctl --user cat hyprsunset.service >/dev/null 2>&1 \ && row "Night light" weather-clear-night 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 ;; *Bluetooth*) exec "$0" bluetooth ;; *"Do Not Disturb"*) exec "$0" dnd ;; *"Night light"*) exec "$0" nightlight ;; *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|capture|keybinds|ask|dnd|nightlight|snapshot]" >&2 exit 64 ;; esac ''; in { config = lib.mkIf cfg.rofi.enable { home.packages = [ nomarchy-menu 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) ]; 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; }; # 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"; }; }; }; }; }