# rofi (2.0, native Wayland on 26.05) — launcher + dmenu renderer for
# the menu system, themed from theme-state.json. rofi's .rasi is far more
# expressive than a flat scheme, so the generated theme styles each
# element (accent border, highlighted selection, rounded inputbar); a
# themes/<slug>/rofi.rasi whole-swap replaces it entirely.
#
# Home of nomarchy-menu, the dispatcher all menu modules hang off.
{ config, lib, pkgs, ... }:

let
  cfg = config.nomarchy;
  t = cfg.theme;
  c = t.colors;
  inherit (config.lib.formats.rasi) mkLiteral;

  px = n: mkLiteral "${toString n}px";

  # Per-theme override probe (same convention as waybar.css).
  rasiOverride = cfg.themesDir + "/${t.slug}/rofi.rasi";
  hasRasiOverride = builtins.pathExists rasiOverride;

  # App-launcher theme (SUPER+Space/D → `rofi -show drun -theme launcher`).
  # Kept separate from the base theme so a theme can give the launcher its
  # own layout (e.g. Boreal's icon grid) WITHOUT that layout leaking into
  # the text menus, which share the base theme and need a list. The default
  # just re-imports the active base theme (custom.rasi for generated themes,
  # rofi.rasi for whole-swaps — see the dataFile below), so every other
  # theme's launcher is byte-for-byte its list.
  launcherOverride = cfg.themesDir + "/${t.slug}/launcher.rasi";
  hasLauncherOverride = builtins.pathExists launcherOverride;

  # ── Visual theme picker ──────────────────────────────────────────────
  # Every preset (themes/<slug>.json) is read at eval time to build a grid
  # of real desktop previews. Grouped dark-first then light — the previews
  # make the mode obvious at a glance, so no light/dark submenu split — and
  # the active theme is marked ✓. "Active" is just t.slug: every switch
  # rebuilds this script, so the baked-in mark always tracks the live theme.
  themeFiles = lib.filterAttrs
    (n: ty: ty == "regular" && lib.hasSuffix ".json" n)
    (builtins.readDir cfg.themesDir);
  themeData = lib.mapAttrsToList (fname: _:
    let
      j = builtins.fromJSON (builtins.readFile (cfg.themesDir + "/${fname}"));
      slug = j.slug or (lib.removeSuffix ".json" fname);
    in {
      inherit slug;
      name = j.name or slug;
      mode = j.mode or "dark";
      hasPreview = builtins.pathExists (cfg.themesDir + "/${slug}/preview.png");
    }) themeFiles;

  byName = lib.sort (a: b: a.name < b.name);
  orderedThemes =
    byName (lib.filter (th: th.mode != "light") themeData)   # dark group
    ++ byName (lib.filter (th: th.mode == "light") themeData); # then light

  # rofi's element-icon cells are SQUARE, so a 16:9 preview would letterbox
  # (theme-coloured bands top/bottom). Build-time crop each committed 480×270
  # preview to a centred square so it fills the cell edge-to-edge. The source
  # preview.png stays 480×270 (untouched) — only the displayed thumb is square.
  themeThumbs = pkgs.runCommand "nomarchy-theme-thumbs"
    { nativeBuildInputs = [ pkgs.imagemagick ]; } ''
    mkdir -p $out
    ${lib.concatMapStringsSep "\n"
      (th: lib.optionalString th.hasPreview ''
        magick ${cfg.themesDir + "/${th.slug}/preview.png"} \
          -resize 360x360^ -gravity center -extent 360x360 -strip $out/${th.slug}.png
      '')
      orderedThemes}
  '';

  # One grid row per theme (square thumb + name, ✓ on the active one), and a
  # Name→slug map so the picked label resolves back to the preset that
  # nomarchy-theme-sync applies (pretty names ≠ slugs, hence the map). Themes
  # with no preview degrade to a plain-name row.
  themeRows = lib.concatMapStringsSep "\n" (th:
    let label = th.name + lib.optionalString (th.slug == t.slug) " ✓";
    in if th.hasPreview
       then "row ${lib.escapeShellArg label} ${themeThumbs}/${th.slug}.png"
       else "printf '%s\\n' ${lib.escapeShellArg label}")
    orderedThemes;
  themeSlugMap = lib.concatMapStringsSep "\n" (th:
    "  [${lib.escapeShellArg th.name}]=${lib.escapeShellArg th.slug}")
    orderedThemes;

  # Per-invocation grid layout (cards: a 16:9 preview above a centered name),
  # overriding the single-column list theme just for this menu.
  #   · The icon box is sized 16:9 (rofi 2.0 takes a two-value `size`) to
  #     match the 480×270 previews exactly — they fill it with no letterbox.
  #   · The window is sized to the *content* (3 × icon + paddings), not a
  #     percentage, so a column is no wider than its card — otherwise the
  #     selection highlight balloons out around the image.
  #   · flow: horizontal lays cards out row-by-row (rofi's default Vertical
  #     fills column-by-column, so Down at a column's foot jumped to the top
  #     of the next column instead of scrolling the page).
  # Grid dial — the previews are as big as the icon px allows; screen *height*
  # caps it, so showing 2 rows (not 3) lets each card grow a lot. Scrolling
  # reaches the rest. Tune themeGridIconW for size; the window resizes to fit.
  # rofi's element-icon `size` is a SINGLE value (the manual only documents
  # one) — a two-value "WxH" is silently collapsed, which is why the preview
  # used to render tiny inside a too-wide column. So: size = the card *width*,
  # rofi fits the 16:9 image tight to it (no square letterbox), and the window
  # is sized so a column is exactly that width — the preview fills the cell.
  themeGridCols = 3;
  themeGridLines = 3;
  themeGridIconW = 240;   # square preview card side (px) — the size knob
  themeGridPad = 0;       # margin around the preview inside its cell (px)
  themeGridGap = 8;       # gap between cards (px)
  themeGridThemeStr = lib.escapeShellArg (lib.concatStringsSep " " [
    # window width = cards + their padding + inter-card gaps + chrome (~20px)
    "window { width: ${toString (themeGridCols * (themeGridIconW + 2 * themeGridPad) + (themeGridCols - 1) * themeGridGap + 20)}px; }"
    "listview { columns: ${toString themeGridCols}; lines: ${toString themeGridLines}; spacing: ${toString themeGridGap}px; flow: horizontal; }"
    "element { orientation: vertical; padding: ${toString themeGridPad}px; spacing: 2px; }"
    "element-icon { size: ${toString themeGridIconW}px; }"
    "element-text { horizontal-align: 0.5; }"
  ]);

  # Keybindings cheatsheet (SUPER+? → the `keybinds` menu module). Built
  # from the SAME ./keybinds.nix that hyprland.nix binds, so it can never
  # drift from the live shortcuts. "$mod" reads as SUPER; rows are padded
  # into two columns. extra[] carries the generated/mouse binds.
  keybinds = import ./keybinds.nix;
  padRight = w: s:
    let n = w - builtins.stringLength s;
    in s + (if n > 0 then lib.concatStrings (builtins.genList (_: " ") n) else "  ");
  prettyKeys = b:
    let m = lib.replaceStrings [ "$mod" ] [ "SUPER" ] b.mods;
        prefix = if m == "" then "" else lib.concatStringsSep " + " (lib.splitString " " m) + " + ";
        key = lib.replaceStrings
          [ "question" "slash" "left" "right" "up" "down" ]
          [ "?" "/" "←" "→" "↑" "↓" ] b.key;
        # The bind is `SHIFT + slash` (base keysym; keybinds.nix), which
        # renders "SHIFT + /". `?` IS Shift+/, so collapse that back to the
        # single documented glyph — the cheatsheet reads "SUPER + ?".
    in lib.replaceStrings [ "SHIFT + /" ] [ "?" ] (prefix + key);
  cheatRows =
    map (b: padRight 22 (prettyKeys b) + b.desc) (keybinds.binds
      ++ lib.optionals (lib.hasInfix "," cfg.keyboard.layout)
         keybinds.multiLayoutBinds)
    # Launch-or-focus binds (nomarchy.launchOrFocus, generated in
    # hyprland.nix) — same renderer, so they land in the cheatsheet too.
    ++ map (e: padRight 22 (prettyKeys e)
               + (if e.desc != "" then e.desc
                  else "Focus or launch ${if e.command == "" then lib.toLower e.class else e.command}"))
         cfg.launchOrFocus
    ++ map (e: padRight 22 e.keys + e.desc) keybinds.extra;
  cheatsheetFile = pkgs.writeText "nomarchy-keybinds.txt"
    (lib.concatStringsSep "\n" cheatRows + "\n");

  # Dimmed keybind hints on menu rows (item 28c) — the same keybinds.nix
  # + prettyKeys the cheatsheet uses, so they can never drift from the
  # live binds. Pango spans; the menus that show them pass -markup-rows.
  # Empty (no hint) when nothing binds the action.
  hintForAction = action:
    let m = lib.filter (b: b.action == action) keybinds.binds;
    in lib.optionalString (m != [ ])
      "  <span size='small' alpha='55%'>${prettyKeys (lib.head m)}</span>";
  menuHint = module: hintForAction "exec, nomarchy-menu ${module}";
  # Apps opens rofi drun directly, not a nomarchy-menu module.
  appsHint = "  <span size='small' alpha='55%'>SUPER + D</span>";
  # A menu row that runs the same op as a global bind whose action isn't a
  # `nomarchy-menu` module (the Capture rows ↔ the Print screenshot keys):
  # match on mods+key so the hint still reads from keybinds.nix (single
  # source, no drift), rather than a brittle full-action-string compare.
  hintForBind = mods: key:
    let m = lib.filter (b: b.mods == mods && b.key == key) keybinds.binds;
    in lib.optionalString (m != [ ])
      "  <span size='small' alpha='55%'>${prettyKeys (lib.head m)}</span>";

  nomarchy-menu = pkgs.writeShellScriptBin "nomarchy-menu" ''
    # Nomarchy menu dispatcher — thin presentation layer over
    # `rofi -dmenu`; actions delegate to nomarchy-theme-sync, systemctl,
    # hyprctl and friends. Menu entries carry real icons from the theme's
    # icon set (Papirus) via rofi's per-row icon protocol — see row() below.
    # `nomarchy-menu` with no argument shows the module picker.

    # System batteries: type=Battery, not scope=Device — same filter as
    # nomarchy-battery-notify. Name-agnostic (BAT0, CMB0, test_battery)
    # so charge-limit / power-profile gates match notify (BACKLOG #60).
    has_system_battery() {
      local d
      for d in /sys/class/power_supply/*/; do
        [ "$(cat "$d/type" 2>/dev/null)" = Battery ] || continue
        [ "$(cat "$d/scope" 2>/dev/null || echo System)" = Device ] && continue
        return 0
      done
      return 1
    }
    has_charge_threshold() {
      local d
      for d in /sys/class/power_supply/*/; do
        [ "$(cat "$d/type" 2>/dev/null)" = Battery ] || continue
        [ "$(cat "$d/scope" 2>/dev/null || echo System)" = Device ] && continue
        [ -e "$d/charge_control_end_threshold" ] && return 0
      done
      return 1
    }

    urlencode() {
      local s="$1" out="" ch i
      for ((i = 0; i < ''${#s}; i++)); do
        ch=''${s:i:1}
        case "$ch" in
          [a-zA-Z0-9.~_-]) out+="$ch" ;;
          ' ') out+="+" ;;
          *) printf -v ch '%%%02X' "'$ch"; out+="$ch" ;;
        esac
      done
      printf '%s' "$out"
    }

    # Emit one dmenu row with a themed icon: `row "Label" icon-name`. The NUL
    # + 0x1f field separator is rofi's per-row icon protocol; printed straight
    # to the pipe because bash can't store NUL in a variable. Rendered when
    # rofi has show-icons (on globally; passed explicitly on the icon menus).
    row() { printf '%s\0icon\x1f%s\n' "$1" "$2"; }

    # Back-audit exceptions (item 24): external modi/tools (calc, emoji,
    # networkmanager_dmenu, rofi-pulse-select) can't take an injected row —
    # Esc is their back path; free-text prompts (web, ask) have no list to
    # append to — Esc cancels. Everything hand-rolled gets the row below.
    # General menu convention: every list menu ends with a "↩ Back" entry that
    # returns one level up (so you never have to Esc out and reopen). The `↩`
    # glyph in the label IS the arrow — `back` must not also attach a
    # go-previous icon, or icon menus render two arrows (item 34). So `back`
    # is now just the plain-label emit, identical to what pick-lists append —
    # one arrow everywhere. Always matched EXACTLY ("↩ Back") so it can't
    # collide with clipboard or filename content.
    BACK="↩ Back"
    back() { printf '%s\n' "$BACK"; }

    case "''${1:-}" in
      power)
        choice=$( {
          row "Lock"      system-lock-screen
          row "Logout"    system-log-out
          row "Suspend"   system-suspend
          row "Hibernate" system-hibernate
          row "Reboot"    system-reboot
          row "Shutdown"  system-shutdown
          back
        } | rofi -dmenu -show-icons -p Power) || exit 0
        case "$choice" in
          "$BACK")    exec "$0" ;;
          *Lock)      loginctl lock-session ;;
          *Logout)    hyprctl dispatch exit ;;
          *Suspend)   systemctl suspend ;;
          *Hibernate) systemctl hibernate ;;
          *Reboot)    systemctl reboot ;;
          *Shutdown)  systemctl poweroff ;;
        esac ;;

      power-profile)
        # power-profiles-daemon profile switcher (the system side ships it
        # by default on laptops — nomarchy.system.power). Switching goes
        # through polkit, so no root prompt.
        if ! command -v powerprofilesctl >/dev/null 2>&1; then
          notify-send "Power profiles" "power-profiles-daemon isn't running on this machine."
          exit 0
        fi
        cur=$(powerprofilesctl get 2>/dev/null)
        # Each profile gets its themed icon from Papirus' colored
        # battery-profile-* family (the -symbolic variants are #444 grey and
        # vanish on dark themes). The selected row's returned text is still
        # the bare profile name, so `powerprofilesctl set` is unaffected.
        choice=$( {
          powerprofilesctl list 2>/dev/null | sed -nE 's/^[* ] ([a-z-]+):$/\1/p' \
            | while IFS= read -r p; do
                case "$p" in
                  performance) row "$p" battery-profile-performance ;;
                  balanced)    row "$p" battery-profile-balanced ;;
                  power-saver) row "$p" battery-profile-powersave ;;
                  *)           row "$p" preferences-system-power ;;
                esac
              done
          back
        } | rofi -dmenu -show-icons -p "profile (now: $cur)") || exit 0
        [ "$choice" = "$BACK" ] && exec "$0" system
        [ -n "$choice" ] && powerprofilesctl set "$choice" ;;

      powermgmt)
        # Combined power menu (item 36b) — power PROFILE + battery CHARGE cap
        # in one flat list, opened from BOTH the Waybar battery and
        # power-profile icons (the two granular System ▸ Power profile /
        # Battery limit rows stay for direct access). No new backend: profile
        # rows drive powerprofilesctl like the power-profile picker, charge
        # rows drive the same theme-sync writer as batterylimit. Each half
        # self-gates on its hardware, so a desktop with neither just toasts.
        haveppd=false;    command -v powerprofilesctl >/dev/null 2>&1 && haveppd=true
        havecharge=false; has_charge_threshold && havecharge=true
        if ! $haveppd && ! $havecharge; then
          notify-send "Power" "No power-profile or charge-limit control on this machine."
          exit 0
        fi
        curp=""; $haveppd && curp=$(powerprofilesctl get 2>/dev/null)
        curc=$(nomarchy-theme-sync get settings.power.batteryChargeLimit 2>/dev/null)
        choice=$( {
          if $haveppd; then
            powerprofilesctl list 2>/dev/null | sed -nE 's/^[* ] ([a-z-]+):$/\1/p' \
              | while IFS= read -r p; do
                  case "$p" in
                    performance) row "Profile: $p" battery-profile-performance ;;
                    balanced)    row "Profile: $p" battery-profile-balanced ;;
                    power-saver) row "Profile: $p" battery-profile-powersave ;;
                    *)           row "Profile: $p" preferences-system-power ;;
                  esac
                done
          fi
          if $havecharge; then
            row "Charge limit: 80% (recommended)" battery-080
            row "Charge limit: 90%"               battery-090
            row "Charge limit: 60%"               battery-060
            row "Charge limit: Off (100%)"        battery-100
          fi
          back
        } | rofi -dmenu -show-icons -p "Power — profile: ''${curp:-n/a}, limit: ''${curc:-default}") || exit 0
        set_limit() {
          nomarchy-theme-sync --quiet set settings.power.batteryChargeLimit "$1" --no-switch
          notify-send "Battery limit" "$2 — applies on the next rebuild."
        }
        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 -dmenu -show-icons -p Theme -theme-str ${themeGridThemeStr}) || exit 0
        [ "$choice" = "$BACK" ] && exec "$0"
        choice="''${choice% ✓}"             # drop the active marker if present
        slug="''${THEME_SLUG[$choice]:-}"
        [ -n "$slug" ] && exec nomarchy-theme-sync apply "$slug" ;;

      clipboard)
        sel=$( { cliphist list; printf '%s\n' "$BACK"; } | rofi -dmenu -p clip) || exit 0
        [ "$sel" = "$BACK" ] && exec "$0" tools
        printf '%s' "$sel" | cliphist decode | wl-copy ;;

      calc)
        # Live calculator (rofi-calc): re-evaluates every keystroke via
        # libqalculate — which dodges the qalc CLI's "15% of 200" misparse —
        # with the answer as the top entry. Enter copies the result; the
        # menu stays open to chain calculations (Esc closes).
        exec rofi -show calc -modi calc -no-show-match -no-sort \
          -calc-command "echo -n '{result}' | wl-copy" ;;

      files)
        sel=$( { fd . "$HOME" --type f --hidden --exclude .git --exclude .cache 2>/dev/null \
          | head -n 50000 | sed "s|^$HOME/||"; printf '%s\n' "$BACK"; } \
          | rofi -dmenu -p file) || exit 0
        [ "$sel" = "$BACK" ] && exec "$0" tools
        [ -n "$sel" ] && exec xdg-open "$HOME/$sel" ;;

      emoji)
        # Emoji / symbol picker (rofi-emoji). The default action copies the
        # glyph to the clipboard through the plugin's Wayland adapter
        # (wl-copy); the alternate action types it into the focused window.
        exec rofi -show emoji -modi emoji ;;

      web)
        q=$(rofi -dmenu -p search < /dev/null) || exit 0
        [ -n "$q" ] || exit 0
        exec xdg-open "https://www.google.com/search?q=$(urlencode "$q")" ;;

      network)
        # Native rofi wifi/VPN picker (networkmanager_dmenu) over the system
        # NetworkManager — replaces the old nmtui-in-a-terminal flow. Reads
        # its config from xdg.configFile below (told to drive rofi).
        # External tool: no injected ↩ Back row possible — Esc closes.
        exec networkmanager_dmenu ;;

      vpn)
        # VPN setup + management (nomarchy-vpn): NM VPN/WireGuard connect +
        # config import + Tailscale. Distinct from `network` (the wifi picker).
        exec nomarchy-vpn ;;

      bluetooth)
        # blueman-manager GUI (services.blueman.enable, system-side).
        # Self-gated in the System menu on the CLI; guard here too in case
        # it's invoked directly with nomarchy.system.bluetooth.enable=false.
        command -v blueman-manager >/dev/null 2>&1 \
          || { notify-send "Bluetooth" "Not available (nomarchy.system.bluetooth off?)."; exit 0; }
        exec blueman-manager ;;

      printers)
        # system-config-printer GUI (the CUPS admin app), installed by
        # nomarchy.services.printing. Self-gated in the System menu; guard
        # here too in case it's invoked directly.
        command -v system-config-printer >/dev/null 2>&1 \
          && exec system-config-printer
        notify-send "Printers" "Not available (nomarchy.services.printing off?)."; exit 0 ;;

      audio)
        # PipeWire (pulse) sink/source switcher via rofi-pulse-select. Self-
        # gated in the System menu on the pulse socket; guarded here too.
        if [ ! -e "''${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/pulse/native" ]; then
          notify-send "Audio" "No PipeWire/PulseAudio socket (nomarchy.audio off?)."; exit 0
        fi
        choice=$( {
          row "Output device" audio-volume-high
          row "Input device"  audio-input-microphone
          back
        } | rofi -dmenu -show-icons -p Audio) || exit 0
        case "$choice" in
          "$BACK")  exec "$0" system ;;
          *Output*) exec rofi-pulse-select sink ;;
          *Input*)  exec rofi-pulse-select source ;;
        esac ;;

      display)
        # Per-output resolution picker. Applies the chosen mode INSTANTLY via
        # `hyprctl keyword monitor` — keeping the output's current position and
        # scale, so only the resolution changes — and PERSISTS it to the in-flake
        # state (settings.monitors.<name>, --no-switch = no rebuild), so the
        # choice survives logout/reboot. A later rebuild bakes the same
        # resolution into the generated monitor rule (hyprland.nix overlays
        # settings.monitors onto nomarchy.monitors by name). Self-gated to the
        # Hyprland session; guard here too in case it's invoked directly.
        command -v hyprctl >/dev/null 2>&1 \
          || { notify-send "Display" "Hyprland is not running."; exit 0; }
        mons=$(hyprctl monitors -j)

        # Choose the output (skip the chooser when only one is connected and
        # there are no profiles — the Profiles row needs the chooser to live in).
        # Where the mode picker's Back returns to: the output chooser when it
        # was shown (a real previous level), else straight to System — on a
        # single-monitor laptop the chooser is skipped, so returning to
        # `display` would just re-open this same mode picker (item 40).
        modeBack=system
        profs=; command -v nomarchy-display-profile >/dev/null 2>&1 && profs=1
        if [ -n "$profs" ] || [ "$(printf '%s' "$mons" | jq -r '.[].name' | grep -c .)" -gt 1 ]; then
          name=$( {
            [ -n "$profs" ] && printf 'Profiles ›\n'
            printf '%s' "$mons" | jq -r '.[] | "\(.name)  ·  \(.width)x\(.height)@\(.refreshRate|round)Hz"'
            printf '%s\n' "$BACK"
          } | rofi -dmenu -p Display ) || exit 0
          [ "$name" = "$BACK" ] && exec "$0" system
          [ "$name" = "Profiles ›" ] && exec "$0" display-profile
          name=''${name%%  ·*}                       # strip the "  ·  WxH@R" hint
          modeBack=display
        else
          name=$(printf '%s' "$mons" | jq -r '.[0].name')
        fi
        [ -n "$name" ] || exit 0

        # Pick a mode: a few Hyprland resolution tokens, then the output's
        # advertised modes (rounded refresh rate, deduped, highest first). The
        # prompt shows the current mode.
        cur=$(printf '%s' "$mons" | jq -r --arg n "$name" \
          '.[]|select(.name==$n)|"\(.width)x\(.height)@\(.refreshRate|round)"')
        mode=$( {
          printf 'preferred\nhighres\nhighrr\n'
          printf '%s' "$mons" | jq -r --arg n "$name" '.[]|select(.name==$n)|.availableModes[]' \
            | sed 's/Hz$//' | awk -F@ '{ printf "%s@%d\n", $1, $2 + 0.5 }' | sort -rV -u
          printf '%s\n' "$BACK"
        } | rofi -dmenu -p "$name (now $cur)" ) || exit 0
        [ "$mode" = "$BACK" ] && exec "$0" "$modeBack"
        [ -n "$mode" ] || exit 0

        # Apply live (keep the output's current position + scale), then persist.
        pos=$(printf '%s' "$mons" | jq -r --arg n "$name" '.[]|select(.name==$n)|"\(.x)x\(.y)"')
        scale=$(printf '%s' "$mons" | jq -r --arg n "$name" '.[]|select(.name==$n)|.scale')
        if hyprctl keyword monitor "$name,$mode,$pos,$scale" >/dev/null 2>&1; then
          nomarchy-theme-sync --quiet set "settings.monitors.$name" "$mode" --no-switch
          notify-send "Display" "$name → $mode"
        else
          notify-send "Display" "Could not set $name to $mode."
        fi ;;

      capture)
        # Screenshots + screen recording. Recording rows self-swap: while
        # one runs, the only offer is Stop (same surface as the bar's ⏺).
        choice=$( {
          row "Region → clipboard${hintForBind "" "Print"}" applets-screenshooter
          row "Region → file${hintForBind "SHIFT" "Print"}" applets-screenshooter
          command -v satty >/dev/null 2>&1 && row "Annotate region${hintForBind "$mod SHIFT" "Print"}" edit-image
          row "Full screen → clipboard" camera-photo
          row "Full screen → file${hintForBind "CTRL" "Print"}" camera-photo
          command -v tesseract >/dev/null 2>&1 && row "OCR region → clipboard" scanner
          if command -v nomarchy-record >/dev/null 2>&1; then
            if nomarchy-record active; then
              row "■ Stop recording" media-playback-stop
            else
              row "Record region"          media-record
              row "Record screen"          media-record
              row "Record region + audio"  audio-input-microphone
              row "Record screen + audio"  audio-input-microphone
            fi
          fi
          back
        } | rofi -dmenu -show-icons -markup-rows -p Capture) || exit 0
        dir="$HOME/Pictures/Screenshots"
        file="$dir/$(date +%Y%m%d-%H%M%S).png"
        case "$choice" in
          "$BACK")                     exec "$0" tools ;;
          *"Region → clipboard"*)      grim -g "$(slurp)" - | wl-copy ;;
          *"Region → file"*)           mkdir -p "$dir"; grim -g "$(slurp)" "$file" \
                                         && notify-send "Screenshot saved" "$file" ;;
          *"Annotate region"*)         mkdir -p "$dir"; grim -g "$(slurp)" - | satty --filename - --fullscreen --output-filename "$file" ;;
          *"OCR region"*)
            # Select → screenshot → tesseract → clipboard. Esc in slurp
            # cancels silently (the recording.nix pattern); recognizing
            # nothing toasts instead of copying an empty clipboard.
            geo=$(slurp) || exit 0
            txt=$(grim -g "$geo" - | tesseract stdin stdout 2>/dev/null)
            [ -n "''${txt//[[:space:]]/}" ] \
              || { notify-send "OCR" "No text recognized in that region."; exit 0; }
            printf '%s' "$txt" | wl-copy
            notify-send "OCR" "Copied $(printf '%s' "$txt" | wc -w) word(s) to the clipboard." ;;
          *"Full screen → clipboard"*) grim - | wl-copy ;;
          *"Full screen → file"*)      mkdir -p "$dir"; grim "$file" \
                                         && notify-send "Screenshot saved" "$file" ;;
          *"Stop recording"*)          exec nomarchy-record stop ;;
          *"Record region + audio"*)   exec nomarchy-record start region audio ;;
          *"Record screen + audio"*)   exec nomarchy-record start screen audio ;;
          *"Record region"*)           exec nomarchy-record start region ;;
          *"Record screen"*)           exec nomarchy-record start screen ;;
        esac ;;

      lookfeel)
        # Look & Feel — appearance grouped (item 19): the theme picker,
        # wallpaper cycling, night light. The root stays six rows; the
        # SUPER+T / SUPER+SHIFT+T direct binds still hit the leaves.
        choice=$( {
          row "Theme${menuHint "theme"}" preferences-desktop-theme
          row "Next wallpaper${hintForAction "exec, nomarchy-theme-sync bg next"}" preferences-desktop-wallpaper
          if systemctl --user is-active --quiet hyprsunset.service 2>/dev/null
          then row "Night light (on)"  weather-clear-night
          else row "Night light (off)" weather-clear-night
          fi
          back
        } | rofi -dmenu -show-icons -markup-rows -p "Look & Feel") || exit 0
        case "$choice" in
          "$BACK")           exec "$0" ;;
          *Theme*)           exec "$0" theme ;;
          *wallpaper*)       exec nomarchy-theme-sync bg next ;;
          *"Night light"*)   exec "$0" nightlight ;;
        esac ;;

      display-profile)
        # Named multi-output layouts (nomarchy.displayProfiles). Applying is
        # instant (hyprctl per entry, via nomarchy-display-profile) and
        # persisted in-flake (settings.displayProfile) so the next rebuild
        # bakes it. "Base layout" clears the state and hyprctl-reloads.
        command -v nomarchy-display-profile >/dev/null 2>&1 \
          || { notify-send "Display profiles" "None declared (nomarchy.displayProfiles)."; exit 0; }
        active=$(nomarchy-display-profile active)
        choice=$( {
          nomarchy-display-profile list | while IFS= read -r p; do
            if [ "$p" = "$active" ]; then printf '● %s\n' "$p"; else printf '○ %s\n' "$p"; fi
          done
          printf 'Base layout\n'
          if [ "$(nomarchy-theme-sync get settings.displayProfileAuto 2>/dev/null)" = true ]
          then printf 'Auto-switch (on)\n'
          else printf 'Auto-switch (off)\n'
          fi
          printf '%s\n' "$BACK"
        } | rofi -dmenu -p Profiles ) || exit 0
        case "$choice" in
          "$BACK")        exec "$0" display ;;
          "Base layout")  exec nomarchy-display-profile base ;;
          *"Auto-switch"*)
            # Instant in-flake flag; the watcher reads it live on the next
            # output change — no rebuild, no unit juggling.
            if [ "$(nomarchy-theme-sync get settings.displayProfileAuto 2>/dev/null)" = true ]; then
              nomarchy-theme-sync --quiet set settings.displayProfileAuto false --no-switch
              notify-send "Display profiles" "Auto-switch off — profiles change only from this menu."
            else
              nomarchy-theme-sync --quiet set settings.displayProfileAuto true --no-switch
              notify-send "Display profiles" "Auto-switch on — plugging/unplugging outputs picks the matching profile."
            fi
            exec "$0" display-profile ;;
          *)              exec nomarchy-display-profile apply "''${choice#[●○] }" ;;
        esac ;;

      colorpicker)
        # Pick a pixel's color (hyprpicker's zoom loupe) → clipboard, hex
        # in the confirmation toast. Esc/cancel exits silently. Self-gated
        # in the Tools row; guarded here too for direct invocation.
        command -v hyprpicker >/dev/null 2>&1 \
          || { notify-send "Color picker" "hyprpicker is not installed."; exit 0; }
        color=$(hyprpicker) || exit 0
        [ -n "$color" ] || exit 0
        printf '%s' "$color" | wl-copy
        notify-send "Color picker" "$color copied to the clipboard." ;;

      keybinds)
        # Read-only cheatsheet, generated from keybinds.nix at build time.
        # Enter on a row is a deliberate no-op (nothing to launch); ↩ Back
        # returns to the root picker, which is also where it's launched.
        sel=$( { cat ${cheatsheetFile}; printf '%s\n' "$BACK"; } \
          | rofi -dmenu -i -p keys ) || exit 0
        [ "$sel" = "$BACK" ] && exec "$0"
        exit 0 ;;

      ask)
        # Free-text question → claude CLI in a terminal (OAuth, no API key).
        # Pulled fresh from npm via npx rather than nixpkgs' claude-code,
        # which lags behind model releases; npx caches after first run. The
        # REPL stays open for follow-ups.
        q=$(rofi -dmenu -p claude < /dev/null) || exit 0
        [ -n "$q" ] || exit 0
        exec ${cfg.terminal} -e npx --yes @anthropic-ai/claude-code@latest "$q" ;;

      dnd)
        # Toggle swaync Do-Not-Disturb (-d prints the new state). With DND
        # on, notifications are suppressed — the Waybar bell-off icon is the
        # cue — so only confirm with a toast when turning it back off.
        [ "$(swaync-client -d)" = "false" ] \
          && notify-send "Do Not Disturb off" "Notifications resumed."
        exit 0 ;;

      nightlight)
        # Force the scheduled blue-light filter on/off for the session by
        # starting/stopping hyprsunset (nomarchy-nightlight, from nightlight.nix).
        # Self-gated in the menu; guard here too in case it's invoked directly.
        command -v nomarchy-nightlight >/dev/null 2>&1 \
          && exec nomarchy-nightlight toggle
        notify-send "Night light" "Not available (nomarchy.nightlight off?)."; exit 0 ;;

      autotimezone)
        # Toggle automatic timezone detection (geoclue + automatic-timezoned).
        # A SYSTEM service, so flipping it writes the in-flake flag and runs a
        # system rebuild (sudo) + a home switch — in a terminal, like Snapshots.
        command -v nomarchy-autotimezone >/dev/null 2>&1 \
          || { notify-send "Auto timezone" "Unavailable on this machine."; exit 0; }
        exec ${cfg.terminal} -e nomarchy-autotimezone toggle ;;

      autocommit)
        # Toggle opt-in auto-commit: every menu/theme mutation also commits
        # theme-state.json (that file only) in the downstream flake, so
        # settings history is `git log`. Nothing in Nix consumes the flag —
        # the tool reads the live state on each write — so the toggle is
        # instant, no rebuild. The off-write commits too (the tool fires
        # when the flag was on before OR after), keeping history consistent.
        if [ "$(nomarchy-theme-sync get settings.autoCommit 2>/dev/null)" = true ]; then
          nomarchy-theme-sync --quiet set settings.autoCommit false --no-switch
          notify-send "Auto-commit" "Off — menu changes stay uncommitted in your flake."
        else
          nomarchy-theme-sync --quiet set settings.autoCommit true --no-switch
          notify-send "Auto-commit" "On — each change commits theme-state.json in your flake."
        fi
        exit 0 ;;

      snapshot)
        # Snapshot browse/restore — the btrfs-assistant GUI via its pkexec
        # launcher. The "2.2 segfaults on 26.05" diagnosis was HALF right:
        # only UNPRIVILEGED runs crash (libbtrfsutil unprivileged subvolume
        # iteration, btrfs-progs 6.17.1; fixed upstream after) — as root it
        # runs fine, and the launcher runs it as root. The prompt needs the
        # session polkit agent (hyprpolkitagent, exec-once in hyprland.nix).
        # Fallback: nomarchy-snapshots, the keyboard-driven fzf browser in a
        # terminal (kept installed — also nice over SSH).
        # Self-gated to nomarchy.system.snapper, so it no-ops elsewhere.
        command -v btrfs-assistant-launcher >/dev/null 2>&1 \
          && exec btrfs-assistant-launcher
        command -v nomarchy-snapshots >/dev/null 2>&1 \
          || { notify-send "Snapshots" "Snapshot tools unavailable (nomarchy.system.snapper off?)."; exit 0; }
        exec ${cfg.terminal} -e sudo nomarchy-snapshots ;;

      doctor)
        # Read-only health sheet in a terminal; exit code doesn't matter
        # here — the sheet itself is the product.
        exec ${cfg.terminal} -e sh -c "nomarchy-doctor
          printf '\nEnter to close.'; read -r _" ;;

      firmware)
        # System ▸ Firmware — fwupd/LVFS update surface (HARDWARE.md §4).
        # Never auto-flashes: refresh metadata, list what LVFS offers, then
        # hand to `fwupdmgr update`, which confirms each device and prompts
        # for the reboot a capsule needs (pillar 1 — no silent BIOS writes).
        # Self-gated on fwupdmgr in System; guard here too.
        command -v fwupdmgr >/dev/null 2>&1 \
          || { notify-send "Firmware" "fwupd not available (services.fwupd.enable off?)."; exit 0; }
        exec ${cfg.terminal} -e sh -c '
          echo "== Refreshing firmware metadata (LVFS) =="
          fwupdmgr refresh || true
          echo
          echo "== Firmware updates available =="
          if fwupdmgr get-updates; then
            echo
            printf "Apply these updates? fwupd confirms each device. [y/N] "
            read -r ans
            case "$ans" in
              [yY]*) fwupdmgr update ;;
              *)     echo "Cancelled — no firmware written." ;;
            esac
          else
            echo "Nothing to apply on this machine."
          fi
          printf "\nEnter to close."; read -r _' ;;

      fingerprint)
        # System ▸ Fingerprint — enroll/list/delete + optional PAM for
        # login/sudo (HARDWARE.md §5, BACKLOG #55). Self-gated on fprintd
        # CLI (services.fprintd). Enroll is interactive → terminal.
        command -v fprintd-list >/dev/null 2>&1 \
          || { notify-send "Fingerprint" "fprintd not available (nomarchy.hardware.fingerprint.enable?)."; exit 0; }
        pam=$(nomarchy-theme-sync get settings.fingerprint.pam 2>/dev/null || echo false)
        case "$pam" in true|True) pam_label="Use for login (on)" ;; *) pam_label="Use for login (off)" ;; esac
        choice=$( {
          row "Enroll finger"     preferences-desktop-user-password
          row "List enrolled"     view-list
          row "Verify"            dialog-password
          row "Delete all"        edit-delete
          row "$pam_label"        system-lock-screen
          back
        } | rofi -dmenu -show-icons -p Fingerprint) || exit 0
        case "$choice" in
          "$BACK") exec "$0" system ;;
          *Enroll*)
            exec ${cfg.terminal} -e sh -c '
              echo "== Enroll fingerprint for $USER =="
              fprintd-enroll || true
              printf "\nEnter to close."; read -r _' ;;
          *List*)
            exec ${cfg.terminal} -e sh -c '
              echo "== Enrolled fingers for $USER =="
              fprintd-list "$USER" || true
              printf "\nEnter to close."; read -r _' ;;
          *Verify*)
            exec ${cfg.terminal} -e sh -c '
              echo "== Verify fingerprint for $USER =="
              fprintd-verify || true
              printf "\nEnter to close."; read -r _' ;;
          *"Delete all"*)
            exec ${cfg.terminal} -e sh -c '
              echo "== Delete all fingerprints for $USER =="
              printf "Type yes to delete every enrolled finger: "
              read -r ans
              case "$ans" in
                yes) fprintd-delete "$USER" || true ;;
                *)   echo "Cancelled." ;;
              esac
              printf "\nEnter to close."; read -r _' ;;
          *"Use for login"*)
            case "$pam" in true|True) new=false ;; *) new=true ;; esac
            if [ "$new" = true ]; then
              if fprintd-list "$USER" 2>/dev/null | grep -qiE 'no fingers enrolled|No devices available'; then
                notify-send "Fingerprint" "Enroll a finger first, then enable login."
                exit 0
              fi
            fi
            nomarchy-theme-sync --quiet set settings.fingerprint.pam "$new" --no-switch
            notify-send "Fingerprint" "Use for login: $new — applies on next sys-rebuild."
            ;;
        esac ;;

      controlcenter)
        # Nomarchy TUI Control Center.
        exec ${cfg.terminal} -e nomarchy-control-center ;;

      rollback)
        # Undo, one menu away (informative + rock-stable). Desktop =
        # Home Manager generations: theme/config history is already one
        # generation per change, and running an older generation's
        # activate script is the supported HM rollback — reversible by
        # activating a newer one again, so no typed-yes gate here.
        # System-level undo deliberately LINKS OUT instead of putting
        # destructive flows in rofi: boot-menu generations and snapper
        # rollback have their own homes and gates (docs/RECOVERY.md,
        # nomarchy-snapshots).
        gens=$(home-manager generations 2>/dev/null | head -10)
        choice=$( {
          if [ -n "$gens" ]; then
            printf '%s\n' "$gens" | awk '{
              printf "Desktop gen %s — %s %s%s\n", $5, $1, $2, (NR==1 ? "  (current)" : "")
            }' | while IFS= read -r line; do row "$line" document-open-recent; done
          fi
          command -v nomarchy-snapshots >/dev/null 2>&1 \
            && row "System files → Snapshots" timeshift
          row "System config → boot an older generation (how)" help-about
          back
        } | rofi -dmenu -show-icons -p Rollback) || exit 0
        case "$choice" in
          "$BACK") exec "$0" system ;;
          "Desktop gen "*)
            num=''${choice#Desktop gen }; num=''${num%% *}
            path=$(printf '%s\n' "$gens" | awk -v n="$num" '$5 == n { print $7 }')
            [ -x "$path/activate" ] \
              || { notify-send "Rollback" "Generation $num not found on disk."; exit 1; }
            exec ${cfg.terminal} -e sh -c "
              echo \"Activating Home Manager generation $num…\"; echo
              \"$path/activate\"
              echo; echo \"Desktop rolled back to generation $num.\"
              echo \"(Roll forward the same way — newer generations stay listed.)\"
              printf 'Enter to close.'; read -r _" ;;
          *Snapshots*) exec "$0" snapshot ;;
          *"older generation"*)
            notify-send "System rollback" "Reboot and pick an older NixOS generation in the boot menu (the last 10 are kept). Make it stick: revert the change in ~/.nomarchy, then sys-rebuild. Details: docs/RECOVERY.md §3." ;;
        esac ;;

      tools)
        # Tools submenu — utilities you invoke. Each leaf is also reachable
        # directly (SUPER+CTRL+<mnemonic>); this just keeps the root short.
        choice=$( {
          # Live ISO only: nomarchy-install is systemPackages on the live
          # host (BACKLOG #57). Installed systems never ship the package.
          command -v nomarchy-install >/dev/null 2>&1 \
            && row "Install Nomarchy" system-software-install
          row "Calculator${menuHint "calc"}" accessories-calculator
          row "Clipboard${menuHint "clipboard"}"  edit-paste
          row "Emoji${menuHint "emoji"}"      face-smile-big
          row "Files${menuHint "files"}"      system-file-manager
          row "Web search${menuHint "web"}" system-search
          row "Capture${menuHint "capture"}"    applets-screenshooter
          command -v hyprpicker >/dev/null 2>&1 && row "Color picker${menuHint "colorpicker"}" preferences-desktop-color
          row "Ask Claude${menuHint "ask"}" internet-chat
          back
        } | rofi -dmenu -show-icons -markup-rows -p Tools) || exit 0
        case "$choice" in
          "$BACK")     exec "$0" ;;
          *"Install Nomarchy"*)
            command -v nomarchy-install >/dev/null 2>&1 \
              && exec ${cfg.terminal} -e nomarchy-install
            notify-send "Install" "nomarchy-install is not on PATH (live ISO only)."
            exit 0 ;;
          *Calc*)      exec "$0" calc ;;
          *Clipboard*) exec "$0" clipboard ;;
          *Emoji*)     exec "$0" emoji ;;
          *Files*)     exec "$0" files ;;
          *Web*)       exec "$0" web ;;
          *Capture*)   exec "$0" capture ;;
          *Color*)     exec "$0" colorpicker ;;
          *Ask*)       exec "$0" ask ;;
        esac ;;

      system)
        # System submenu — connectivity and machine state. Snapshots and the
        # power-profile picker self-gate (BTRFS+snapper / laptop on ppd), so
        # the submenu shrinks to match the hardware, like the Waybar modules.
        choice=$( {
          row "Network${menuHint "network"}"        network-wireless
          row "VPN"            network-vpn
          command -v blueman-manager >/dev/null 2>&1 \
            && row "Bluetooth${menuHint "bluetooth"}"      bluetooth
          [ -e "''${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/pulse/native" ] \
            && row "Audio" audio-volume-high
          row "Display" video-display
          command -v system-config-printer >/dev/null 2>&1 && row "Printers" printer
          row "Do Not Disturb${menuHint "dnd"}" notification-disabled
          if [ "$(nomarchy-theme-sync get settings.autoTimezone 2>/dev/null)" = true ]
          then row "Auto timezone (on)"  preferences-system-time
          else row "Auto timezone (off)" preferences-system-time
          fi
          if [ -e "''${NOMARCHY_PATH:-$HOME/.nomarchy}/.git" ]; then
            if [ "$(nomarchy-theme-sync get settings.autoCommit 2>/dev/null)" = true ]
            then row "Auto-commit (on)"  git
            else row "Auto-commit (off)" git
            fi
          fi
          command -v nomarchy-snapshots >/dev/null 2>&1 && row "Snapshots" timeshift
          row "Rollback" edit-undo
          command -v nomarchy-doctor >/dev/null 2>&1 && row "Doctor" utilities-system-monitor
          # Firmware/LVFS updates — self-gated on fwupd (default-on, but off
          # on VMs/headless via services.fwupd.enable=false), like Printers.
          command -v fwupdmgr >/dev/null 2>&1 && row "Firmware" firmware-manager
          # Fingerprint — self-gated on fprintd CLI (services.fprintd; BACKLOG #55).
          command -v fprintd-list >/dev/null 2>&1 && row "Fingerprint" preferences-desktop-user-password
          command -v nomarchy-control-center >/dev/null 2>&1 && row "Control Center" preferences-desktop
          if has_system_battery && command -v powerprofilesctl >/dev/null 2>&1; then
            row "Power profile" preferences-system-power
          fi
          # Self-gated on the charge-threshold sysfs node (laptops that
          # expose it), like Power profile above.
          has_charge_threshold && row "Battery limit" battery-080
          back
        } | rofi -dmenu -show-icons -markup-rows -p System) || exit 0
        case "$choice" in
          "$BACK")            exec "$0" ;;
          *Network*)          exec "$0" network ;;
          *VPN*)              exec "$0" vpn ;;
          *Bluetooth*)        exec "$0" bluetooth ;;
          *Audio*)            exec "$0" audio ;;
          *Display*)          exec "$0" display ;;
          *Printers*)         exec "$0" printers ;;
          *"Do Not Disturb"*) exec "$0" dnd ;;
          *"Auto timezone"*)  exec "$0" autotimezone ;;
          *"Auto-commit"*)    exec "$0" autocommit ;;
          *Snapshots*)        exec "$0" snapshot ;;
          *Rollback*)         exec "$0" rollback ;;
          *Doctor*)           exec "$0" doctor ;;
          *Firmware*)         exec "$0" firmware ;;
          *Fingerprint*)      exec "$0" fingerprint ;;
          *"Control Center"*) exec "$0" controlcenter ;;
          *"Power profile"*)  exec "$0" power-profile ;;
          *"Battery limit"*)  exec "$0" batterylimit ;;
        esac ;;

      batterylimit)
        # Battery charge-limit preset picker — the rofi-System counterpart of
        # the control-center's toggle (Bernardo: he wanted this in the menu,
        # not buried in the gum TUI). Writes the baked NixOS option
        # settings.power.batteryChargeLimit via theme-sync (--no-switch), so
        # it lands on the next sys-rebuild — the sysfs oneshot in power.nix
        # owns applying it (boot + AC-replug). Self-gated in System; guard
        # here too in case it's invoked directly.
        has_charge_threshold \
          || { notify-send "Battery limit" "No charge-threshold control on this machine."; exit 0; }
        cur=$(nomarchy-theme-sync get settings.power.batteryChargeLimit 2>/dev/null)
        sel=$( {
          row "80% (recommended)"    battery-080
          row "90%"                  battery-090
          row "60%"                  battery-060
          row "Off — charge to 100%" battery-100
          row "Custom…"              battery
          back
        } | rofi -dmenu -show-icons -p "Battery limit (now: ''${cur:-default})") || exit 0
        set_limit() {
          nomarchy-theme-sync --quiet set settings.power.batteryChargeLimit "$1" --no-switch
          notify-send "Battery limit" "$2 — applies on the next rebuild."
        }
        case "$sel" in
          "$BACK")   exec "$0" system ;;
          "80%"*)    set_limit 80 "Set to 80%" ;;
          "90%"*)    set_limit 90 "Set to 90%" ;;
          "60%"*)    set_limit 60 "Set to 60%" ;;
          "Off"*)    set_limit null "Off — charges to 100%" ;;
          "Custom"*)
            limit=$(rofi -dmenu -p "Charge limit % (50–100)" < /dev/null) || exit 0
            [ -n "$limit" ] || exit 0
            case "$limit" in
              *[!0-9]*) notify-send "Battery limit" "Not a whole number: $limit"; exit 0 ;;
            esac
            if [ "$limit" -ge 50 ] && [ "$limit" -le 100 ]; then
              set_limit "$limit" "Set to $limit%"
            else
              notify-send "Battery limit" "Out of range (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 &amp; Feel" preferences-desktop-theme
          row "Tools"       applications-utilities
          row "System"      preferences-system
          row "Power${menuHint "power"}"       system-shutdown
          row "Keybindings${menuHint "keybinds"}" preferences-desktop-keyboard
        } | rofi -dmenu -show-icons -markup-rows -p Menu) || exit 0
        case "$choice" in
          *Apps*)        exec rofi -show drun -theme launcher ;;
          *Look*)        exec "$0" lookfeel ;;
          *Tools*)       exec "$0" tools ;;
          *System*)      exec "$0" system ;;
          *Power*)       exec "$0" power ;;
          *Keybindings*) exec "$0" keybinds ;;
        esac ;;

      *)
        echo "usage: nomarchy-menu [lookfeel|tools|system|power|power-profile|powermgmt|batterylimit|theme|clipboard|calc|files|emoji|web|network|bluetooth|audio|display|display-profile|printers|capture|colorpicker|keybinds|ask|dnd|nightlight|autotimezone|autocommit|vpn|snapshot|doctor|firmware|fingerprint|controlcenter|rollback]" >&2
        exit 64 ;;
    esac
  '';

  # VPN setup + management — a dedicated System submenu (and the custom/vpn
  # Waybar indicator's click target). Goes past networkmanager_dmenu (which
  # only connects existing VPNs) to importing configs and driving Tailscale:
  #   · NetworkManager VPN/WireGuard connections, ● active / ○ inactive, toggled
  #     up/down (networkmanager-group users need no sudo).
  #   · Import a WireGuard .conf or OpenVPN .ovpn (nmcli import; OpenVPN needs the
  #     networkmanager-openvpn plugin, shipped system-side).
  #   · Tailscale (self-gated on the CLI being present = nomarchy.services.tailscale,
  #     which makes the login user the operator): status is read-only; up/down/
  #     exit-node run inline without sudo thanks to the operator grant, falling
  #     back to a sudo terminal if it's absent. First login goes to a terminal.
  # Secrets stay in NetworkManager's / Tailscale's own store — no new manager.
  nomarchy-vpn = pkgs.writeShellScriptBin "nomarchy-vpn" ''
    set -u
    BACK="↩ Back"

    conn_rows() {
      nmcli -t -f NAME,TYPE,STATE connection show 2>/dev/null \
        | awk -F: '$2=="vpn"||$2=="wireguard"{ printf "%s %s\n", ($3=="activated"?"●":"○"), $1 }'
    }

    toggle_conn() {
      mark=''${1%% *}; name=''${1#* }
      if [ "$mark" = "●" ]; then
        nmcli connection down "$name" >/dev/null 2>&1 \
          && notify-send "VPN" "Disconnected $name" || notify-send "VPN" "Could not disconnect $name"
      else
        nmcli connection up "$name" >/dev/null 2>&1 \
          && notify-send "VPN" "Connected $name" || notify-send "VPN" "Could not connect $name (auth or bad config?)"
      fi
    }

    import_config() {
      file=$( { fd -e conf -e ovpn . "$HOME" --type f --hidden \
                  --exclude .git --exclude .cache 2>/dev/null \
                | sed "s|^$HOME/||"; printf '%s\n' "$BACK"; } \
              | rofi -dmenu -p "Import VPN config" ) || return
      { [ -z "$file" ] || [ "$file" = "$BACK" ]; } && return
      case "$file" in *.ovpn) type=openvpn ;; *) type=wireguard ;; esac
      if nmcli connection import type "$type" file "$HOME/$file" >/dev/null 2>&1; then
        notify-send "VPN" "Imported $type config: $(basename "$file")"
      else
        notify-send "VPN" "Import failed ($type plugin missing, or bad file?)"
      fi
    }

    ts_state() { tailscale status --json 2>/dev/null | jq -r '.BackendState // "Unknown"'; }
    # Run a privileged tailscale subcommand. nomarchy.services.tailscale makes
    # the login user the operator, so this runs inline (no sudo); if the operator
    # grant is absent (downstream dropped it) it falls back to a sudo terminal.
    ts_priv() {
      if tailscale "$@" >/dev/null 2>&1; then notify-send "Tailscale" "tailscale $*"
      else ${cfg.terminal} -e sudo tailscale "$@"; fi
    }
    ts_exit_node() {
      node=$( { printf 'none\n'; tailscale exit-node list 2>/dev/null | awk 'NR>1 && $2 {print $2}'; \
                printf '%s\n' "$BACK"; } | rofi -dmenu -p "Exit node" ) || return
      { [ -z "$node" ] || [ "$node" = "$BACK" ]; } && return
      [ "$node" = none ] && node=""
      ts_priv set --exit-node="$node"
    }
    tailscale_menu() {
      while :; do
        st=$(ts_state)
        choice=$( {
          if [ "$st" = Running ]; then printf 'Disconnect\n'; else printf 'Connect\n'; fi
          printf 'Exit node…\n'
          printf '%s\n' "$BACK"
        } | rofi -dmenu -p "Tailscale ($st)" ) || return
        case "$choice" in
          "$BACK"|"")   return ;;
          # Authed-but-down → bring it up inline; otherwise it needs an
          # interactive login, so show it in a terminal (URL visible).
          Connect)      if [ "$st" = Stopped ]; then ts_priv up; else ${cfg.terminal} -e sudo tailscale up; fi ;;
          Disconnect)   ts_priv down ;;
          "Exit node…") ts_exit_node ;;
        esac
      done
    }

    while :; do
      have_ts=""
      command -v tailscale >/dev/null 2>&1 && have_ts=1
      choice=$( {
        conn_rows
        printf 'Import config…\n'
        [ -n "$have_ts" ] && printf 'Tailscale ›\n'
        printf '%s\n' "$BACK"
      } | rofi -dmenu -p VPN ) || exit 0
      case "$choice" in
        "$BACK"|"")       exec nomarchy-menu system ;;
        "Import config…") import_config ;;
        "Tailscale ›")    tailscale_menu ;;
        "● "*|"○ "*)      toggle_conn "$choice" ;;
      esac
    done
  '';
in
{
  config = lib.mkIf cfg.rofi.enable {
    home.packages = [
      nomarchy-menu
      nomarchy-vpn               # VPN submenu (NM connect/import + Tailscale)
      pkgs.fd                    # files module (fuzzy search over $HOME)
      pkgs.xdg-utils             # xdg-open for the files + web modules
      pkgs.nodejs                # npx, for the Ask Claude module (claude-code from npm)
      pkgs.networkmanager_dmenu  # network module: rofi wifi/VPN picker
      pkgs.rofi-pulse-select     # audio module: sink/source switcher
      # Capture › OCR region: English only — the unscoped wrapper drags
      # in every language's traineddata, a real cost on a compile-from-
      # source distro. More languages: override in your home.nix.
      (pkgs.tesseract.override { enableLanguages = [ "eng" ]; })
    ];

    # networkmanager_dmenu drives rofi (not bare dmenu) and uses rofi's
    # active/urgent row styling for the connected/available networks, so it
    # inherits the generated theme like every other menu module. Editing a
    # connection drops to nmtui in the configured terminal. force: the tool
    # writes a default config on first run, so an unmanaged file may already
    # sit at this path — we fully own it, so overwrite rather than abort.
    # compact: with False the script inserts EMPTY rows as section
    # separators (eth/wifi/vpn) — blank entries in a themed picker read as
    # breakage (item 22). True drops them and uses the unpadded row format,
    # which also sits better in rofi's proportional font than the
    # space-aligned columns. (A hidden-SSID AP still renders one nameless
    # row — sec+bars only; not filterable via config, dedup keeps one.)
    xdg.configFile."networkmanager-dmenu/config.ini" = {
      force = true;
      text = ''
        [dmenu]
        dmenu_command = rofi
        rofi_highlight = True
        compact = True
        wifi_chars = ▂▄▆█

        [editor]
        terminal = ${cfg.terminal}
        gui_if_available = False
      '';
    };

    # The launcher theme lives beside HM's active base theme, which it
    # @imports so the default launcher is byte-for-byte that theme's list.
    # HM names that base by its SOURCE: a generated (attrset) theme lands as
    # custom.rasi, but a whole-swap theme (a path — themes/<slug>/rofi.rasi)
    # keeps its basename, rofi.rasi. Import must match, or `-theme launcher`
    # dies with "custom.rasi not found" (rofi resolves the missing import
    # against $HOME) on every whole-swap theme.
    xdg.dataFile."rofi/themes/launcher.rasi".text =
      if hasLauncherOverride
      then builtins.readFile launcherOverride
      else ''@import "${if hasRasiOverride then "rofi.rasi" else "custom.rasi"}"'';

    programs.rofi = {
      enable = true;
      terminal = cfg.terminal;
      font = "${t.fonts.ui} ${toString t.fonts.size}";

      # Native rofi modi backing two menu modules: calc (live, via
      # libqalculate — no qalc CLI needed) and emoji (glyph picker, copies
      # via its bundled Wayland adapter). Both render through the
      # generated/whole-swap theme like every other modi.
      plugins = [ pkgs.rofi-calc pkgs.rofi-emoji ];

      extraConfig = {
        modi = "drun,run";
        show-icons = true;          # app icons via the theme's icon set
        icon-theme = t.iconTheme;   # resolved in theme.nix (Papirus-*/per-theme)
        drun-display-format = "{name}";
        display-drun = "apps";
        display-run = "run";
        # Size the window to the actual number of rows instead of always
        # reserving `lines` (8) — so a 6-entry menu doesn't leave empty space,
        # while the app launcher still fills + scrolls past `lines`.
        fixed-num-lines = false;
        # Search: case-insensitive fuzzy across every menu + the launcher, so
        # "system" finds "System" and "fzr" finds "Firefox". `normal` sorting
        # ranks by match distance so the closest match lands on top (the
        # per-keystroke modules — calc/emoji — opt out per-invocation with
        # -no-sort).
        #
        # NOT `fzf`: rofi 2.0.0's fzf sorter mis-ranks with fuzzy matching —
        # typing "steam" left ghostty selected on top instead of Steam
        # (confirmed on hardware: `-sorting-method normal` and `-matching
        # normal` both fixed it, `-no-sort` did not). Keep fuzzy matching for
        # forgiveness; use the normal sorter to rank it (item 31).
        matching = "fuzzy";
        case-sensitive = 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";
          };
        };
    };
  };
}
