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>
419 lines
17 KiB
Nix
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";
|
|
};
|
|
};
|
|
};
|
|
};
|
|
}
|