Files
Nomarchy/modules/home/rofi.nix
Bernardo Magri f658b0087e
All checks were successful
Check / eval (push) Successful in 4m14s
feat(autotheme): fire at the configured sunrise/sunset, not a 15-min poll
The timer's OnCalendar now bakes the configured times (falling back
like _hhmm_to_min on a hand-edited state), so the switch lands at the
configured minute instead of up to 15 minutes late. The original
poll's robustness case is covered without it: Persistent catches
transitions missed while powered off, systemd fires elapsed calendar
timers on resume, and OnStartupSec settles the theme after login.

Baked times mean a time edit must rebuild: the menu's Sunrise/Sunset
writes now run `auto --force` when enabled (one rebuild re-bakes the
timer and lands on the right theme, same as the enable flow). Day and
night slugs stay live reads — but with no poll to catch them up,
editing the slot currently on screen applies immediately via a plain
`auto`. New eval check `auto-theme-timer` asserts the bake
state-bridges-style: configured times reach OnCalendar, garbage falls
back to 07:00/20:00, disabled installs no unit. Docs + hardware-queue
entry updated to the new semantics.

V2: checks.auto-theme (KVM VM, passed) covers the auto engine the
timer ticks; checks.auto-theme-timer (eval, passed) covers the unit;
OnCalendar strings systemd-analyze-validated; the rebuilt menu script
passes bash -n with the new branches in place. V3 pending: live timer
firing at the exact configured minute on hardware (existing
"Auto-theme pair flip" queue entry, refreshed).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 22:31:02 +01:00

1977 lines
97 KiB
Nix
Raw Permalink 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;
termSheet = import ./term-sheet.nix { inherit pkgs; };
# Ask ▸ web chat (#140). One table, because these URLs are the vendors' to
# change: prefill is undocumented on every one of them, so when a `?q=`
# stops working the fix must be a one-line edit here and nothing else.
# Claude and ChatGPT are the well-attested pair; Gemini and Grok are
# best-effort — hence the clipboard fallback in the `ask` handler, which is
# what keeps a dropped parameter from costing the user their question.
askProviders = {
claude = "https://claude.ai/new?q=";
chatgpt = "https://chatgpt.com/?q=";
gemini = "https://gemini.google.com/app?q=";
grok = "https://grok.com/?q=";
};
askDefaultProvider = "claude";
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
}
# Hibernate path available (logind CanHibernate, or resume= fallback).
# Gates the #115 Preferences row so machines without swap/resume hide it.
can_hibernate() {
local v
v=$(busctl get-property org.freedesktop.login1 \
/org/freedesktop/login1 org.freedesktop.login1.Manager \
CanHibernate 2>/dev/null || true)
case "$v" in
's "yes"') return 0 ;;
esac
grep -qE '(^|[[:space:]])resume=' /proc/cmdline 2>/dev/null
}
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)
# #115: nomarchy-suspend s2h on battery when enabled; else suspend.
if command -v nomarchy-suspend >/dev/null 2>&1; then
nomarchy-suspend
else
systemctl suspend
fi ;;
*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-preferences
[ -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-devices ;;
"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-devices
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-devices
[ "$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, blur/gaps
# (#110 moved blur/gaps out of Control Center presets only, no
# free-text). Root stays six entries.
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
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
case "$(nomarchy-state-sync get ui.blur 2>/dev/null)" in
true|True) row "Blur (on)" preferences-desktop-effects ;;
*) row "Blur (off)" preferences-desktop-effects ;;
esac
gaps=$(nomarchy-state-sync get ui.gapsOut 2>/dev/null || echo 12)
row "Gaps (now $gaps px)" preferences-desktop-display
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 ;;
*Blur*) exec "$0" blur ;;
*Gaps*) exec "$0" gaps ;;
esac ;;
blur)
# ui.blur Hyprland decoration; needs a home switch to bake.
case "$(nomarchy-state-sync get ui.blur 2>/dev/null)" in
true|True)
nomarchy-state-sync --quiet set ui.blur false
notify-send "Blur" "Off desktop rebuilt." ;;
*)
nomarchy-state-sync --quiet set ui.blur true
notify-send "Blur" "On desktop rebuilt." ;;
esac
exit 0 ;;
gaps)
# Preset-only (no free-text) #110 keyboard/gaps free-text was unsafe.
cur=$(nomarchy-state-sync get ui.gapsOut 2>/dev/null || echo 12)
choice=$( {
for g in 0 4 8 12 16 24; do
if [ "$g" = "$cur" ]; then row "$g px (current)" preferences-desktop-display
else row "$g px" preferences-desktop-display
fi
done
back
} | rofi_menu -show-icons -p "Outer gaps (now $cur px)") || exit 0
case "$choice" in
"$BACK") exec "$0" lookfeel ;;
*)
g=''${choice%% *}
case "$g" in *[!0-9]*) exit 0 ;; esac
nomarchy-state-sync --quiet set ui.gapsOut "$g"
notify-send "Gaps" "Outer gaps $g px desktop rebuilt." ;;
esac
exit 0 ;;
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 the provider's web chat in the default browser
# (#140). It used to open claude-code in a terminal: a coding agent, an
# npm fetch on first run and a REPL left sitting there, all to answer
# "what's the syntax for". The prompt was the right surface; the
# destination was not.
#
# The provider is read from the in-flake state on every invocation, so
# switching it in Preferences takes effect immediately no rebuild
# and an unknown/absent value falls back rather than opening nothing.
q=$(rofi_menu -p ask < /dev/null) || exit 0
{ [ -n "$q" ] && [ "$q" != "$BACK" ]; } || exit 0
p=$(nomarchy-state-sync get settings.ask.provider 2>/dev/null)
case "$p" in ${lib.concatStringsSep "|" (lib.attrNames askProviders)}) ;; *) p=${askDefaultProvider} ;; esac
enc=$(printf '%s' "$q" | ${pkgs.jq}/bin/jq -sRr @uri)
case "$p" in
${lib.concatStringsSep "\n" (lib.mapAttrsToList
(name: url: " ${name}) url='${url}'\"$enc\" ;;") askProviders)}
esac
# Prefill is the *vendor's* feature, not ours: each can change or drop
# `?q=` whenever they like, and the failure is silent you land in an
# empty chat having already typed the question. So the text goes to the
# clipboard too. Worst case is one paste, not retyping.
printf '%s' "$q" | ${pkgs.wl-clipboard}/bin/wl-copy 2>/dev/null || true
exec xdg-open "$url" ;;
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 and the
# sunrise/sunset times are BAKED into its OnCalendar, so turning it
# *on* or editing a time must rebuild both run `auto --force`, so
# one rebuild re-bakes the timer AND lands on the right theme now.
# Day/night slugs are read live by `nomarchy-state-sync auto`, so
# those writes are instant (--no-switch); when the edited slot is the
# one on screen, a plain `auto` applies it right away (the timer only
# fires at the next transition there is no poll to catch it up).
# 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"*|"Night theme"*)
key=day; label="Day theme"
case "$choice" in "Night theme"*) key=night; label="Night theme" ;; esac
slug=$( { nomarchy-state-sync list; printf '%s\n' "$BACK"; } | rofi_menu -p "$label") || exec "$0" autotheme
if [ "$slug" != "$BACK" ] && [ -n "$slug" ]; then
nomarchy-state-sync --quiet set "settings.autoTheme.$key" "$slug" --no-switch
# Edited the slot that is on screen right now? Apply it the
# timer won't fire again until the next sunrise/sunset.
if [ "$en" = true ] && [ "$(nomarchy-state-sync auto --which)" = "$slug" ] \
&& [ "$(nomarchy-state-sync get slug)" != "$slug" ]; then
notify-send "Auto theme" "$label: $slug applying"
exec nomarchy-state-sync auto
fi
fi
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
# The time is baked into the timer's OnCalendar, so a change
# must rebuild; `auto --force` is that rebuild AND lands on
# the right theme for the new schedule in the same pass.
if [ "$en" = true ]; then
notify-send "Auto theme" "$label $t rebuilding the schedule"
exec nomarchy-state-sync auto --force
fi ;;
*) 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, sizing itself for the same reason (#139).
# Exit code doesn't matter; the sheet is the product.
exec ${termSheet}/bin/nomarchy-term-sheet com.nomarchy.doctor 55 70 \
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-devices ;;
*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 ;;
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 _" ;;
# #111 Scope-first Recovery one menu for every undo path.
# Desktop = Home Manager generations (themes/home config).
# System boot = NixOS generations in the firmware boot menu.
# Files = BTRFS snapper (files on disk / whole-root roll).
# Each path is listed once; labels say what is restored and the cost
# (instant / reboot / confirm) before the action.
recovery)
choice=$( {
row "Desktop generation themes &amp; home config (instant, reversible)" document-open-recent
row "System boot generation older NixOS (reboot, then pick in boot menu)" system-reboot
command -v nomarchy-snapshots >/dev/null 2>&1 \
&& row "Files (BTRFS) restore files or roll root (password + confirm)" timeshift
command -v nomarchy-what-changed >/dev/null 2>&1 \
&& row "What changed? last system &amp; desktop rebuild" view-refresh
back
} | rofi_menu -show-icons -markup-rows -p Recovery) || exit 0
case "$choice" in
"$BACK") exec "$0" system ;;
*Desktop*) exec "$0" recovery-desktop ;;
*System\ boot*)
notify-send "System boot generation" \
"Reboot and pick an older NixOS generation in the boot menu (last 10 kept). That boot is temporary make it stick: revert in ~/.nomarchy, then nomarchy-rebuild. Details: docs/RECOVERY.md §3." ;;
*Files*) exec "$0" snapshot ;;
*"What changed"*) exec "$0" whatchanged ;;
esac ;;
recovery-desktop)
# Home Manager generations only desktop half of RECOVERY.md §1.
gens=$(home-manager generations 2>/dev/null | head -10)
choice=$( {
if [ -n "$gens" ]; then
printf '%s\n' "$gens" | awk '{
printf "Gen %s %s %s%s\n", $5, $1, $2, (NR==1 ? " (current)" : "")
}' | while IFS= read -r line; do row "$line" document-open-recent; done
else
row "(no Home Manager generations found)" dialog-information
fi
back
} | rofi_menu -show-icons -p "Desktop generation") || exit 0
case "$choice" in
"$BACK"|"(no "*) exec "$0" recovery ;;
"Gen "*)
num=''${choice#Gen }; num=''${num%% *}
path=$(printf '%s\n' "$gens" | awk -v n="$num" '$5 == n { print $7 }')
[ -x "$path/activate" ] \
|| { notify-send "Desktop generation" "Generation $num not found on disk."; exit 1; }
exec ${cfg.terminal} -e sh -c "
echo \"Activating Home Manager generation $num (desktop only)\"
echo \"This does not change the NixOS system or BTRFS files.\"
echo
\"$path/activate\"
echo
echo \"Desktop is now generation $num.\"
echo \"Roll forward the same way newer generations stay listed.\"
printf 'Enter to close.'; read -r _" ;;
esac ;;
# Compat aliases old menu ids and muscle memory.
rollback) exec "$0" recovery ;;
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 AI${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)
# #105 System IA four groups under the same System entry so the
# root menu stays at six items (Apps / Look & Feel / Tools / System /
# Power / Keybindings). Leaves keep SUPER+CTRL mnemonics via their
# own modules; this only organizes the picker.
choice=$( {
row "Connectivity" network-wireless
row "Devices" preferences-desktop-peripherals
row "Recovery${menuHint "recovery"}" edit-undo
row "Preferences" preferences-system
back
} | rofi_menu -show-icons -markup-rows -p System) || exit 0
case "$choice" in
"$BACK") exec "$0" ;;
*Connectivity*) exec "$0" system-connectivity ;;
*Devices*) exec "$0" system-devices ;;
*Recovery*) exec "$0" recovery ;;
*Preferences*) exec "$0" system-preferences ;;
esac ;;
system-connectivity)
choice=$( {
row "Network${menuHint "network"}" network-wireless
row "VPN" network-vpn
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
back
} | rofi_menu -show-icons -markup-rows -p Connectivity) || exit 0
case "$choice" in
"$BACK") exec "$0" system ;;
*Network*) exec "$0" network ;;
*VPN*) exec "$0" vpn ;;
*"Airplane mode"*) exec "$0" airplane ;;
*Bluetooth*) exec "$0" bluetooth ;;
esac ;;
system-devices)
choice=$( {
[ -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
command -v fprintd-list >/dev/null 2>&1 \
&& row "Fingerprint" preferences-desktop-user-password
command -v fwupdmgr >/dev/null 2>&1 && row "Firmware" firmware-manager
back
} | rofi_menu -show-icons -markup-rows -p Devices) || exit 0
case "$choice" in
"$BACK") exec "$0" system ;;
*Audio*) exec "$0" audio ;;
*Display*) exec "$0" display ;;
*Keyboard*) exec "$0" keyboard ;;
*Printers*) exec "$0" printers ;;
*Fingerprint*) exec "$0" fingerprint ;;
*Firmware*) exec "$0" firmware ;;
esac ;;
system-preferences)
# Preferences + former Control Center system toggles (#110).
# Bluetooth/Printing flip NixOS bridges rebuild in a terminal.
# Updates is HM-side (state-sync rebuilds home).
choice=$( {
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
if command -v nomarchy-autologin >/dev/null 2>&1; then
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
case "$(nomarchy-state-sync get settings.updates.enable 2>/dev/null)" in
true|True) row "Update checks (on)" system-software-update ;;
*) row "Update checks (off)" system-software-update ;;
esac
askp=$(nomarchy-state-sync get settings.ask.provider 2>/dev/null)
case "$askp" in ${lib.concatStringsSep "|" (lib.attrNames askProviders)}) ;; *) askp=${askDefaultProvider} ;; esac
row "Ask provider ($askp)" system-users
case "$(nomarchy-state-sync get settings.bluetooth.enable 2>/dev/null)" in
false|False) row "Bluetooth package (off)" bluetooth ;;
*) row "Bluetooth package (on)" bluetooth ;;
esac
case "$(nomarchy-state-sync get settings.printing.enable 2>/dev/null)" in
true|True) row "Printing (on)" printer ;;
*) row "Printing (off)" printer ;;
esac
if has_system_battery && command -v powerprofilesctl >/dev/null 2>&1; then
row "Power profile" preferences-system-power
fi
has_system_battery && row "Battery limit" battery-080
# #115: only offer when hibernate can work (self-gate).
if can_hibernate; then
case "$(nomarchy-state-sync get settings.power.suspendThenHibernate 2>/dev/null)" in
false|False) row "Suspend then hibernate (off)" system-suspend ;;
*) row "Suspend then hibernate (on)" system-suspend ;;
esac
fi
command -v nomarchy-doctor >/dev/null 2>&1 && row "Doctor" utilities-system-monitor
back
} | rofi_menu -show-icons -markup-rows -p Preferences) || exit 0
case "$choice" in
"$BACK") exec "$0" system ;;
*"Do Not Disturb"*) exec "$0" dnd ;;
*"Auto timezone"*) exec "$0" autotimezone ;;
*"Auto-login"*) exec "$0" autologin ;;
*"Auto-commit"*) exec "$0" autocommit ;;
*"Update checks"*) exec "$0" updates-toggle ;;
*"Ask provider"*) exec "$0" ask-provider ;;
*"Bluetooth package"*) exec "$0" bluetooth-package ;;
*Printing*) exec "$0" printing-toggle ;;
*"Power profile"*) exec "$0" power-profile ;;
*"Battery limit"*) exec "$0" batterylimit ;;
*"Suspend then hibernate"*) exec "$0" suspend-then-hibernate-toggle ;;
*Doctor*) exec "$0" doctor ;;
esac ;;
ask-provider)
# settings.ask.provider which web chat SUPER+CTRL+A opens (#140).
# `--no-switch`: the ask handler reads this key at run time, so there
# is nothing to rebuild the next question just goes elsewhere.
cur=$(nomarchy-state-sync get settings.ask.provider 2>/dev/null)
case "$cur" in ${lib.concatStringsSep "|" (lib.attrNames askProviders)}) ;; *) cur=${askDefaultProvider} ;; esac
choice=$( {
${lib.concatStringsSep "\n" (map
(name: " row \"${name}\" system-users") (lib.attrNames askProviders))}
back
} | rofi_menu -show-icons -p "Ask provider (now: $cur)") || exit 0
[ "$choice" = "$BACK" ] && exec "$0" system-preferences
case "$choice" in
${lib.concatStringsSep "|" (lib.attrNames askProviders)})
nomarchy-state-sync --quiet set settings.ask.provider "$choice" --no-switch
notify-send "Ask" "SUPER+CTRL+A now opens $choice." ;;
esac
exec "$0" system-preferences ;;
updates-toggle)
# settings.updates.enable HM nomarchy.updates (Waybar checker).
case "$(nomarchy-state-sync get settings.updates.enable 2>/dev/null)" in
true|True)
nomarchy-state-sync --quiet set settings.updates.enable false
notify-send "Update checks" "Off desktop rebuilt." ;;
*)
nomarchy-state-sync --quiet set settings.updates.enable true
notify-send "Update checks" "On desktop rebuilt." ;;
esac
exit 0 ;;
bluetooth-package)
# settings.bluetooth.enable NixOS hardware.bluetooth (state bridge).
# Needs a system rebuild; run it in a terminal with a clear story.
case "$(nomarchy-state-sync get settings.bluetooth.enable 2>/dev/null)" in
false|False) new=true; label=On ;;
*) new=false; label=Off ;;
esac
exec ${cfg.terminal} -e sh -c "
nomarchy-state-sync --quiet set settings.bluetooth.enable $new --no-switch
echo \"Bluetooth package $label (system rebuild)\"
echo
nomarchy-rebuild
echo
printf 'Enter to close.'; read -r _" ;;
suspend-then-hibernate-toggle)
# #115: settings.power.suspendThenHibernate logind + sleep.conf.
# hypridle/Power menu pick it up live via nomarchy-suspend; lid policy
# needs the system rebuild (same shape as Bluetooth package).
can_hibernate \
|| { notify-send "Suspend then hibernate" \
"Hibernate is not available on this machine (no resume/swap path)."; exit 0; }
case "$(nomarchy-state-sync get settings.power.suspendThenHibernate 2>/dev/null)" in
false|False) new=true; label=On ;;
*) new=false; label=Off ;;
esac
exec ${cfg.terminal} -e sh -c "
nomarchy-state-sync --quiet set settings.power.suspendThenHibernate $new --no-switch
echo \"Suspend then hibernate $label (1h on battery; system rebuild)\"
echo
nomarchy-rebuild
echo
printf 'Enter to close.'; read -r _" ;;
printing-toggle)
case "$(nomarchy-state-sync get settings.printing.enable 2>/dev/null)" in
true|True) new=false; label=Off ;;
*) new=true; label=On ;;
esac
exec ${cfg.terminal} -e sh -c "
nomarchy-state-sync --quiet set settings.printing.enable $new --no-switch
echo \"Printing $label (system rebuild)\"
echo
nomarchy-rebuild
echo
printf 'Enter to close.'; read -r _" ;;
batterylimit)
# Battery charge-limit preset picker the rofi-System counterpart of
# (Bernardo: charge limit belongs in the menu, not a raw 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-preferences ;;
# 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|system-connectivity|system-devices|system-preferences|recovery|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|rollback|whatchanged|blur|gaps|updates-toggle|bluetooth-package|printing-toggle|suspend-then-hibernate-toggle]" >&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.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";
# Selection follows the row *index* across a refilter, not the
# entry — with sort=true a backspace can leave the highlight on an
# unrelated app (rofi#2317; maintainer pointed at this knob).
# Reset to row 0 on every query edit, like dmenu/fuzzel.
inputchange = {
action = "kb-row-first";
};
};
# 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;
# Sized in `ch` — the width of one digit in *this theme's* font —
# not in % of the monitor (#131). A menu has to fit its longest
# label, and that is a count of characters, so the window must
# scale with the font: 40% of a 1366 panel and 40% of a 2560 one
# give the same row 546px and 1024px, while a 14pt monospace theme
# needs ~25% more room than an 11pt one on both. 84ch fits the
# longest row we ship (69 chars) plus icon and padding, measured on
# hardware: 756px in GeistMono 11, 924px in JetBrainsMono 14.
# The `min` cap is what keeps that honest on a small screen — the
# window may never eat more than 65% of the panel, so a big font on
# a 1366 gets a capped 888px rather than sprawling. Grid views
# (theme picker, drun) override this per-invocation; they are sized
# by icons, not text.
width = mkLiteral "calc( 84ch min 65% )";
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";
};
};
};
};
}