Files
Nomarchy/modules/home/rofi.nix
Bernardo Magri b5263bcef8
Some checks failed
Check / eval (push) Has been cancelled
fix(desktop): tighter bar icon pad + case-insensitive rofi match
- Double-space pad on volume (esp. high), battery, keyboard layout glyphs
- rofi: matching=normal,fuzzy; case-smart off (ste finds Steam)
- Queue: unfree+calendar PASS; retest spacing + rofi search

V0: nix flake check --no-build green. V3: bar/rofi on session.
2026-07-10 13:06:47 +01:00

1288 lines
63 KiB
Nix
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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;
# App-launcher theme (SUPER+Space/D → `rofi -show drun -theme launcher`).
# Kept separate from the base theme so a theme can give the launcher its
# own layout (e.g. Boreal's icon grid) WITHOUT that layout leaking into
# the text menus, which share the base theme and need a list. The default
# just re-imports the active base theme (custom.rasi for generated themes,
# rofi.rasi for whole-swaps — see the dataFile below), so every other
# theme's launcher is byte-for-byte its list.
launcherOverride = cfg.themesDir + "/${t.slug}/launcher.rasi";
hasLauncherOverride = builtins.pathExists launcherOverride;
# ── Visual theme picker ──────────────────────────────────────────────
# Every preset (themes/<slug>.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" "left" "right" "up" "down" ]
[ "?" "/" "" "" "" "" ] b.key;
# The bind is `SHIFT + slash` (base keysym; keybinds.nix), which
# renders "SHIFT + /". `?` IS Shift+/, so collapse that back to the
# single documented glyph — the cheatsheet reads "SUPER + ?".
in lib.replaceStrings [ "SHIFT + /" ] [ "?" ] (prefix + key);
cheatRows =
map (b: padRight 22 (prettyKeys b) + b.desc) (keybinds.binds
++ lib.optionals (lib.hasInfix "," cfg.keyboard.layout)
keybinds.multiLayoutBinds)
# Launch-or-focus binds (nomarchy.launchOrFocus, generated in
# hyprland.nix) — same renderer, so they land in the cheatsheet too.
++ map (e: padRight 22 (prettyKeys e)
+ (if e.desc != "" then e.desc
else "Focus or launch ${if e.command == "" then lib.toLower e.class else e.command}"))
cfg.launchOrFocus
++ map (e: padRight 22 e.keys + e.desc) keybinds.extra;
cheatsheetFile = pkgs.writeText "nomarchy-keybinds.txt"
(lib.concatStringsSep "\n" cheatRows + "\n");
# Dimmed keybind hints on menu rows (item 28c) — the same keybinds.nix
# + prettyKeys the cheatsheet uses, so they can never drift from the
# live binds. Pango spans; the menus that show them pass -markup-rows.
# Empty (no hint) when nothing binds the action.
hintForAction = action:
let m = lib.filter (b: b.action == action) keybinds.binds;
in lib.optionalString (m != [ ])
" <span size='small' alpha='55%'>${prettyKeys (lib.head m)}</span>";
menuHint = module: hintForAction "exec, nomarchy-menu ${module}";
# Apps opens rofi drun directly, not a nomarchy-menu module.
appsHint = " <span size='small' alpha='55%'>SUPER + D</span>";
# A menu row that runs the same op as a global bind whose action isn't a
# `nomarchy-menu` module (the Capture rows ↔ the Print screenshot keys):
# match on mods+key so the hint still reads from keybinds.nix (single
# source, no drift), rather than a brittle full-action-string compare.
hintForBind = mods: key:
let m = lib.filter (b: b.mods == mods && b.key == key) keybinds.binds;
in lib.optionalString (m != [ ])
" <span size='small' alpha='55%'>${prettyKeys (lib.head m)}</span>";
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.
# System batteries: type=Battery, not scope=Device same filter as
# nomarchy-battery-notify. Name-agnostic (BAT0, CMB0, test_battery)
# so charge-limit / power-profile gates match notify (BACKLOG #60).
has_system_battery() {
local d
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
return 0
done
return 1
}
has_charge_threshold() {
local d
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
[ -e "$d/charge_control_end_threshold" ] && return 0
done
return 1
}
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"; }
# Back-audit exceptions (item 24): external modi/tools (calc, emoji,
# networkmanager_dmenu, rofi-pulse-select) can't take an injected row
# Esc is their back path; free-text prompts (web, ask) have no list to
# append to Esc cancels. Everything hand-rolled gets the row below.
# 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). The ``
# glyph in the label IS the arrow `back` must not also attach a
# go-previous icon, or icon menus render two arrows (item 34). So `back`
# is now just the plain-label emit, identical to what pick-lists append
# one arrow everywhere. Always matched EXACTLY (" Back") so it can't
# collide with clipboard or filename content.
BACK=" Back"
back() { printf '%s\n' "$BACK"; }
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)
# Each profile gets its themed icon from Papirus' colored
# battery-profile-* family (the -symbolic variants are #444 grey and
# vanish on dark themes). The selected row's returned text is still
# the bare profile name, so `powerprofilesctl set` is unaffected.
choice=$( {
powerprofilesctl list 2>/dev/null | sed -nE 's/^[* ] ([a-z-]+):$/\1/p' \
| while IFS= read -r p; do
case "$p" in
performance) row "$p" battery-profile-performance ;;
balanced) row "$p" battery-profile-balanced ;;
power-saver) row "$p" battery-profile-powersave ;;
*) row "$p" preferences-system-power ;;
esac
done
back
} | rofi -dmenu -show-icons -p "profile (now: $cur)") || exit 0
[ "$choice" = "$BACK" ] && exec "$0" system
[ -n "$choice" ] && powerprofilesctl set "$choice" ;;
powermgmt)
# Combined power menu (item 36b) power PROFILE + battery CHARGE cap
# in one flat list, opened from BOTH the Waybar battery and
# power-profile icons (the two granular System Power profile /
# Battery limit rows stay for direct access). No new backend: profile
# rows drive powerprofilesctl like the power-profile picker, charge
# rows drive the same theme-sync writer as batterylimit. Each half
# self-gates on its hardware, so a desktop with neither just toasts.
haveppd=false; command -v powerprofilesctl >/dev/null 2>&1 && haveppd=true
havecharge=false; has_charge_threshold && havecharge=true
if ! $haveppd && ! $havecharge; then
notify-send "Power" "No power-profile or charge-limit control on this machine."
exit 0
fi
curp=""; $haveppd && curp=$(powerprofilesctl get 2>/dev/null)
curc=$(nomarchy-theme-sync get settings.power.batteryChargeLimit 2>/dev/null)
choice=$( {
if $haveppd; then
powerprofilesctl list 2>/dev/null | sed -nE 's/^[* ] ([a-z-]+):$/\1/p' \
| while IFS= read -r p; do
case "$p" in
performance) row "Profile: $p" battery-profile-performance ;;
balanced) row "Profile: $p" battery-profile-balanced ;;
power-saver) row "Profile: $p" battery-profile-powersave ;;
*) row "Profile: $p" preferences-system-power ;;
esac
done
fi
if $havecharge; then
row "Charge limit: 80% (recommended)" battery-080
row "Charge limit: 90%" battery-090
row "Charge limit: 60%" battery-060
row "Charge limit: Off (100%)" battery-100
fi
back
} | rofi -dmenu -show-icons -p "Power profile: ''${curp:-n/a}, limit: ''${curc:-default}") || exit 0
# Persist in-flake + apply live to sysfs (udev makes the node
# group-writable for users Bernardo 2026-07-10).
set_limit() {
nomarchy-theme-sync --quiet set settings.power.batteryChargeLimit "$1" --no-switch
n="$1"; [ "$n" = null ] && n=100
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
thresh="$d/charge_control_end_threshold"
[ -w "$thresh" ] && echo "$n" > "$thresh" 2>/dev/null || true
done
notify-send "Battery limit" "$2 applied now."
}
case "$choice" in
"$BACK") exec "$0" ;;
"Profile: "*) powerprofilesctl set "''${choice#Profile: }" ;;
"Charge limit: 80%"*) set_limit 80 "Set to 80%" ;;
"Charge limit: 90%"*) set_limit 90 "Set to 90%" ;;
"Charge limit: 60%"*) set_limit 60 "Set to 60%" ;;
"Charge limit: Off"*) set_limit null "Off charges to 100%" ;;
esac ;;
theme)
# Visual picker: a grid of real desktop previews (rows + Nameslug
# 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).
# External tool: no injected Back row possible Esc closes.
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).
# Self-gated in the System menu on the CLI; guard here too in case
# it's invoked directly with nomarchy.system.bluetooth.enable=false.
command -v blueman-manager >/dev/null 2>&1 \
|| { notify-send "Bluetooth" "Not available (nomarchy.system.bluetooth off?)."; exit 0; }
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.<name>, --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 and
# there are no profiles the Profiles row needs the chooser to live in).
# Where the mode picker's Back returns to: the output chooser when it
# was shown (a real previous level), else straight to System on a
# single-monitor laptop the chooser is skipped, so returning to
# `display` would just re-open this same mode picker (item 40).
modeBack=system
profs=; command -v nomarchy-display-profile >/dev/null 2>&1 && profs=1
if [ -n "$profs" ] || [ "$(printf '%s' "$mons" | jq -r '.[].name' | grep -c .)" -gt 1 ]; then
name=$( {
[ -n "$profs" ] && printf 'Profiles \n'
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" = "Profiles " ] && exec "$0" display-profile
name=''${name%% ·*} # strip the " · WxH@R" hint
modeBack=display
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" "$modeBack"
[ -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)
# Screenshots + screen recording. Recording rows self-swap: while
# one runs, the only offer is Stop (same surface as the bar's ).
choice=$( {
row "Region clipboard${hintForBind "" "Print"}" applets-screenshooter
row "Region file${hintForBind "SHIFT" "Print"}" applets-screenshooter
command -v satty >/dev/null 2>&1 && row "Annotate region${hintForBind "$mod SHIFT" "Print"}" edit-image
row "Full screen clipboard" camera-photo
row "Full screen file${hintForBind "CTRL" "Print"}" camera-photo
command -v tesseract >/dev/null 2>&1 && row "OCR region clipboard" scanner
if command -v nomarchy-record >/dev/null 2>&1; then
if nomarchy-record active; then
row " Stop recording" media-playback-stop
else
row "Record region" media-record
row "Record screen" media-record
row "Record region + audio" audio-input-microphone
row "Record screen + audio" audio-input-microphone
fi
fi
back
} | rofi -dmenu -show-icons -markup-rows -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" ;;
*"Annotate region"*) mkdir -p "$dir"; grim -g "$(slurp)" - | satty --filename - --fullscreen --output-filename "$file" ;;
*"OCR region"*)
# Select screenshot tesseract clipboard. Esc in slurp
# cancels silently (the recording.nix pattern); recognizing
# nothing toasts instead of copying an empty clipboard.
geo=$(slurp) || exit 0
txt=$(grim -g "$geo" - | tesseract stdin stdout 2>/dev/null)
[ -n "''${txt//[[:space:]]/}" ] \
|| { notify-send "OCR" "No text recognized in that region."; exit 0; }
printf '%s' "$txt" | wl-copy
notify-send "OCR" "Copied $(printf '%s' "$txt" | wc -w) word(s) to the clipboard." ;;
*"Full screen clipboard"*) grim - | wl-copy ;;
*"Full screen file"*) mkdir -p "$dir"; grim "$file" \
&& notify-send "Screenshot saved" "$file" ;;
*"Stop recording"*) exec nomarchy-record stop ;;
*"Record region + audio"*) exec nomarchy-record start region audio ;;
*"Record screen + audio"*) exec nomarchy-record start screen audio ;;
*"Record region"*) exec nomarchy-record start region ;;
*"Record screen"*) exec nomarchy-record start screen ;;
esac ;;
lookfeel)
# Look & Feel appearance: Theme, wallpapers, night light.
# Root stays six entries (Apps / Look & Feel / Tools / System /
# Power / Keybindings). SUPER+T / SUPER+SHIFT+T still hit leaves.
choice=$( {
row "Theme${menuHint "theme"}" preferences-desktop-theme
row "Next wallpaper${hintForAction "exec, nomarchy-theme-sync bg next"}" preferences-desktop-wallpaper
row "Reset wallpaper (auto)" preferences-desktop-wallpaper
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
back
} | rofi -dmenu -show-icons -markup-rows -p "Look & Feel") || exit 0
case "$choice" in
"$BACK") exec "$0" ;;
*Theme*) exec "$0" theme ;;
*"Next wallpaper"*) exec nomarchy-theme-sync bg next ;;
*"Reset wallpaper"*) exec nomarchy-theme-sync bg auto ;;
*"Night light"*) exec "$0" nightlight ;;
esac ;;
display-profile)
# Named multi-output layouts (nomarchy.displayProfiles). Applying is
# instant (hyprctl per entry, via nomarchy-display-profile) and
# persisted in-flake (settings.displayProfile) so the next rebuild
# bakes it. "Base layout" clears the state and hyprctl-reloads.
command -v nomarchy-display-profile >/dev/null 2>&1 \
|| { notify-send "Display profiles" "None declared (nomarchy.displayProfiles)."; exit 0; }
active=$(nomarchy-display-profile active)
choice=$( {
nomarchy-display-profile list | while IFS= read -r p; do
if [ "$p" = "$active" ]; then printf ' %s\n' "$p"; else printf ' %s\n' "$p"; fi
done
printf 'Base layout\n'
if [ "$(nomarchy-theme-sync get settings.displayProfileAuto 2>/dev/null)" = true ]
then printf 'Auto-switch (on)\n'
else printf 'Auto-switch (off)\n'
fi
printf '%s\n' "$BACK"
} | rofi -dmenu -p Profiles ) || exit 0
case "$choice" in
"$BACK") exec "$0" display ;;
"Base layout") exec nomarchy-display-profile base ;;
*"Auto-switch"*)
# Instant in-flake flag; the watcher reads it live on the next
# output change no rebuild, no unit juggling.
if [ "$(nomarchy-theme-sync get settings.displayProfileAuto 2>/dev/null)" = true ]; then
nomarchy-theme-sync --quiet set settings.displayProfileAuto false --no-switch
notify-send "Display profiles" "Auto-switch off profiles change only from this menu."
else
nomarchy-theme-sync --quiet set settings.displayProfileAuto true --no-switch
notify-send "Display profiles" "Auto-switch on plugging/unplugging outputs picks the matching profile."
fi
exec "$0" display-profile ;;
*) exec nomarchy-display-profile apply "''${choice#[] }" ;;
esac ;;
colorpicker)
# Pick a pixel's color (hyprpicker's zoom loupe) clipboard, hex
# in the confirmation toast. Esc/cancel exits silently. Self-gated
# in the Tools row; guarded here too for direct invocation.
command -v hyprpicker >/dev/null 2>&1 \
|| { notify-send "Color picker" "hyprpicker is not installed."; exit 0; }
color=$(hyprpicker) || exit 0
[ -n "$color" ] || exit 0
printf '%s' "$color" | wl-copy
notify-send "Color picker" "$color copied to the clipboard." ;;
keybinds)
# Read-only cheatsheet, generated from keybinds.nix at build time.
# Enter on a row is a deliberate no-op (nothing to launch); Back
# returns to the root picker, which is also where it's launched.
sel=$( { cat ${cheatsheetFile}; printf '%s\n' "$BACK"; } \
| rofi -dmenu -i -p keys ) || exit 0
[ "$sel" = "$BACK" ] && exec "$0"
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 ;;
doctor)
# Read-only health sheet in a terminal; exit code doesn't matter
# here the sheet itself is the product.
exec ${cfg.terminal} -e sh -c "nomarchy-doctor
printf '\nEnter to close.'; read -r _" ;;
firmware)
# System Firmware fwupd/LVFS update surface (HARDWARE.md §4).
# Never auto-flashes: refresh metadata, list what LVFS offers, then
# hand to `fwupdmgr update`, which confirms each device and prompts
# for the reboot a capsule needs (pillar 1 no silent BIOS writes).
# Self-gated on fwupdmgr in System; guard here too.
command -v fwupdmgr >/dev/null 2>&1 \
|| { notify-send "Firmware" "fwupd not available (services.fwupd.enable off?)."; exit 0; }
exec ${cfg.terminal} -e sh -c '
echo "== Refreshing firmware metadata (LVFS) =="
fwupdmgr refresh || true
echo
echo "== Firmware updates available =="
if fwupdmgr get-updates; then
echo
printf "Apply these updates? fwupd confirms each device. [y/N] "
read -r ans
case "$ans" in
[yY]*) fwupdmgr update ;;
*) echo "Cancelled no firmware written." ;;
esac
else
echo "Nothing to apply on this machine."
fi
printf "\nEnter to close."; read -r _' ;;
fingerprint)
# System Fingerprint enroll/list/delete + optional PAM for
# login/sudo (HARDWARE.md §5, BACKLOG #55). Self-gated on fprintd
# CLI (services.fprintd). Enroll is interactive terminal.
command -v fprintd-list >/dev/null 2>&1 \
|| { notify-send "Fingerprint" "fprintd not available (nomarchy.hardware.fingerprint.enable?)."; exit 0; }
pam=$(nomarchy-theme-sync get settings.fingerprint.pam 2>/dev/null || echo false)
case "$pam" in true|True) pam_label="Use for login (on)" ;; *) pam_label="Use for login (off)" ;; esac
choice=$( {
row "Enroll finger" preferences-desktop-user-password
row "List enrolled" view-list
row "Verify" dialog-password
row "Delete all" edit-delete
row "$pam_label" system-lock-screen
back
} | rofi -dmenu -show-icons -p Fingerprint) || exit 0
case "$choice" in
"$BACK") exec "$0" system ;;
*Enroll*)
exec ${cfg.terminal} -e sh -c '
echo "== Enroll fingerprint for $USER =="
fprintd-enroll || true
printf "\nEnter to close."; read -r _' ;;
*List*)
exec ${cfg.terminal} -e sh -c '
echo "== Enrolled fingers for $USER =="
fprintd-list "$USER" || true
printf "\nEnter to close."; read -r _' ;;
*Verify*)
exec ${cfg.terminal} -e sh -c '
echo "== Verify fingerprint for $USER =="
fprintd-verify || true
printf "\nEnter to close."; read -r _' ;;
*"Delete all"*)
exec ${cfg.terminal} -e sh -c '
echo "== Delete all fingerprints for $USER =="
printf "Type yes to delete every enrolled finger: "
read -r ans
case "$ans" in
yes) fprintd-delete "$USER" || true ;;
*) echo "Cancelled." ;;
esac
printf "\nEnter to close."; read -r _' ;;
*"Use for login"*)
case "$pam" in true|True) new=false ;; *) new=true ;; esac
if [ "$new" = true ]; then
if fprintd-list "$USER" 2>/dev/null | grep -qiE 'no fingers enrolled|No devices available'; then
notify-send "Fingerprint" "Enroll a finger first, then enable login."
exit 0
fi
fi
nomarchy-theme-sync --quiet set settings.fingerprint.pam "$new" --no-switch
notify-send "Fingerprint" "Use for login: $new applies on next sys-rebuild."
;;
esac ;;
controlcenter)
# Nomarchy TUI Control Center.
exec ${cfg.terminal} -e nomarchy-control-center ;;
rollback)
# Undo, one menu away (informative + rock-stable). Desktop =
# Home Manager generations: theme/config history is already one
# generation per change, and running an older generation's
# activate script is the supported HM rollback reversible by
# activating a newer one again, so no typed-yes gate here.
# System-level undo deliberately LINKS OUT instead of putting
# destructive flows in rofi: boot-menu generations and snapper
# rollback have their own homes and gates (docs/RECOVERY.md,
# nomarchy-snapshots).
gens=$(home-manager generations 2>/dev/null | head -10)
choice=$( {
if [ -n "$gens" ]; then
printf '%s\n' "$gens" | awk '{
printf "Desktop gen %s %s %s%s\n", $5, $1, $2, (NR==1 ? " (current)" : "")
}' | while IFS= read -r line; do row "$line" document-open-recent; done
fi
command -v nomarchy-snapshots >/dev/null 2>&1 \
&& row "System files Snapshots" timeshift
row "System config boot an older generation (how)" help-about
back
} | rofi -dmenu -show-icons -p Rollback) || exit 0
case "$choice" in
"$BACK") exec "$0" system ;;
"Desktop gen "*)
num=''${choice#Desktop gen }; num=''${num%% *}
path=$(printf '%s\n' "$gens" | awk -v n="$num" '$5 == n { print $7 }')
[ -x "$path/activate" ] \
|| { notify-send "Rollback" "Generation $num not found on disk."; exit 1; }
exec ${cfg.terminal} -e sh -c "
echo \"Activating Home Manager generation $num\"; echo
\"$path/activate\"
echo; echo \"Desktop rolled back to generation $num.\"
echo \"(Roll forward the same way newer generations stay listed.)\"
printf 'Enter to close.'; read -r _" ;;
*Snapshots*) exec "$0" snapshot ;;
*"older generation"*)
notify-send "System rollback" "Reboot and pick an older NixOS generation in the boot menu (the last 10 are kept). Make it stick: revert the change in ~/.nomarchy, then sys-rebuild. Details: docs/RECOVERY.md §3." ;;
esac ;;
tools)
# Tools submenu utilities you invoke. Each leaf is also reachable
# directly (SUPER+CTRL+<mnemonic>); this just keeps the root short.
choice=$( {
# Live ISO only: nomarchy-install is systemPackages on the live
# host (BACKLOG #57). Installed systems never ship the package.
command -v nomarchy-install >/dev/null 2>&1 \
&& row "Install Nomarchy" system-software-install
row "Calculator${menuHint "calc"}" accessories-calculator
row "Clipboard${menuHint "clipboard"}" edit-paste
row "Emoji${menuHint "emoji"}" face-smile-big
row "Files${menuHint "files"}" system-file-manager
row "Web search${menuHint "web"}" system-search
row "Capture${menuHint "capture"}" applets-screenshooter
command -v hyprpicker >/dev/null 2>&1 && row "Color picker${menuHint "colorpicker"}" preferences-desktop-color
row "Ask Claude${menuHint "ask"}" internet-chat
back
} | rofi -dmenu -show-icons -markup-rows -p Tools) || exit 0
case "$choice" in
"$BACK") exec "$0" ;;
*"Install Nomarchy"*)
command -v nomarchy-install >/dev/null 2>&1 \
&& exec ${cfg.terminal} -e nomarchy-install
notify-send "Install" "nomarchy-install is not on PATH (live ISO only)."
exit 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 ;;
*Color*) exec "$0" colorpicker ;;
*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.
choice=$( {
row "Network${menuHint "network"}" network-wireless
row "VPN" network-vpn
command -v blueman-manager >/dev/null 2>&1 \
&& row "Bluetooth${menuHint "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${menuHint "dnd"}" notification-disabled
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
row "Rollback" edit-undo
command -v nomarchy-doctor >/dev/null 2>&1 && row "Doctor" utilities-system-monitor
# Firmware/LVFS updates self-gated on fwupd (default-on, but off
# on VMs/headless via services.fwupd.enable=false), like Printers.
command -v fwupdmgr >/dev/null 2>&1 && row "Firmware" firmware-manager
# Fingerprint self-gated on fprintd CLI (services.fprintd; BACKLOG #55).
command -v fprintd-list >/dev/null 2>&1 && row "Fingerprint" preferences-desktop-user-password
command -v nomarchy-control-center >/dev/null 2>&1 && row "Control Center" preferences-desktop
if has_system_battery && command -v powerprofilesctl >/dev/null 2>&1; then
row "Power profile" preferences-system-power
fi
# Self-gated on the charge-threshold sysfs node (laptops that
# expose it), like Power profile above.
has_charge_threshold && row "Battery limit" battery-080
back
} | rofi -dmenu -show-icons -markup-rows -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 ;;
*"Auto timezone"*) exec "$0" autotimezone ;;
*"Auto-commit"*) exec "$0" autocommit ;;
*Snapshots*) exec "$0" snapshot ;;
*Rollback*) exec "$0" rollback ;;
*Doctor*) exec "$0" doctor ;;
*Firmware*) exec "$0" firmware ;;
*Fingerprint*) exec "$0" fingerprint ;;
*"Control Center"*) exec "$0" controlcenter ;;
*"Power profile"*) exec "$0" power-profile ;;
*"Battery limit"*) exec "$0" batterylimit ;;
esac ;;
batterylimit)
# Battery charge-limit preset picker the rofi-System counterpart of
# the control-center's toggle (Bernardo: he wanted this in the menu,
# not buried in the gum TUI). Writes the baked NixOS option
# Persist + live sysfs apply (same as powermgmt). Boot/AC re-apply
# stays with the oneshot in power.nix.
has_charge_threshold \
|| { notify-send "Battery limit" "No charge-threshold control on this machine."; exit 0; }
cur=$(nomarchy-theme-sync get settings.power.batteryChargeLimit 2>/dev/null)
sel=$( {
row "80% (recommended)" battery-080
row "90%" battery-090
row "60%" battery-060
row "Off charge to 100%" battery-100
row "Custom" battery
back
} | rofi -dmenu -show-icons -p "Battery limit (now: ''${cur:-default})") || exit 0
set_limit() {
nomarchy-theme-sync --quiet set settings.power.batteryChargeLimit "$1" --no-switch
n="$1"; [ "$n" = null ] && n=100
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
thresh="$d/charge_control_end_threshold"
[ -w "$thresh" ] && echo "$n" > "$thresh" 2>/dev/null || true
done
notify-send "Battery limit" "$2 applied now."
}
case "$sel" in
"$BACK") exec "$0" system ;;
"80%"*) set_limit 80 "Set to 80%" ;;
"90%"*) set_limit 90 "Set to 90%" ;;
"60%"*) set_limit 60 "Set to 60%" ;;
"Off"*) set_limit null "Off charges to 100%" ;;
"Custom"*)
limit=$(rofi -dmenu -p "Charge limit % (50100)" < /dev/null) || exit 0
[ -n "$limit" ] || exit 0
case "$limit" in
*[!0-9]*) notify-send "Battery limit" "Not a whole number: $limit"; exit 0 ;;
esac
if [ "$limit" -ge 50 ] && [ "$limit" -le 100 ]; then
set_limit "$limit" "Set to $limit%"
else
notify-send "Battery limit" "Out of range (50100): $limit"
fi ;;
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${appsHint}" applications-all
row "Look &amp; Feel" preferences-desktop-theme
row "Tools" applications-utilities
row "System" preferences-system
row "Power${menuHint "power"}" system-shutdown
row "Keybindings${menuHint "keybinds"}" preferences-desktop-keyboard
} | rofi -dmenu -show-icons -markup-rows -p Menu) || exit 0
case "$choice" in
*Apps*) exec rofi -show drun -theme launcher ;;
*Look*) exec "$0" lookfeel ;;
*Tools*) exec "$0" tools ;;
*System*) exec "$0" system ;;
*Power*) exec "$0" power ;;
*Keybindings*) exec "$0" keybinds ;;
esac ;;
*)
echo "usage: nomarchy-menu [lookfeel|tools|system|power|power-profile|powermgmt|batterylimit|theme|clipboard|calc|files|emoji|web|network|bluetooth|audio|display|display-profile|printers|capture|colorpicker|keybinds|ask|dnd|nightlight|autotimezone|autocommit|vpn|snapshot|doctor|firmware|fingerprint|controlcenter|rollback]" >&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
# Capture OCR region: English only — the unscoped wrapper drags
# in every language's traineddata, a real cost on a compile-from-
# source distro. More languages: override in your home.nix.
(pkgs.tesseract.override { enableLanguages = [ "eng" ]; })
];
# 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.
# compact: with False the script inserts EMPTY rows as section
# separators (eth/wifi/vpn) — blank entries in a themed picker read as
# breakage (item 22). True drops them and uses the unpadded row format,
# which also sits better in rofi's proportional font than the
# space-aligned columns. (A hidden-SSID AP still renders one nameless
# row — sec+bars only; not filterable via config, dedup keeps one.)
xdg.configFile."networkmanager-dmenu/config.ini" = {
force = true;
text = ''
[dmenu]
dmenu_command = rofi
rofi_highlight = True
compact = True
wifi_chars =
[editor]
terminal = ${cfg.terminal}
gui_if_available = False
'';
};
# The launcher theme lives beside HM's active base theme, which it
# @imports so the default launcher is byte-for-byte that theme's list.
# HM names that base by its SOURCE: a generated (attrset) theme lands as
# custom.rasi, but a whole-swap theme (a path — themes/<slug>/rofi.rasi)
# keeps its basename, rofi.rasi. Import must match, or `-theme launcher`
# dies with "custom.rasi not found" (rofi resolves the missing import
# against $HOME) on every whole-swap theme.
xdg.dataFile."rofi/themes/launcher.rasi".text =
if hasLauncherOverride
then builtins.readFile launcherOverride
else ''@import "${if hasRasiOverride then "rofi.rasi" else "custom.rasi"}"'';
programs.rofi = {
enable = true;
terminal = cfg.terminal;
font = "${t.fonts.ui} ${toString t.fonts.size}";
# 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, substring first + fuzzy forgiveness.
# "ste" must find "Steam", "system" finds "System", "fzr" still
# reaches "Firefox". Comma list = match if ANY method hits (rofi 1.7+).
#
# History: fuzzy-only + fzf sorter mis-ranked (ghostty over Steam);
# fuzzy-only still failed short lowercase queries on hardware
# 2026-07-10 despite case-sensitive=false. normal covers the
# prefix/substring case; fuzzy stays for typo-tolerant hits.
# case-smart off so all-lowercase never flips to case-sensitive.
matching = "normal,fuzzy";
case-sensitive = false;
case-smart = false;
sort = true;
sorting-method = "normal";
};
# 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/@surface are fg-derived #RRGGBBAA tints, NOT the
# palette's subtext/surface roles — "on-surface" palettes
# (flexoki-light: subtext==base, surface==text) made the
# inputbar, textbox and alternate rows invisible (item 27;
# guarded by tools/check-theme-contrast.py).
dim = mkLiteral (c.text + "80");
surface = mkLiteral (c.text + "1a");
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 only draws when lines < rows (rofi self-hides on
# short menus). Overflow hint for long lists / launcher grids.
scrollbar = true;
spacing = px 4; # a little air so the rounded zebra rows separate
padding = mkLiteral "8px 0px 0px 0px";
};
"scrollbar" = {
width = px 4;
handle-width = px 4;
handle-color = mkLiteral "@accent";
background-color = mkLiteral "@surface";
border = px 0;
};
# 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";
};
};
};
};
}