# rofi (2.0, native Wayland on 26.05) — launcher + dmenu renderer for # the menu system, themed from theme-state.json. rofi's .rasi is far more # expressive than a flat scheme, so the generated theme styles each # element (accent border, highlighted selection, rounded inputbar); a # themes//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/.json) is read at eval time to build a grid # of real desktop previews. Grouped dark-first then light — the previews # make the mode obvious at a glance, so no light/dark submenu split — and # the active theme is marked ✓. "Active" is just t.slug: every switch # rebuilds this script, so the baked-in mark always tracks the live theme. themeFiles = lib.filterAttrs (n: ty: ty == "regular" && lib.hasSuffix ".json" n) (builtins.readDir cfg.themesDir); themeData = lib.mapAttrsToList (fname: _: let j = builtins.fromJSON (builtins.readFile (cfg.themesDir + "/${fname}")); slug = j.slug or (lib.removeSuffix ".json" fname); in { inherit slug; name = j.name or slug; mode = j.mode or "dark"; hasPreview = builtins.pathExists (cfg.themesDir + "/${slug}/preview.png"); }) themeFiles; byName = lib.sort (a: b: a.name < b.name); orderedThemes = byName (lib.filter (th: th.mode != "light") themeData) # dark group ++ byName (lib.filter (th: th.mode == "light") themeData); # then light # rofi's element-icon cells are SQUARE, so a 16:9 preview would letterbox # (theme-coloured bands top/bottom). Build-time crop each committed 480×270 # preview to a centred square so it fills the cell edge-to-edge. The source # preview.png stays 480×270 (untouched) — only the displayed thumb is square. themeThumbs = pkgs.runCommand "nomarchy-theme-thumbs" { nativeBuildInputs = [ pkgs.imagemagick ]; } '' mkdir -p $out ${lib.concatMapStringsSep "\n" (th: lib.optionalString th.hasPreview '' magick ${cfg.themesDir + "/${th.slug}/preview.png"} \ -resize 360x360^ -gravity center -extent 360x360 -strip $out/${th.slug}.png '') orderedThemes} ''; # One grid row per theme (square thumb + name, ✓ on the active one), and a # Name→slug map so the picked label resolves back to the preset that # nomarchy-theme-sync applies (pretty names ≠ slugs, hence the map). Themes # with no preview degrade to a plain-name row. themeRows = lib.concatMapStringsSep "\n" (th: let label = th.name + lib.optionalString (th.slug == t.slug) " ✓"; in if th.hasPreview then "row ${lib.escapeShellArg label} ${themeThumbs}/${th.slug}.png" else "printf '%s\\n' ${lib.escapeShellArg label}") orderedThemes; themeSlugMap = lib.concatMapStringsSep "\n" (th: " [${lib.escapeShellArg th.name}]=${lib.escapeShellArg th.slug}") orderedThemes; # Per-invocation grid layout (cards: a 16:9 preview above a centered name), # overriding the single-column list theme just for this menu. # · The icon box is sized 16:9 (rofi 2.0 takes a two-value `size`) to # match the 480×270 previews exactly — they fill it with no letterbox. # · The window is sized to the *content* (3 × icon + paddings), not a # percentage, so a column is no wider than its card — otherwise the # selection highlight balloons out around the image. # · flow: horizontal lays cards out row-by-row (rofi's default Vertical # fills column-by-column, so Down at a column's foot jumped to the top # of the next column instead of scrolling the page). # Grid dial — the previews are as big as the icon px allows; screen *height* # caps it, so showing 2 rows (not 3) lets each card grow a lot. Scrolling # reaches the rest. Tune themeGridIconW for size; the window resizes to fit. # rofi's element-icon `size` is a SINGLE value (the manual only documents # one) — a two-value "WxH" is silently collapsed, which is why the preview # used to render tiny inside a too-wide column. So: size = the card *width*, # rofi fits the 16:9 image tight to it (no square letterbox), and the window # is sized so a column is exactly that width — the preview fills the cell. themeGridCols = 3; themeGridLines = 3; themeGridIconW = 240; # square preview card side (px) — the size knob themeGridPad = 0; # margin around the preview inside its cell (px) themeGridGap = 8; # gap between cards (px) themeGridThemeStr = lib.escapeShellArg (lib.concatStringsSep " " [ # window width = cards + their padding + inter-card gaps + chrome (~20px) "window { width: ${toString (themeGridCols * (themeGridIconW + 2 * themeGridPad) + (themeGridCols - 1) * themeGridGap + 20)}px; }" "listview { columns: ${toString themeGridCols}; lines: ${toString themeGridLines}; spacing: ${toString themeGridGap}px; flow: horizontal; }" "element { orientation: vertical; padding: ${toString themeGridPad}px; spacing: 2px; }" "element-icon { size: ${toString themeGridIconW}px; }" "element-text { horizontal-align: 0.5; }" ]); # Keybindings cheatsheet (SUPER+? → the `keybinds` menu module). Built # from the SAME ./keybinds.nix that hyprland.nix binds, so it can never # drift from the live shortcuts. "$mod" reads as SUPER; rows are padded # into two columns. extra[] carries the generated/mouse binds. keybinds = import ./keybinds.nix; padRight = w: s: let n = w - builtins.stringLength s; in s + (if n > 0 then lib.concatStrings (builtins.genList (_: " ") n) else " "); prettyKeys = b: let m = lib.replaceStrings [ "$mod" ] [ "SUPER" ] b.mods; prefix = if m == "" then "" else lib.concatStringsSep " + " (lib.splitString " " m) + " + "; key = lib.replaceStrings [ "question" "slash" "left" "right" "up" "down" ] [ "?" "/" "←" "→" "↑" "↓" ] b.key; # The bind is `SHIFT + slash` (base keysym; keybinds.nix), which # renders "SHIFT + /". `?` IS Shift+/, so collapse that back to the # single documented glyph — the cheatsheet reads "SUPER + ?". in lib.replaceStrings [ "SHIFT + /" ] [ "?" ] (prefix + key); cheatRows = map (b: padRight 22 (prettyKeys b) + b.desc) (keybinds.binds ++ lib.optionals (lib.hasInfix "," cfg.keyboard.layout) keybinds.multiLayoutBinds) # Launch-or-focus binds (nomarchy.launchOrFocus, generated in # hyprland.nix) — same renderer, so they land in the cheatsheet too. ++ map (e: padRight 22 (prettyKeys e) + (if e.desc != "" then e.desc else "Focus or launch ${if e.command == "" then lib.toLower e.class else e.command}")) cfg.launchOrFocus ++ map (e: padRight 22 e.keys + e.desc) keybinds.extra; cheatsheetFile = pkgs.writeText "nomarchy-keybinds.txt" (lib.concatStringsSep "\n" cheatRows + "\n"); # Dimmed keybind hints on menu rows (item 28c) — the same keybinds.nix # + prettyKeys the cheatsheet uses, so they can never drift from the # live binds. Pango spans; the menus that show them pass -markup-rows. # Empty (no hint) when nothing binds the action. hintForAction = action: let m = lib.filter (b: b.action == action) keybinds.binds; in lib.optionalString (m != [ ]) " ${prettyKeys (lib.head m)}"; menuHint = module: hintForAction "exec, nomarchy-menu ${module}"; # Apps opens rofi drun directly, not a nomarchy-menu module. appsHint = " SUPER + Space"; # 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 != [ ]) " ${prettyKeys (lib.head m)}"; nomarchy-menu = pkgs.writeShellScriptBin "nomarchy-menu" '' # Nomarchy menu dispatcher — thin presentation layer over # `rofi -dmenu` (via rofi_menu helper); actions delegate to # nomarchy-theme-sync, systemctl, hyprctl and friends. Menu entries # carry real icons from the theme's icon set (Papirus) via rofi's # per-row icon protocol — see row() below. `nomarchy-menu` with no # argument shows the module picker. # System batteries: type=Battery, not scope=Device — same filter as # nomarchy-battery-notify. Name-agnostic (BAT0, CMB0, test_battery) # so charge-limit / power-profile gates match notify (BACKLOG #60). has_system_battery() { local d for d in /sys/class/power_supply/*/; do [ "$(cat "$d/type" 2>/dev/null)" = Battery ] || continue [ "$(cat "$d/scope" 2>/dev/null || echo System)" = Device ] && continue return 0 done return 1 } has_charge_threshold() { local d for d in /sys/class/power_supply/*/; do [ "$(cat "$d/type" 2>/dev/null)" = Battery ] || continue [ "$(cat "$d/scope" 2>/dev/null || echo System)" = Device ] && continue [ -e "$d/charge_control_end_threshold" ] && return 0 done return 1 } urlencode() { local s="$1" out="" ch i for ((i = 0; i < ''${#s}; i++)); do ch=''${s:i:1} case "$ch" in [a-zA-Z0-9.~_-]) out+="$ch" ;; ' ') out+="+" ;; *) printf -v ch '%%%02X' "'$ch"; out+="$ch" ;; esac done printf '%s' "$out" } # Emit one dmenu row with a themed icon: `row "Label" icon-name`. The NUL # + 0x1f field separator is rofi's per-row icon protocol; printed straight # to the pipe because bash can't store NUL in a variable. Rendered when # rofi has show-icons (on globally; passed explicitly on the icon menus). row() { printf '%s\0icon\x1f%s\n' "$1" "$2"; } # Back-audit exceptions (item 24): external modi/tools (calc, emoji, # networkmanager_dmenu, rofi-pulse-select) can't take an injected row — # Esc is their back path; free-text prompts (web, ask) have no list to # append to — Esc cancels. Everything hand-rolled gets the row below. # General menu convention: every list menu ends with a "↩ Back" entry that # returns one level up (so you never have to Esc out and reopen). The `↩` # glyph in the label IS the arrow — `back` must not also attach a # go-previous icon, or icon menus render two arrows (item 34). So `back` # is now just the plain-label emit, identical to what pick-lists append — # one arrow everywhere. Always matched EXACTLY ("↩ Back") so it can't # collide with clipboard or filename content. BACK="↩ Back" 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/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 theme-sync writer as batterylimit. Each half # self-gates on its hardware, so a desktop with neither just toasts. haveppd=false; command -v powerprofilesctl >/dev/null 2>&1 && haveppd=true havecharge=false; has_charge_threshold && havecharge=true if ! $haveppd && ! $havecharge; then notify-send "Power" "No power-profile or charge-limit control on this machine." exit 0 fi curp=""; $haveppd && curp=$(powerprofilesctl get 2>/dev/null) curc=$(nomarchy-theme-sync get settings.power.batteryChargeLimit 2>/dev/null) choice=$( { if $haveppd; then powerprofilesctl list 2>/dev/null | sed -nE 's/^[* ] ([a-z-]+):$/\1/p' \ | while IFS= read -r p; do case "$p" in performance) row "Profile: $p" battery-profile-performance ;; balanced) row "Profile: $p" battery-profile-balanced ;; power-saver) row "Profile: $p" battery-profile-powersave ;; *) row "Profile: $p" preferences-system-power ;; esac done fi if $havecharge; then row "Charge limit: 80% (recommended)" battery-080 row "Charge limit: 90%" battery-090 row "Charge limit: 60%" battery-060 row "Charge limit: Off (100%)" battery-100 fi back } | rofi_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-theme-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 + Name→slug # 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-theme-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; } 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., --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: a few Hyprland resolution tokens, then the output's # advertised modes (rounded refresh rate, deduped, highest first). The # prompt shows the current mode. cur=$(printf '%s' "$mons" | jq -r --arg n "$name" \ '.[]|select(.name==$n)|"\(.width)x\(.height)@\(.refreshRate|round)"') mode=$( { printf 'preferred\nhighres\nhighrr\n' printf '%s' "$mons" | jq -r --arg n "$name" '.[]|select(.name==$n)|.availableModes[]' \ | sed 's/Hz$//' | awk -F@ '{ printf "%s@%d\n", $1, $2 + 0.5 }' | sort -rV -u printf '%s\n' "$BACK" } | rofi_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. pos=$(printf '%s' "$mons" | jq -r --arg n "$name" '.[]|select(.name==$n)|"\(.x)x\(.y)"') scale=$(printf '%s' "$mons" | jq -r --arg n "$name" '.[]|select(.name==$n)|.scale') if hyprctl keyword monitor "$name,$mode,$pos,$scale" >/dev/null 2>&1; then nomarchy-theme-sync --quiet set "settings.monitors.$name" "$mode" --no-switch notify-send "Display" "$name → $mode" else notify-send "Display" "Could not set $name to $mode." fi ;; capture) # Screenshots + screen recording. Recording rows self-swap: while # one runs, the only offer is Stop (same surface as the bar's ⏺). choice=$( { row "Region → clipboard${hintForBind "" "Print"}" applets-screenshooter row "Region → file${hintForBind "SHIFT" "Print"}" applets-screenshooter command -v satty >/dev/null 2>&1 && row "Annotate region${hintForBind "$mod SHIFT" "Print"}" edit-image row "Full screen → clipboard" camera-photo row "Full screen → file${hintForBind "CTRL" "Print"}" camera-photo command -v tesseract >/dev/null 2>&1 && row "OCR region → clipboard" scanner if command -v nomarchy-record >/dev/null 2>&1; then if nomarchy-record active; then row "■ Stop recording" media-playback-stop else row "Record region" media-record row "Record screen" media-record row "Record region + audio" audio-input-microphone row "Record screen + audio" audio-input-microphone fi fi back } | rofi_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-theme-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-theme-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-theme-sync bg next ;; *"Reset wallpaper"*) exec nomarchy-theme-sync bg auto ;; *"Night light"*) exec "$0" nightlight ;; esac ;; display-profile) # Named multi-output layouts (nomarchy.displayProfiles). Applying is # instant (hyprctl per entry, via nomarchy-display-profile) and # persisted in-flake (settings.displayProfile) so the next rebuild # bakes it. "Base layout" clears the state and hyprctl-reloads. command -v nomarchy-display-profile >/dev/null 2>&1 \ || { notify-send "Display profiles" "None declared (nomarchy.displayProfiles)."; exit 0; } active=$(nomarchy-display-profile active) choice=$( { nomarchy-display-profile list | while IFS= read -r p; do if [ "$p" = "$active" ]; then printf '● %s\n' "$p"; else printf '○ %s\n' "$p"; fi done printf 'Base layout\n' if [ "$(nomarchy-theme-sync get settings.displayProfileAuto 2>/dev/null)" = true ] then printf 'Auto-switch (on)\n' else printf 'Auto-switch (off)\n' fi printf '%s\n' "$BACK" } | rofi_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-theme-sync get settings.displayProfileAuto 2>/dev/null)" = true ]; then nomarchy-theme-sync --quiet set settings.displayProfileAuto false --no-switch notify-send "Display profiles" "Auto-switch off — profiles change only from this menu." else nomarchy-theme-sync --quiet set settings.displayProfileAuto true --no-switch notify-send "Display profiles" "Auto-switch on — plugging/unplugging outputs picks the matching profile." fi exec "$0" display-profile ;; *) exec nomarchy-display-profile apply "''${choice#[●○] }" ;; esac ;; colorpicker) # Pick a pixel's color (hyprpicker's zoom loupe) → clipboard, hex # in the confirmation toast. Esc/cancel exits silently. Self-gated # in the Tools row; guarded here too for direct invocation. command -v hyprpicker >/dev/null 2>&1 \ || { notify-send "Color picker" "hyprpicker is not installed."; exit 0; } color=$(hyprpicker) || exit 0 [ -n "$color" ] || exit 0 printf '%s' "$color" | wl-copy notify-send "Color picker" "$color copied to the clipboard." ;; keybinds) # Read-only cheatsheet, generated from keybinds.nix at build time. # Enter on a row is a deliberate no-op (nothing to launch); ↩ Back # returns to the root picker, which is also where it's launched. sel=$( { cat ${cheatsheetFile}; printf '%s\n' "$BACK"; } \ | rofi_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 ;; 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-theme-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-theme-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-theme-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-theme-sync --quiet set settings.autoTheme.day summer-day --no-switch [ -n "$night" ] || nomarchy-theme-sync --quiet set settings.autoTheme.night summer-night --no-switch nomarchy-theme-sync --quiet set settings.autoTheme.enable true --no-switch notify-send "Auto theme" "On — applying the theme for now…" exec nomarchy-theme-sync auto --force ;; # one rebuild: install timer + apply "Day theme"*) slug=$( { nomarchy-theme-sync list; printf '%s\n' "$BACK"; } | rofi_menu -p "Day theme") || exec "$0" autotheme [ "$slug" != "$BACK" ] && [ -n "$slug" ] \ && nomarchy-theme-sync --quiet set settings.autoTheme.day "$slug" --no-switch exec "$0" autotheme ;; "Night theme"*) slug=$( { nomarchy-theme-sync list; printf '%s\n' "$BACK"; } | rofi_menu -p "Night theme") || exec "$0" autotheme [ "$slug" != "$BACK" ] && [ -n "$slug" ] \ && nomarchy-theme-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-theme-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 ;; autocommit) # Toggle opt-in auto-commit: every menu/theme mutation also commits # theme-state.json (that file only) in the downstream flake, so # settings history is `git log`; 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-theme-sync get settings.autoCommit 2>/dev/null)" = true ]; then nomarchy-theme-sync --quiet set settings.autoCommit false --no-switch notify-send "Auto-commit" "Off — menu changes stay uncommitted in your flake." else nomarchy-theme-sync --quiet set settings.autoCommit true --no-switch notify-send "Auto-commit" "On — 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 ghostty window # (class matched by hyprland.nix windowrules). --gtk-single-instance # =false forces a fresh window — same pattern as nomarchy-calendar. # Exit code doesn't matter; the sheet itself is the product. exec ghostty --class=com.nomarchy.doctor --gtk-single-instance=false \ -e sh -c "nomarchy-doctor printf '\nEnter to close.'; read -r _" ;; firmware) # System ▸ Firmware — fwupd/LVFS update surface (HARDWARE.md §4). # Never auto-flashes: refresh metadata, list what LVFS offers, then # hand to `fwupdmgr update`, which confirms each device and prompts # for the reboot a capsule needs (pillar 1 — no silent BIOS writes). # Self-gated on fwupdmgr in System; guard here too. command -v fwupdmgr >/dev/null 2>&1 \ || { notify-send "Firmware" "fwupd not available (services.fwupd.enable off?)."; exit 0; } exec ${cfg.terminal} -e sh -c ' echo "== Refreshing firmware metadata (LVFS) ==" fwupdmgr refresh || true echo echo "== Firmware updates available ==" if fwupdmgr get-updates; then echo printf "Apply these updates? fwupd confirms each device. [y/N] " read -r ans case "$ans" in [yY]*) fwupdmgr update ;; *) echo "Cancelled — no firmware written." ;; esac else echo "Nothing to apply on this machine." fi printf "\nEnter to close."; read -r _' ;; fingerprint) # System ▸ Fingerprint — enroll/list/delete + optional PAM for # login/sudo (HARDWARE.md §5, BACKLOG #55). Self-gated on fprintd # CLI (services.fprintd). Enroll is interactive → terminal. command -v fprintd-list >/dev/null 2>&1 \ || { notify-send "Fingerprint" "fprintd not available (nomarchy.hardware.fingerprint.enable?)."; exit 0; } pam=$(nomarchy-theme-sync get settings.fingerprint.pam 2>/dev/null || echo false) case "$pam" in true|True) pam_label="Use for login (on)" ;; *) pam_label="Use for login (off)" ;; esac choice=$( { row "Enroll finger" preferences-desktop-user-password row "List enrolled" view-list row "Verify" dialog-password row "Delete all" edit-delete row "$pam_label" system-lock-screen back } | rofi_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 _' ;; *"Use for login"*) case "$pam" in true|True) new=false ;; *) new=true ;; esac if [ "$new" = true ]; then if fprintd-list "$USER" 2>/dev/null | grep -qiE 'no fingers enrolled|No devices available'; then notify-send "Fingerprint" "Enroll a finger first, then enable login." exit 0 fi fi nomarchy-theme-sync --quiet set settings.fingerprint.pam "$new" --no-switch notify-send "Fingerprint" "Use for login: $new — applies on next nomarchy-rebuild." ;; 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 ghostty 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 ghostty --class=com.nomarchy.doctor --gtk-single-instance=false \ -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+); 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 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-theme-sync get settings.autoTimezone 2>/dev/null)" = true ] then row "Auto timezone (on)" preferences-system-time else row "Auto timezone (off)" preferences-system-time fi if [ -e "''${NOMARCHY_PATH:-$HOME/.nomarchy}/.git" ]; then if [ "$(nomarchy-theme-sync get settings.autoCommit 2>/dev/null)" = true ] then row "Auto-commit (on)" git else row "Auto-commit (off)" git fi fi command -v nomarchy-snapshots >/dev/null 2>&1 && row "Snapshots" timeshift row "Rollback" edit-undo # 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 # Self-gated on the charge-threshold sysfs node (laptops that # expose it), like Power profile above. has_charge_threshold && row "Battery limit" battery-080 back } | rofi_menu -show-icons -markup-rows -p System) || exit 0 case "$choice" in "$BACK") exec "$0" ;; *Network*) exec "$0" network ;; *VPN*) exec "$0" vpn ;; *Bluetooth*) exec "$0" bluetooth ;; *Audio*) exec "$0" audio ;; *Display*) exec "$0" display ;; *Keyboard*) exec "$0" keyboard ;; *Printers*) exec "$0" printers ;; *"Do Not Disturb"*) exec "$0" dnd ;; *"Auto timezone"*) exec "$0" autotimezone ;; *"Auto-commit"*) exec "$0" autocommit ;; *Snapshots*) exec "$0" snapshot ;; *Rollback*) exec "$0" rollback ;; *"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" "No charge-threshold control on this machine."; exit 0; } cur=$(nomarchy-theme-sync get settings.power.batteryChargeLimit 2>/dev/null) sel=$( { row "80% (recommended)" battery-080 row "90%" battery-090 row "60%" battery-060 row "Off — charge to 100%" battery-100 row "Custom…" battery back } | rofi_menu -show-icons -p "Battery limit (now: ''${cur:-default})") || exit 0 set_limit() { nomarchy-theme-sync --quiet set settings.power.batteryChargeLimit "$1" --no-switch n="$1"; [ "$n" = null ] && n=100 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 % (50–100)" < /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 (50–100): $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 & Feel" preferences-desktop-theme row "Tools" applications-utilities row "System" preferences-system row "Power${menuHint "power"}" system-shutdown row "Keybindings${menuHint "keybinds"}" preferences-desktop-keyboard } | rofi_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|audio|display|display-profile|keyboard|printers|capture|colorpicker|keybinds|ask|dnd|nightlight|autotimezone|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/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//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"; }; }; }; }; }