Files
Nomarchy/modules/home/rofi.nix
Bernardo Magri 0e42763aea feat(menu): Back on every submenu + working snapshot fallback
Two menu improvements.

Back everywhere: every list menu now ends with a "↩ Back" entry that
returns one level up — power, theme, power-profile, clipboard, files,
capture, not just Tools/System — so you no longer have to Esc out and
reopen to back up. A `back` helper + a shared `BACK` label keep it uniform;
matched exactly so it can't collide with clipboard/filename content. Esc
still quits instantly.

Snapshot fallback: btrfs-assistant 2.2 segfaults on 26.05 (libbtrfsutil
ABI), so `nomarchy-menu snapshot` now launches nomarchy-snapshots instead —
a keyboard-driven snapper browser/restore. snapper is root-only here and
rofi can't run as root under Wayland, so it runs in a terminal via sudo
(one password prompt) and uses fzf to pick: browse/diff (read-only),
restore files (undochange), or roll back (root config only), each behind a
typed-`yes` confirmation — safer than a one-click rofi rollback.
btrfs-assistant stays installed for when nixpkgs fixes it.

Verified: flake check clean; home generation builds and the menu passes
bash -n with all Back + snapshot wiring; nomarchy-snapshots passes
shellcheck (writeShellApplication) in a snapper-enabled system build.
Pending an on-machine check of the restore/rollback paths.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 19:54:42 +01:00

419 lines
17 KiB
Nix

# 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/<slug>/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+<mnemonic>); 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";
};
};
};
};
}