Files
Nomarchy/modules/home/rofi.nix
Bernardo Magri 875dae9c58
Some checks failed
Check / eval (push) Has been cancelled
feat: airplane mode (#104) + crisper fastfetch logo (#122)
Airplane: runtime Wi-Fi+Bluetooth kill-switch with prior-state restore,
System menu + SUPER+CTRL+R, Waybar plane glyph (self-hides, whole-swap
parity). Fastfetch: chafa sextants at 24×12 (was block 20×10) for a
finer monogram; V2 kitty screenshots under boreal and summer-night.
2026-07-15 11:33:59 +01:00

1720 lines
86 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 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 → `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-state-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);
# Grouped cheatsheet (#108): Window → Workspace → Menu → Media.
# Section headers are non-selectable chrome (rofi still shows them as rows;
# Enter is a no-op either way). Order matches the group enum.
cheatGroupOrder = [ "Window" "Workspace" "Menu" "Media" ];
cheatBindRows =
map (b: {
inherit (b) group desc;
keys = prettyKeys b;
}) (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: {
group = "Menu";
keys = prettyKeys e;
desc = 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: {
group = e.group or "Menu";
keys = e.keys;
inherit (e) desc;
}) keybinds.extra;
cheatRowsFor = g:
let rows = lib.filter (r: r.group == g) cheatBindRows;
in lib.optionals (rows != [ ]) (
[ (" " + g + " ") ]
++ map (r: padRight 22 r.keys + r.desc) rows
);
cheatRows = lib.concatLists (map cheatRowsFor cheatGroupOrder);
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 + Space</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` (via rofi_menu helper); actions delegate to
# nomarchy-state-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"
TAB=$(printf '\t')
back() { printf '%s\n' "$BACK"; }
# dmenu mode is case-sensitive by default (dmenu compatibility), even
# when config.rasi has case-sensitive: false for drun/modi so
# SUPER+Space (apps) matched "ste"Steam while the main Menu filter
# did not match "sys"System. Always pass -i on menu pickers.
#
# Left arrow one level up (same as choosing " Back"). rofi's
# -kb-custom-1 returns exit 10; Esc still returns non-zero and quits
# the whole menu. Free-text prompts should treat "$BACK" as cancel.
# Pass --grid as the first arg to skip Left=Back (multi-column theme
# picker needs Left for column motion).
# Left is bound to kb-move-char-back by default and rofi hard-fails
# on a duplicate binding (error dialog, no menu) every binding we
# steal must be released from its default first (Ctrl+b remains for
# cursor motion in text prompts).
rofi_menu() {
local out rc left_back=1
if [ "''${1:-}" = --grid ]; then
left_back=0
shift
fi
if [ "$left_back" -eq 1 ]; then
out=$(command rofi -dmenu -i -kb-move-char-back Control+b \
-kb-custom-1 Left "$@" </dev/stdin)
else
out=$(command rofi -dmenu -i "$@" </dev/stdin)
fi
rc=$?
if [ "$left_back" -eq 1 ] && [ "$rc" -eq 10 ]; then
printf '%s\n' "$BACK"
return 0
elif [ "$rc" -eq 0 ]; then
printf '%s\n' "$out"
return 0
else
return 1
fi
}
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_menu -show-icons -p Power) || exit 0
case "$choice" in
"$BACK") exec "$0" ;;
*Lock) loginctl lock-session ;;
*Logout) hyprctl dispatch exit ;;
*Suspend) systemctl suspend ;;
*Hibernate)
# zram can't hold a hibernate image; a machine installed with
# swap=0 has no disk swap. Attempt, and if logind rejects it,
# say why instead of a silent no-op. On success this blocks
# until resume, so the notify only fires on a real failure.
systemctl hibernate 2>/dev/null \
|| notify-send "Hibernate" "Couldn't hibernate likely no swap is configured. See docs/MIGRATION.md Enabling hibernation." ;;
*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_menu -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 state-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-state-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_menu -show-icons -p "Power profile: ''${curp:-n/a}, limit: ''${curc:-default}") || exit 0
# Persist in-flake, then re-apply via the root oneshot (polkit
# allows users to restart it) so a non-writable sysfs node still
# updates. Fallback: direct echo if the node is group-writable.
set_limit() {
nomarchy-state-sync --quiet set settings.power.batteryChargeLimit "$1" --no-switch
n="$1"; [ "$n" = null ] && n=100
# Root oneshot sets Custom charge type (Dell) + thresholds.
applied=
if systemctl restart nomarchy-battery-charge-limit.service 2>/dev/null; then
applied=1
else
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
if [ "$n" -lt 100 ] 2>/dev/null; then
for tnode in charge_types charge_type; do
[ -w "$d$tnode" ] || continue
listed=$(cat "$d$tnode" 2>/dev/null || true)
case " $listed " in *" Custom "*|*"Custom"*) echo Custom > "$d$tnode" 2>/dev/null || true ;; esac
done
fi
thresh="$d/charge_control_end_threshold"
if [ -w "$thresh" ] && echo "$n" > "$thresh" 2>/dev/null; then
applied=1
fi
done
fi
actual=; ctype=
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
[ -r "$d/charge_control_end_threshold" ] \
&& actual=$(cat "$d/charge_control_end_threshold" 2>/dev/null)
ctype=$(cat "$d/charge_types" 2>/dev/null || cat "$d/charge_type" 2>/dev/null || true)
[ -n "$actual" ] && break
done
# Highlight active type: " [Custom] "
active=$(printf '%s' "$ctype" | sed -n 's/.*\[\([^]]*\)\].*/\1/p')
extra=
[ -n "$active" ] && extra=" (type $active)"
if [ -n "$applied" ] && [ -n "$actual" ]; then
notify-send "Battery limit" "$2 stop at $actual%$extra."
elif [ -n "$applied" ]; then
notify-send "Battery limit" "$2 applied (could not re-read sysfs)."
else
notify-send -u critical "Battery limit" \
"Saved in theme-state but could not write sysfs (need rebuild or users-group on threshold)."
fi
}
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_menu --grid -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-state-sync apply "$slug" ;;
clipboard)
sel=$( { cliphist list; printf '%s\n' "$BACK"; } | rofi_menu -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_menu -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_menu -p search < /dev/null) || exit 0
{ [ -n "$q" ] && [ "$q" != "$BACK" ]; } || 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; }
# blueman shows a raw "Connection to BlueZ failed" when bluetoothd
# isn't up (BACKLOG #97) it never runs on machines whose adapter
# is absent, unpowered, or rfkill-blocked (bluetooth.service is
# udev-triggered on /sys/class/bluetooth). Explain instead.
if ! systemctl is-active --quiet bluetooth.service; then
if ls /sys/class/bluetooth/hci* >/dev/null 2>&1; then
notify-send "Bluetooth" "The Bluetooth service is not running. Try: systemctl start bluetooth if it fails, check 'rfkill list' for a blocked adapter."
else
notify-send "Bluetooth" "No Bluetooth adapter detected on this machine (nothing in /sys/class/bluetooth). If it has one, it may be disabled by a hardware switch or missing firmware check 'rfkill list' and 'journalctl -k -g bluetooth'."
fi
exit 0
fi
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
# The helper owns dock-class detection + availability (regexes come
# from dock-audio-rules.nix once). This row is the manual catch-up.
candidate=
command -v nomarchy-dock-audio >/dev/null 2>&1 \
&& candidate=$(nomarchy-dock-audio candidates 2>/dev/null \
| ${pkgs.coreutils}/bin/head -n 1)
dockname=''${candidate%%"$TAB"*}
dockdesc=''${candidate#*"$TAB"}
cursink=$(${pkgs.pulseaudio}/bin/pactl get-default-sink 2>/dev/null || true)
[ "$dockname" = "$cursink" ] && dockname=
choice=$( {
[ -n "$dockname" ] && row "Send output ''${dockdesc:-$dockname}" video-display
row "Output device" audio-volume-high
row "Input device" audio-input-microphone
back
} | rofi_menu -show-icons -p Audio) || exit 0
case "$choice" in
"$BACK") exec "$0" system ;;
"Send output"*)
nomarchy-dock-audio select menu ;;
*Output*) exec rofi-pulse-select sink ;;
*Input*) exec rofi-pulse-select source ;;
esac ;;
keyboard)
command -v nomarchy-keyboard-layout >/dev/null 2>&1 \
|| { notify-send "Keyboard" "Per-device layout helper is unavailable."; exit 0; }
devices=$(nomarchy-keyboard-layout devices)
[ -n "$devices" ] \
|| { notify-send "Keyboard" "No keyboards reported by Hyprland."; exit 0; }
picked=$( {
printf '%s\n' "$devices" | while IFS="$TAB" read -r dev active; do
printf '%s · %s\n' "$dev" "$active"
done
back
} | rofi_menu -p "Keyboard") || exit 0
[ "$picked" = "$BACK" ] && exec "$0" system
device=''${picked%% ·*}
saved=$(nomarchy-keyboard-layout saved "$device")
layout=$( {
[ -n "$saved" ] && printf 'Use session default · forget %s\n' "$saved"
nomarchy-keyboard-layout layouts
back
} | rofi_menu -p "Layout · $device (now: ''${saved:-session default})") || exit 0
case "$layout" in
"$BACK") exec "$0" keyboard ;;
"Use session default"*) exec nomarchy-keyboard-layout forget "$device" ;;
"") exit 0 ;;
*) exec nomarchy-keyboard-layout apply "$device" "$layout" ;;
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)
# Docked context: which outputs are active, which is the built-in
# panel, is anything currently soft-disabled (monitors all shows
# disabled outputs; plain monitors does not).
nActive=$(printf '%s' "$mons" | jq -r '.[].name' | grep -c .)
internal=$(printf '%s' "$mons" | jq -r '[.[].name | select(test("^(eDP|LVDS|DSI)"))][0] // empty')
external=$(printf '%s' "$mons" | jq -r '[.[].name | select(test("^(eDP|LVDS|DSI)") | not)][0] // empty')
offmon=$(printf '%s' "$(hyprctl monitors all -j)" | jq -r '[.[] | select(.disabled)][0].name // empty')
# 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" ] || [ "$nActive" -gt 1 ] || [ -n "$offmon" ]; then
name=$( {
[ -n "$profs" ] && printf 'Profiles \n'
# Dock mode performs an atomic workspace handoff and only then
# disables the internal panel. The always-on hotplug watcher
# holds logind's lid-switch inhibitor across the reverse path.
[ "$nActive" -gt 1 ] && [ -n "$internal" ] && [ -n "$external" ] \
&& printf 'Dock mode · external only %s\n' "$external"
[ -n "$offmon" ] && printf 'Screen on · %s\n' "$offmon"
[ "$nActive" -gt 1 ] && printf 'Move workspace · next monitor\n'
[ "$nActive" -eq 2 ] && printf 'Swap workspaces · %s %s\n' \
"$(printf '%s' "$mons" | jq -r '.[0].name')" \
"$(printf '%s' "$mons" | jq -r '.[1].name')"
printf '%s' "$mons" | jq -r '.[] | "\(.name) · \(.width)x\(.height)@\(.refreshRate|round)Hz"'
printf '%s\n' "$BACK"
} | rofi_menu -p Display ) || exit 0
[ "$name" = "$BACK" ] && exec "$0" system
[ "$name" = "Profiles " ] && exec "$0" display-profile
case "$name" in
"Dock mode"*)
if nomarchy-display-transition dock "$internal" "$external" \
"$external, preferred, auto, 1"; then
notify-send "Display" "Dock mode every laptop workspace moved to $external; laptop panel off."
else
notify-send "Display" "Dock transition failed; the previous layout was kept where possible."
fi
exit 0 ;;
"Screen on"*)
# preferred/auto/1 is the known-good live fallback; the declared
# scale/position come back at the next relogin/switch.
if nomarchy-display-transition enable "$offmon"; then
notify-send "Display" "$offmon is back on."
else
notify-send "Display" "Could not enable $offmon."
fi
exit 0 ;;
"Move workspace"*)
hyprctl dispatch movecurrentworkspacetomonitor +1 >/dev/null 2>&1
exit 0 ;;
"Swap workspaces"*)
hyprctl dispatch swapactiveworkspaces \
"$(printf '%s' "$mons" | jq -r '.[0].name')" \
"$(printf '%s' "$mons" | jq -r '.[1].name')" >/dev/null 2>&1
exit 0 ;;
esac
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: Hyprland tokens, then advertised modes sorted by
# pixel area (highest first). Do NOT use sort -rV on "WxH@R"
# version sort is not geometric and mis-orders common modes (#126).
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'[@x]' '
NF >= 2 {
w=$1+0; h=$2+0; r=($3==""?0:$3+0);
printf "%d\t%dx%d@%d\n", w*h, w, h, r
}' \
| sort -t$'\t' -k1,1nr -k2,2 \
| cut -f2- \
| awk 'NF && !seen[$0]++'
printf '%s\n' "$BACK"
} | rofi_menu -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.
# Notify with hyprctl-reported size so a QEMU fixed window that
# *looks* smaller at higher modes is not mistaken for an invert (#126).
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-state-sync --quiet set "settings.monitors.$name" "$mode" --no-switch
got=$(hyprctl monitors -j | jq -r --arg n "$name" \
'.[]|select(.name==$n)|"\(.width)x\(.height)@\(.refreshRate|round)"')
notify-send "Display" "$name $mode (now ${got:-?})"
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_menu -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 \
&& notify-send "Screenshot" "Region copied to clipboard." ;;
*"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 \
&& notify-send "Screenshot" "Screen copied to clipboard." ;;
*"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-state-sync bg next"}" preferences-desktop-wallpaper
row "Reset wallpaper (auto)" preferences-desktop-wallpaper
# Use the toggle script (knows hyprsunset vs wlsunset); a hard-coded
# hyprsunset-only check always showed (off) in geo mode and when
# the unit was still the empty HM mask from a disabled package unit.
if [ "$(nomarchy-nightlight is-active 2>/dev/null)" = on ]
then row "Night light (on)" weather-clear-night
else row "Night light (off)" weather-clear-night
fi
if [ "$(nomarchy-state-sync get settings.autoTheme.enable 2>/dev/null)" = true ]
then row "Auto theme (on)" preferences-desktop-theme
else row "Auto theme (off)" preferences-desktop-theme
fi
back
} | rofi_menu -show-icons -markup-rows -p "Look & Feel") || exit 0
case "$choice" in
"$BACK") exec "$0" ;;
*"Auto theme"*) exec "$0" autotheme ;;
*Theme*) exec "$0" theme ;;
*"Next wallpaper"*) exec nomarchy-state-sync bg next ;;
*"Reset wallpaper"*) exec nomarchy-state-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-state-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_menu -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-state-sync get settings.displayProfileAuto 2>/dev/null)" = true ]; then
nomarchy-state-sync --quiet set settings.displayProfileAuto false --no-switch
notify-send "Display profiles" "Auto-switch off profiles change only from this menu."
else
nomarchy-state-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_menu -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_menu -p claude < /dev/null) || exit 0
{ [ -n "$q" ] && [ "$q" != "$BACK" ]; } || 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 ;;
airplane)
# Runtime Wi-Fi + Bluetooth kill-switch (#104). Priors restored on
# disengage; Waybar plane glyph self-hides when off.
command -v nomarchy-airplane >/dev/null 2>&1 \
&& exec nomarchy-airplane toggle
notify-send "Airplane mode" "Not available."; exit 0 ;;
autotheme)
# Look & Feel Auto theme automatic day/night theme switch
# (settings.autoTheme, applied by the nomarchy-auto-theme timer from
# autotheme.nix). The enable flag GATES the timer's install, so
# turning it *on* must rebuild; day/night/times are read live by
# `nomarchy-state-sync auto`, so those writes are instant
# (--no-switch). Enabling writes the flag then `auto --force`, so a
# single rebuild both installs the timer and applies the right theme
# now. Disabling is instant: the flag flips and `auto` self-gates to
# a no-op, so the lingering timer does nothing until the next rebuild.
at_get() { nomarchy-state-sync get "settings.autoTheme.$1" 2>/dev/null; }
en=$(at_get enable); day=$(at_get day); night=$(at_get night)
sunrise=$(at_get sunrise); sunset=$(at_get sunset)
choice=$( {
if [ "$en" = true ]
then row "Auto theme (on)" preferences-desktop-theme
else row "Auto theme (off)" preferences-desktop-theme
fi
row "Day theme: ''${day:- pick}" weather-clear
row "Night theme: ''${night:- pick}" weather-clear-night
row "Sunrise: ''${sunrise:-07:00}" preferences-system-time
row "Sunset: ''${sunset:-20:00}" preferences-system-time
back
} | rofi_menu -show-icons -markup-rows -p "Auto theme") || exit 0
case "$choice" in
"$BACK") exec "$0" lookfeel ;;
*"Auto theme"*)
if [ "$en" = true ]; then
nomarchy-state-sync --quiet set settings.autoTheme.enable false --no-switch
notify-send "Auto theme" "Off current theme stays."
exec "$0" autotheme
fi
# Need a day + night pair before enabling; default the summer pair.
[ -n "$day" ] || nomarchy-state-sync --quiet set settings.autoTheme.day summer-day --no-switch
[ -n "$night" ] || nomarchy-state-sync --quiet set settings.autoTheme.night summer-night --no-switch
nomarchy-state-sync --quiet set settings.autoTheme.enable true --no-switch
notify-send "Auto theme" "On applying the theme for now"
exec nomarchy-state-sync auto --force ;; # one rebuild: install timer + apply
"Day theme"*)
slug=$( { nomarchy-state-sync list; printf '%s\n' "$BACK"; } | rofi_menu -p "Day theme") || exec "$0" autotheme
[ "$slug" != "$BACK" ] && [ -n "$slug" ] \
&& nomarchy-state-sync --quiet set settings.autoTheme.day "$slug" --no-switch
exec "$0" autotheme ;;
"Night theme"*)
slug=$( { nomarchy-state-sync list; printf '%s\n' "$BACK"; } | rofi_menu -p "Night theme") || exec "$0" autotheme
[ "$slug" != "$BACK" ] && [ -n "$slug" ] \
&& nomarchy-state-sync --quiet set settings.autoTheme.night "$slug" --no-switch
exec "$0" autotheme ;;
"Sunrise"*|"Sunset"*)
key=sunrise; label=Sunrise; cur="''${sunrise:-07:00}"
case "$choice" in "Sunset"*) key=sunset; label=Sunset; cur="''${sunset:-20:00}" ;; esac
t=$(rofi_menu -p "$label HH:MM (now $cur)" < /dev/null) || exec "$0" autotheme
case "$t" in
""|"$BACK") : ;;
[01][0-9]:[0-5][0-9]|2[0-3]:[0-5][0-9])
nomarchy-state-sync --quiet set "settings.autoTheme.$key" "$t" --no-switch ;;
*) notify-send "Auto theme" "Ignored '$t' use HH:MM (24-hour)." ;;
esac
exec "$0" autotheme ;;
esac ;;
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 ;;
autologin)
# Toggle booting straight into the session (greetd initial_session).
# A SYSTEM setting baked at rebuild, so this sudos a system switch in
# a terminal, like Auto timezone and the change shows on the NEXT
# boot, not now. Off is what makes the greeter ask for a password (or
# a finger, when System Fingerprint is on).
command -v nomarchy-autologin >/dev/null 2>&1 \
|| { notify-send "Auto-login" "Unavailable on this machine."; exit 0; }
exec ${cfg.terminal} -e nomarchy-autologin toggle ;;
autocommit)
# Toggle opt-in auto-commit: every menu/theme mutation also commits
# state.json (that file only) in the downstream flake, so
# settings history is `git log`; nomarchy-pull/-rebuild/-home
# additionally sweep everything else dirty (hand edits, lock bumps)
# into a commit first. 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-state-sync get settings.autoCommit 2>/dev/null)" = true ]; then
nomarchy-state-sync --quiet set settings.autoCommit false --no-switch
notify-send "Auto-commit" "Off menu changes stay uncommitted in your flake."
else
nomarchy-state-sync --quiet set settings.autoCommit true --no-switch
notify-send "Auto-commit" "On menu changes commit; rebuilds sweep pending edits in too."
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 floating, centered kitty window
# (class matched by hyprland.nix windowrules). Same pattern as
# nomarchy-calendar. Exit code doesn't matter; the sheet is the product.
exec kitty --class=com.nomarchy.doctor \
-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-state-sync get settings.fingerprint.pam 2>/dev/null || echo false)
case "$pam" in true|True) pam_label="Fingerprint (on)" ;; *) pam_label="Fingerprint (off)" ;; esac
choice=$( {
# The switch leads: it is the decision this menu exists for, and
# the rest (enroll/list/verify/delete) is the plumbing behind it
# all still usable while the switch is off, since turning it on
# requires an enrolled finger.
row "$pam_label" system-lock-screen
row "Enroll finger" preferences-desktop-user-password
row "List enrolled" view-list
row "Verify" dialog-password
row "Delete all" edit-delete
back
} | rofi_menu -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 _' ;;
*"Fingerprint ("*)
# Drives BOTH sides off one state key (sudo/login PAM + the
# hyprlock unlock), so it sudos a system rebuild and runs a home
# switch in a terminal, like Auto timezone. The enrolled-finger
# guard and the state write live in the tool, so the CLI and this
# row can't disagree.
command -v nomarchy-fingerprint >/dev/null 2>&1 \
|| { notify-send "Fingerprint" "nomarchy-fingerprint not on PATH (hardware.fingerprint.enable?)."; exit 0; }
exec ${cfg.terminal} -e nomarchy-fingerprint toggle ;;
esac ;;
controlcenter)
# Nomarchy TUI Control Center.
exec ${cfg.terminal} -e nomarchy-control-center ;;
whatchanged)
# Plain-language last rebuild (system + desktop nvd). Toast the
# one-liners; open a floating terminal for the full report when
# the user wants detail (same kitty class pattern as Doctor).
if ! command -v nomarchy-what-changed >/dev/null 2>&1; then
notify-send "What changed" "nomarchy-what-changed not on PATH (lifecycle package?)."
exit 0
fi
summary=$(nomarchy-what-changed --summary 2>/dev/null || true)
[ -n "$summary" ] && notify-send -a Nomarchy "What changed" "$summary"
exec kitty --class=com.nomarchy.doctor \
-e sh -c "nomarchy-what-changed
printf '\nEnter to close.'; read -r _" ;;
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_menu -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 nomarchy-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_menu -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
# Airplane mode (#104): runtime Wi-Fi+BT kill-switch. Label
# tracks the session flag; tool is always on PATH via airplane.nix.
if [ "$(nomarchy-airplane is-active 2>/dev/null)" = on ]
then row "Airplane mode (on)${menuHint "airplane"}" network-wireless-offline
else row "Airplane mode (off)${menuHint "airplane"}" network-wireless-offline
fi
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 nomarchy-keyboard-layout >/dev/null 2>&1 \
&& row "Keyboard${menuHint "keyboard"}" preferences-desktop-keyboard
command -v system-config-printer >/dev/null 2>&1 && row "Printers" printer
row "Do Not Disturb${menuHint "dnd"}" notification-disabled
if [ "$(nomarchy-state-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
# Auto-login self-gated on the tool (greeter.enable). Off = the
# greeter asks; that's the prompt the Fingerprint switch feeds.
if command -v nomarchy-autologin >/dev/null 2>&1; then
# Unset (get exits 1, empty) and null both read as off. "None" is
# what an older state-sync printed for null accepted so a menu
# from a newer generation can't misreport against an older tool.
case "$(nomarchy-state-sync get settings.greeter.autoLogin 2>/dev/null)" in
null|""|None) row "Auto-login (off)" system-users ;;
*) row "Auto-login (on)" system-users ;;
esac
fi
if [ -e "''${NOMARCHY_PATH:-$HOME/.nomarchy}/.git" ]; then
if [ "$(nomarchy-state-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
# Plain-language last-rebuild summary (nvd; BACKLOG #82).
command -v nomarchy-what-changed >/dev/null 2>&1 \
&& row "What changed?" view-refresh
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
# Gated on a battery (like Power profile), NOT on the
# charge-threshold sysfs node: firmware without the control must
# get the leaf's explanation, not a silently missing row that
# reads as a broken feature (BACKLOG #96, Acer M5-481T).
has_system_battery && row "Battery limit" battery-080
back
} | rofi_menu -show-icons -markup-rows -p System) || exit 0
case "$choice" in
"$BACK") exec "$0" ;;
*Network*) exec "$0" network ;;
*VPN*) exec "$0" vpn ;;
*"Airplane mode"*) exec "$0" airplane ;;
*Bluetooth*) exec "$0" bluetooth ;;
*Audio*) exec "$0" audio ;;
*Display*) exec "$0" display ;;
*Keyboard*) exec "$0" keyboard ;;
*Printers*) exec "$0" printers ;;
*"Do Not Disturb"*) exec "$0" dnd ;;
*"Auto timezone"*) exec "$0" autotimezone ;;
*"Auto-login"*) exec "$0" autologin ;;
*"Auto-commit"*) exec "$0" autocommit ;;
*Snapshots*) exec "$0" snapshot ;;
*Rollback*) exec "$0" rollback ;;
*"What changed"*) exec "$0" whatchanged ;;
*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" \
"This machine's firmware does not expose a charge-stop control (no charge_control_end_threshold in /sys), so a charge limit cannot be set here. The battery still works normally this is a hardware capability, not a Nomarchy problem."
exit 0; }
cur=$(nomarchy-state-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_menu -show-icons -p "Battery limit (now: ''${cur:-default})") || exit 0
set_limit() {
nomarchy-state-sync --quiet set settings.power.batteryChargeLimit "$1" --no-switch
n="$1"; [ "$n" = null ] && n=100
applied=
if systemctl restart nomarchy-battery-charge-limit.service 2>/dev/null; then
applied=1
else
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
if [ "$n" -lt 100 ] 2>/dev/null; then
for tnode in charge_types charge_type; do
[ -w "$d$tnode" ] || continue
listed=$(cat "$d$tnode" 2>/dev/null || true)
case " $listed " in *" Custom "*|*"Custom"*) echo Custom > "$d$tnode" 2>/dev/null || true ;; esac
done
fi
thresh="$d/charge_control_end_threshold"
if [ -w "$thresh" ] && echo "$n" > "$thresh" 2>/dev/null; then
applied=1
fi
done
fi
actual=; ctype=
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
[ -r "$d/charge_control_end_threshold" ] \
&& actual=$(cat "$d/charge_control_end_threshold" 2>/dev/null)
ctype=$(cat "$d/charge_types" 2>/dev/null || cat "$d/charge_type" 2>/dev/null || true)
[ -n "$actual" ] && break
done
active=$(printf '%s' "$ctype" | sed -n 's/.*\[\([^]]*\)\].*/\1/p')
extra=; [ -n "$active" ] && extra=" (type $active)"
if [ -n "$applied" ] && [ -n "$actual" ]; then
notify-send "Battery limit" "$2 stop at $actual%$extra."
elif [ -n "$applied" ]; then
notify-send "Battery limit" "$2 applied (could not re-read sysfs)."
else
notify-send -u critical "Battery limit" \
"Saved in theme-state but could not write sysfs (need rebuild or users-group on threshold)."
fi
}
case "$sel" in
"$BACK") exec "$0" system ;;
# Order: Off (100%) before 90/80/60 so "100%" does not hit *0%* patterns.
*Off*|*100%*) set_limit null "Off charges to 100%" ;;
*90%*) set_limit 90 "Set to 90%" ;;
*80%*) set_limit 80 "Set to 80%" ;;
*60%*) set_limit 60 "Set to 60%" ;;
*Custom*)
limit=$(rofi_menu -p "Charge limit % (50100)" < /dev/null) || exit 0
{ [ -n "$limit" ] && [ "$limit" != "$BACK" ]; } || 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${menuHint "tools"}" applications-utilities
row "System${menuHint "system"}" preferences-system
row "Power${menuHint "power"}" system-shutdown
row "Keybindings${menuHint "keybinds"}" preferences-desktop-keyboard
} | rofi_menu -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|airplane|audio|display|display-profile|keyboard|printers|capture|colorpicker|keybinds|ask|dnd|nightlight|autotimezone|autologin|autocommit|vpn|snapshot|doctor|firmware|fingerprint|controlcenter|rollback|whatchanged]" >&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"
# Own helper this is a separate package from nomarchy-menu, so the
# menu's rofi_menu function is not in scope (was a silent no-op: every
# VPN submenu died with "rofi_menu: command not found").
rofi_menu() {
local out rc
# Release Left from kb-move-char-back first rofi hard-fails on a
# duplicate binding (same fix as the menu's rofi_menu above).
out=$(command rofi -dmenu -i -kb-move-char-back Control+b \
-kb-custom-1 Left "$@" </dev/stdin)
rc=$?
if [ "$rc" -eq 10 ]; then
printf '%s\n' "$BACK"
return 0
elif [ "$rc" -eq 0 ]; then
printf '%s\n' "$out"
return 0
else
return 1
fi
}
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#* }
err=
if [ "$mark" = "" ]; then
err=$(nmcli connection down "$name" 2>&1) \
&& notify-send "VPN" "Disconnected $name" \
|| notify-send "VPN" "Could not disconnect $name''${err:+ $err}"
else
err=$(nmcli connection up "$name" 2>&1) \
&& notify-send "VPN" "Connected $name" \
|| notify-send "VPN" "Could not connect $name''${err:+ $err}"
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_menu -p "Import VPN config" ) || return
{ [ -z "$file" ] || [ "$file" = "$BACK" ]; } && return
case "$file" in *.ovpn) type=openvpn ;; *) type=wireguard ;; esac
err=$(nmcli connection import type "$type" file "$HOME/$file" 2>&1) \
&& notify-send "VPN" "Imported $type config: $(basename "$file")" \
|| notify-send "VPN" "Import failed''${err:+ $err}"
}
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() {
# Rows show "hostname Country City" (Mullvad-style nodes carry a
# location). The COUNTRY/CITY columns are fixed-width and multi-word,
# so they're sliced by the header's column offsets, never by field
# splitting; hostnames have no spaces, so the pick strips back to the
# hostname at the first space. Locationless nodes stay a bare hostname.
node=$( { printf 'none\n'; tailscale exit-node list 2>/dev/null | awk '
NR==1 { c=index($0,"COUNTRY"); s=index($0,"STATUS"); next }
$2 {
loc=""
if (c > 0) {
end = (s > c) ? s : length($0) + 1
loc = substr($0, c, end - c)
gsub(/ +/, " ", loc); sub(/^ /, "", loc); sub(/ +$/, "", loc)
}
if (loc != "" && loc !~ /^[- ]+$/) print $2 " " loc
else print $2
}'; \
printf '%s\n' "$BACK"; } | rofi_menu -p "Exit node" ) || return
{ [ -z "$node" ] || [ "$node" = "$BACK" ]; } && return
node=''${node%% *} # display row hostname
[ "$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_menu -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
rows=$(conn_rows)
choice=$( {
if [ -n "$rows" ]; then printf '%s\n' "$rows"
else printf 'No VPNs yet import a config or enable Tailscale\n'
fi
printf 'Import config\n'
[ -n "$have_ts" ] && printf 'Tailscale \n'
printf '%s\n' "$BACK"
} | rofi_menu -p VPN ) || exit 0
case "$choice" in
"$BACK"|"") exec nomarchy-menu system ;;
"No VPNs yet"*) ;; # hint row stay in menu
"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]
# -i: networkmanager_dmenu uses dmenu mode (case-sensitive by
# default); match nomarchy-menu's case-insensitive filter.
dmenu_command = rofi -i
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";
# Rofi defaults to the output containing the mouse pointer (-5).
# After docking with the lid shut that pointer can remain on the hidden
# laptop workspace while keyboard focus is on the external display.
monitor = -1; # Hyprland's currently focused output
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";
};
};
};
};
}