All checks were successful
Check / eval (push) Successful in 3m57s
Bernardo: "opening claude-code is too disruptive". SUPER+CTRL+A spent a terminal, an npm fetch and a REPL on "what's the syntax for…" — the prompt was the right surface, the destination was not. The query is now URL-encoded into the provider's web chat and handed to xdg-open. settings.ask.provider (claude|chatgpt|gemini|grok, default claude) with a Preferences row, read from state at RUN TIME so switching needs no rebuild, and any unknown/absent value falls back rather than opening nothing. He asked for chatgpt + grok alongside claude/gemini, and for the CLI to go: claude-code is deleted and pkgs.nodejs leaves with it (carried only for that npx). Keybind is "Ask AI" now the destination is the user's. The failure mode is the design point: prefill is undocumented on all four vendors, each can drop `?q=` unilaterally, and it fails SILENTLY — you land in an empty chat having already typed the question. So the query is also copied to the clipboard (a vendor breaking change costs one paste, not the question), and the URLs sit in one attrset so the repair is one line. Verified against the generated script: bash -n clean; every bad state value (unset/null/garbage) falls back to claude; a hostile query encodes correctly (& → %26, ? → %3F, # → %23, $(whoami) neutralised, café → caf%C3%A9). NOT verified on purpose: whether each vendor fills the box or auto-submits — that means posting into Bernardo's own accounts. Sweep: ROADMAP's shipped-modules line described the npx REPL; nix-ld's comment cited claude-code as its example; both fixed. Menu row + keybind renamed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1955 lines
96 KiB
Nix
1955 lines
96 KiB
Nix
# rofi (2.0, native Wayland on 26.05) — launcher + dmenu renderer for
|
||
# the menu system, themed from state.json. rofi's .rasi is far more
|
||
# expressive than a flat scheme, so the generated theme styles each
|
||
# element (accent border, highlighted selection, rounded inputbar); a
|
||
# themes/<slug>/rofi.rasi whole-swap replaces it entirely.
|
||
#
|
||
# Home of nomarchy-menu, the dispatcher all menu modules hang off.
|
||
{ config, lib, pkgs, ... }:
|
||
|
||
let
|
||
cfg = config.nomarchy;
|
||
t = cfg.theme;
|
||
c = t.colors;
|
||
termSheet = import ./term-sheet.nix { inherit pkgs; };
|
||
|
||
# Ask ▸ web chat (#140). One table, because these URLs are the vendors' to
|
||
# change: prefill is undocumented on every one of them, so when a `?q=`
|
||
# stops working the fix must be a one-line edit here and nothing else.
|
||
# Claude and ChatGPT are the well-attested pair; Gemini and Grok are
|
||
# best-effort — hence the clipboard fallback in the `ask` handler, which is
|
||
# what keeps a dropped parameter from costing the user their question.
|
||
askProviders = {
|
||
claude = "https://claude.ai/new?q=";
|
||
chatgpt = "https://chatgpt.com/?q=";
|
||
gemini = "https://gemini.google.com/app?q=";
|
||
grok = "https://grok.com/?q=";
|
||
};
|
||
askDefaultProvider = "claude";
|
||
inherit (config.lib.formats.rasi) mkLiteral;
|
||
|
||
px = n: mkLiteral "${toString n}px";
|
||
|
||
# Per-theme override probe (same convention as waybar.css).
|
||
rasiOverride = cfg.themesDir + "/${t.slug}/rofi.rasi";
|
||
hasRasiOverride = builtins.pathExists rasiOverride;
|
||
|
||
# App-launcher theme (SUPER+Space → `rofi -show drun -theme launcher`).
|
||
# Kept separate from the base theme so a theme can give the launcher its
|
||
# own layout (e.g. Boreal's icon grid) WITHOUT that layout leaking into
|
||
# the text menus, which share the base theme and need a list. The default
|
||
# just re-imports the active base theme (custom.rasi for generated themes,
|
||
# rofi.rasi for whole-swaps — see the dataFile below), so every other
|
||
# theme's launcher is byte-for-byte its list.
|
||
launcherOverride = cfg.themesDir + "/${t.slug}/launcher.rasi";
|
||
hasLauncherOverride = builtins.pathExists launcherOverride;
|
||
|
||
# ── Visual theme picker ──────────────────────────────────────────────
|
||
# Every preset (themes/<slug>.json) is read at eval time to build a grid
|
||
# of real desktop previews. Grouped dark-first then light — the previews
|
||
# make the mode obvious at a glance, so no light/dark submenu split — and
|
||
# the active theme is marked ✓. "Active" is just t.slug: every switch
|
||
# rebuilds this script, so the baked-in mark always tracks the live theme.
|
||
themeFiles = lib.filterAttrs
|
||
(n: ty: ty == "regular" && lib.hasSuffix ".json" n)
|
||
(builtins.readDir cfg.themesDir);
|
||
themeData = lib.mapAttrsToList (fname: _:
|
||
let
|
||
j = builtins.fromJSON (builtins.readFile (cfg.themesDir + "/${fname}"));
|
||
slug = j.slug or (lib.removeSuffix ".json" fname);
|
||
in {
|
||
inherit slug;
|
||
name = j.name or slug;
|
||
mode = j.mode or "dark";
|
||
hasPreview = builtins.pathExists (cfg.themesDir + "/${slug}/preview.png");
|
||
}) themeFiles;
|
||
|
||
byName = lib.sort (a: b: a.name < b.name);
|
||
orderedThemes =
|
||
byName (lib.filter (th: th.mode != "light") themeData) # dark group
|
||
++ byName (lib.filter (th: th.mode == "light") themeData); # then light
|
||
|
||
# rofi's element-icon cells are SQUARE, so a 16:9 preview would letterbox
|
||
# (theme-coloured bands top/bottom). Build-time crop each committed 480×270
|
||
# preview to a centred square so it fills the cell edge-to-edge. The source
|
||
# preview.png stays 480×270 (untouched) — only the displayed thumb is square.
|
||
themeThumbs = pkgs.runCommand "nomarchy-theme-thumbs"
|
||
{ nativeBuildInputs = [ pkgs.imagemagick ]; } ''
|
||
mkdir -p $out
|
||
${lib.concatMapStringsSep "\n"
|
||
(th: lib.optionalString th.hasPreview ''
|
||
magick ${cfg.themesDir + "/${th.slug}/preview.png"} \
|
||
-resize 360x360^ -gravity center -extent 360x360 -strip $out/${th.slug}.png
|
||
'')
|
||
orderedThemes}
|
||
'';
|
||
|
||
# One grid row per theme (square thumb + name, ✓ on the active one), and a
|
||
# Name→slug map so the picked label resolves back to the preset that
|
||
# nomarchy-state-sync applies (pretty names ≠ slugs, hence the map). Themes
|
||
# with no preview degrade to a plain-name row.
|
||
themeRows = lib.concatMapStringsSep "\n" (th:
|
||
let label = th.name + lib.optionalString (th.slug == t.slug) " ✓";
|
||
in if th.hasPreview
|
||
then "row ${lib.escapeShellArg label} ${themeThumbs}/${th.slug}.png"
|
||
else "printf '%s\\n' ${lib.escapeShellArg label}")
|
||
orderedThemes;
|
||
themeSlugMap = lib.concatMapStringsSep "\n" (th:
|
||
" [${lib.escapeShellArg th.name}]=${lib.escapeShellArg th.slug}")
|
||
orderedThemes;
|
||
|
||
# Per-invocation grid layout (cards: a 16:9 preview above a centered name),
|
||
# overriding the single-column list theme just for this menu.
|
||
# · The icon box is sized 16:9 (rofi 2.0 takes a two-value `size`) to
|
||
# match the 480×270 previews exactly — they fill it with no letterbox.
|
||
# · The window is sized to the *content* (3 × icon + paddings), not a
|
||
# percentage, so a column is no wider than its card — otherwise the
|
||
# selection highlight balloons out around the image.
|
||
# · flow: horizontal lays cards out row-by-row (rofi's default Vertical
|
||
# fills column-by-column, so Down at a column's foot jumped to the top
|
||
# of the next column instead of scrolling the page).
|
||
# Grid dial — the previews are as big as the icon px allows; screen *height*
|
||
# caps it, so showing 2 rows (not 3) lets each card grow a lot. Scrolling
|
||
# reaches the rest. Tune themeGridIconW for size; the window resizes to fit.
|
||
# rofi's element-icon `size` is a SINGLE value (the manual only documents
|
||
# one) — a two-value "WxH" is silently collapsed, which is why the preview
|
||
# used to render tiny inside a too-wide column. So: size = the card *width*,
|
||
# rofi fits the 16:9 image tight to it (no square letterbox), and the window
|
||
# is sized so a column is exactly that width — the preview fills the cell.
|
||
themeGridCols = 3;
|
||
themeGridLines = 3;
|
||
themeGridIconW = 240; # square preview card side (px) — the size knob
|
||
themeGridPad = 0; # margin around the preview inside its cell (px)
|
||
themeGridGap = 8; # gap between cards (px)
|
||
themeGridThemeStr = lib.escapeShellArg (lib.concatStringsSep " " [
|
||
# window width = cards + their padding + inter-card gaps + chrome (~20px)
|
||
"window { width: ${toString (themeGridCols * (themeGridIconW + 2 * themeGridPad) + (themeGridCols - 1) * themeGridGap + 20)}px; }"
|
||
"listview { columns: ${toString themeGridCols}; lines: ${toString themeGridLines}; spacing: ${toString themeGridGap}px; flow: horizontal; }"
|
||
"element { orientation: vertical; padding: ${toString themeGridPad}px; spacing: 2px; }"
|
||
"element-icon { size: ${toString themeGridIconW}px; }"
|
||
"element-text { horizontal-align: 0.5; }"
|
||
]);
|
||
|
||
# Keybindings cheatsheet (SUPER+? → the `keybinds` menu module). Built
|
||
# from the SAME ./keybinds.nix that hyprland.nix binds, so it can never
|
||
# drift from the live shortcuts. "$mod" reads as SUPER; rows are padded
|
||
# into two columns. extra[] carries the generated/mouse binds.
|
||
keybinds = import ./keybinds.nix;
|
||
padRight = w: s:
|
||
let n = w - builtins.stringLength s;
|
||
in s + (if n > 0 then lib.concatStrings (builtins.genList (_: " ") n) else " ");
|
||
prettyKeys = b:
|
||
let m = lib.replaceStrings [ "$mod" ] [ "SUPER" ] b.mods;
|
||
prefix = if m == "" then "" else lib.concatStringsSep " + " (lib.splitString " " m) + " + ";
|
||
key = lib.replaceStrings
|
||
[ "question" "slash" "left" "right" "up" "down" ]
|
||
[ "?" "/" "←" "→" "↑" "↓" ] b.key;
|
||
# The bind is `SHIFT + slash` (base keysym; keybinds.nix), which
|
||
# renders "SHIFT + /". `?` IS Shift+/, so collapse that back to the
|
||
# single documented glyph — the cheatsheet reads "SUPER + ?".
|
||
in lib.replaceStrings [ "SHIFT + /" ] [ "?" ] (prefix + key);
|
||
# Grouped cheatsheet (#108): Window → Workspace → Menu → Media.
|
||
# Section headers are non-selectable chrome (rofi still shows them as rows;
|
||
# Enter is a no-op either way). Order matches the group enum.
|
||
cheatGroupOrder = [ "Window" "Workspace" "Menu" "Media" ];
|
||
cheatBindRows =
|
||
map (b: {
|
||
inherit (b) group desc;
|
||
keys = prettyKeys b;
|
||
}) (keybinds.binds
|
||
++ lib.optionals (lib.hasInfix "," cfg.keyboard.layout)
|
||
keybinds.multiLayoutBinds)
|
||
# Launch-or-focus binds (nomarchy.launchOrFocus, generated in
|
||
# hyprland.nix) — same renderer, so they land in the cheatsheet too.
|
||
++ map (e: {
|
||
group = "Menu";
|
||
keys = prettyKeys e;
|
||
desc = if e.desc != "" then e.desc
|
||
else "Focus or launch ${if e.command == "" then lib.toLower e.class else e.command}";
|
||
}) cfg.launchOrFocus
|
||
++ map (e: {
|
||
group = e.group or "Menu";
|
||
keys = e.keys;
|
||
inherit (e) desc;
|
||
}) keybinds.extra;
|
||
cheatRowsFor = g:
|
||
let rows = lib.filter (r: r.group == g) cheatBindRows;
|
||
in lib.optionals (rows != [ ]) (
|
||
[ ("── " + g + " ──") ]
|
||
++ map (r: padRight 22 r.keys + r.desc) rows
|
||
);
|
||
cheatRows = lib.concatLists (map cheatRowsFor cheatGroupOrder);
|
||
cheatsheetFile = pkgs.writeText "nomarchy-keybinds.txt"
|
||
(lib.concatStringsSep "\n" cheatRows + "\n");
|
||
|
||
# Dimmed keybind hints on menu rows (item 28c) — the same keybinds.nix
|
||
# + prettyKeys the cheatsheet uses, so they can never drift from the
|
||
# live binds. Pango spans; the menus that show them pass -markup-rows.
|
||
# Empty (no hint) when nothing binds the action.
|
||
hintForAction = action:
|
||
let m = lib.filter (b: b.action == action) keybinds.binds;
|
||
in lib.optionalString (m != [ ])
|
||
" <span size='small' alpha='55%'>${prettyKeys (lib.head m)}</span>";
|
||
menuHint = module: hintForAction "exec, nomarchy-menu ${module}";
|
||
# Apps opens rofi drun directly, not a nomarchy-menu module.
|
||
appsHint = " <span size='small' alpha='55%'>SUPER + Space</span>";
|
||
# A menu row that runs the same op as a global bind whose action isn't a
|
||
# `nomarchy-menu` module (the Capture rows ↔ the Print screenshot keys):
|
||
# match on mods+key so the hint still reads from keybinds.nix (single
|
||
# source, no drift), rather than a brittle full-action-string compare.
|
||
hintForBind = mods: key:
|
||
let m = lib.filter (b: b.mods == mods && b.key == key) keybinds.binds;
|
||
in lib.optionalString (m != [ ])
|
||
" <span size='small' alpha='55%'>${prettyKeys (lib.head m)}</span>";
|
||
|
||
nomarchy-menu = pkgs.writeShellScriptBin "nomarchy-menu" ''
|
||
# Nomarchy menu dispatcher — thin presentation layer over
|
||
# `rofi -dmenu` (via rofi_menu helper); actions delegate to
|
||
# nomarchy-state-sync, systemctl, hyprctl and friends. Menu entries
|
||
# carry real icons from the theme's icon set (Papirus) via rofi's
|
||
# per-row icon protocol — see row() below. `nomarchy-menu` with no
|
||
# argument shows the module picker.
|
||
|
||
# System batteries: type=Battery, not scope=Device — same filter as
|
||
# nomarchy-battery-notify. Name-agnostic (BAT0, CMB0, test_battery)
|
||
# so charge-limit / power-profile gates match notify (BACKLOG #60).
|
||
has_system_battery() {
|
||
local d
|
||
for d in /sys/class/power_supply/*/; do
|
||
[ "$(cat "$d/type" 2>/dev/null)" = Battery ] || continue
|
||
[ "$(cat "$d/scope" 2>/dev/null || echo System)" = Device ] && continue
|
||
return 0
|
||
done
|
||
return 1
|
||
}
|
||
has_charge_threshold() {
|
||
local d
|
||
for d in /sys/class/power_supply/*/; do
|
||
[ "$(cat "$d/type" 2>/dev/null)" = Battery ] || continue
|
||
[ "$(cat "$d/scope" 2>/dev/null || echo System)" = Device ] && continue
|
||
[ -e "$d/charge_control_end_threshold" ] && return 0
|
||
done
|
||
return 1
|
||
}
|
||
# Hibernate path available (logind CanHibernate, or resume= fallback).
|
||
# Gates the #115 Preferences row so machines without swap/resume hide it.
|
||
can_hibernate() {
|
||
local v
|
||
v=$(busctl get-property org.freedesktop.login1 \
|
||
/org/freedesktop/login1 org.freedesktop.login1.Manager \
|
||
CanHibernate 2>/dev/null || true)
|
||
case "$v" in
|
||
's "yes"') return 0 ;;
|
||
esac
|
||
grep -qE '(^|[[:space:]])resume=' /proc/cmdline 2>/dev/null
|
||
}
|
||
|
||
urlencode() {
|
||
local s="$1" out="" ch i
|
||
for ((i = 0; i < ''${#s}; i++)); do
|
||
ch=''${s:i:1}
|
||
case "$ch" in
|
||
[a-zA-Z0-9.~_-]) out+="$ch" ;;
|
||
' ') out+="+" ;;
|
||
*) printf -v ch '%%%02X' "'$ch"; out+="$ch" ;;
|
||
esac
|
||
done
|
||
printf '%s' "$out"
|
||
}
|
||
|
||
# Emit one dmenu row with a themed icon: `row "Label" icon-name`. The NUL
|
||
# + 0x1f field separator is rofi's per-row icon protocol; printed straight
|
||
# to the pipe because bash can't store NUL in a variable. Rendered when
|
||
# rofi has show-icons (on globally; passed explicitly on the icon menus).
|
||
row() { printf '%s\0icon\x1f%s\n' "$1" "$2"; }
|
||
|
||
# Back-audit exceptions (item 24): external modi/tools (calc, emoji,
|
||
# networkmanager_dmenu, rofi-pulse-select) can't take an injected row —
|
||
# Esc is their back path; free-text prompts (web, ask) have no list to
|
||
# append to — Esc cancels. Everything hand-rolled gets the row below.
|
||
# General menu convention: every list menu ends with a "↩ Back" entry that
|
||
# returns one level up (so you never have to Esc out and reopen). The `↩`
|
||
# glyph in the label IS the arrow — `back` must not also attach a
|
||
# go-previous icon, or icon menus render two arrows (item 34). So `back`
|
||
# is now just the plain-label emit, identical to what pick-lists append —
|
||
# one arrow everywhere. Always matched EXACTLY ("↩ Back") so it can't
|
||
# collide with clipboard or filename content.
|
||
BACK="↩ Back"
|
||
TAB=$(printf '\t')
|
||
back() { printf '%s\n' "$BACK"; }
|
||
|
||
# dmenu mode is case-sensitive by default (dmenu compatibility), even
|
||
# when config.rasi has case-sensitive: false for drun/modi — so
|
||
# SUPER+Space (apps) matched "ste"→Steam while the main Menu filter
|
||
# did not match "sys"→System. Always pass -i on menu pickers.
|
||
#
|
||
# Left arrow → one level up (same as choosing "↩ Back"). rofi's
|
||
# -kb-custom-1 returns exit 10; Esc still returns non-zero and quits
|
||
# the whole menu. Free-text prompts should treat "$BACK" as cancel.
|
||
# Pass --grid as the first arg to skip Left=Back (multi-column theme
|
||
# picker needs Left for column motion).
|
||
# Left is bound to kb-move-char-back by default and rofi hard-fails
|
||
# on a duplicate binding (error dialog, no menu) — every binding we
|
||
# steal must be released from its default first (Ctrl+b remains for
|
||
# cursor motion in text prompts).
|
||
rofi_menu() {
|
||
local out rc left_back=1
|
||
if [ "''${1:-}" = --grid ]; then
|
||
left_back=0
|
||
shift
|
||
fi
|
||
if [ "$left_back" -eq 1 ]; then
|
||
out=$(command rofi -dmenu -i -kb-move-char-back Control+b \
|
||
-kb-custom-1 Left "$@" </dev/stdin)
|
||
else
|
||
out=$(command rofi -dmenu -i "$@" </dev/stdin)
|
||
fi
|
||
rc=$?
|
||
if [ "$left_back" -eq 1 ] && [ "$rc" -eq 10 ]; then
|
||
printf '%s\n' "$BACK"
|
||
return 0
|
||
elif [ "$rc" -eq 0 ]; then
|
||
printf '%s\n' "$out"
|
||
return 0
|
||
else
|
||
return 1
|
||
fi
|
||
}
|
||
|
||
case "''${1:-}" in
|
||
power)
|
||
choice=$( {
|
||
row "Lock" system-lock-screen
|
||
row "Logout" system-log-out
|
||
row "Suspend" system-suspend
|
||
row "Hibernate" system-hibernate
|
||
row "Reboot" system-reboot
|
||
row "Shutdown" system-shutdown
|
||
back
|
||
} | rofi_menu -show-icons -p Power) || exit 0
|
||
case "$choice" in
|
||
"$BACK") exec "$0" ;;
|
||
*Lock) loginctl lock-session ;;
|
||
*Logout) hyprctl dispatch exit ;;
|
||
*Suspend)
|
||
# #115: nomarchy-suspend → s2h on battery when enabled; else suspend.
|
||
if command -v nomarchy-suspend >/dev/null 2>&1; then
|
||
nomarchy-suspend
|
||
else
|
||
systemctl suspend
|
||
fi ;;
|
||
*Hibernate)
|
||
# zram can't hold a hibernate image; a machine installed with
|
||
# swap=0 has no disk swap. Attempt, and if logind rejects it,
|
||
# say why instead of a silent no-op. On success this blocks
|
||
# until resume, so the notify only fires on a real failure.
|
||
systemctl hibernate 2>/dev/null \
|
||
|| notify-send "Hibernate" "Couldn't hibernate — likely no swap is configured. See docs/MIGRATION.md → Enabling hibernation." ;;
|
||
*Reboot) systemctl reboot ;;
|
||
*Shutdown) systemctl poweroff ;;
|
||
esac ;;
|
||
|
||
power-profile)
|
||
# power-profiles-daemon profile switcher (the system side ships it
|
||
# by default on laptops — nomarchy.system.power). Switching goes
|
||
# through polkit, so no root prompt.
|
||
if ! command -v powerprofilesctl >/dev/null 2>&1; then
|
||
notify-send "Power profiles" "power-profiles-daemon isn't running on this machine."
|
||
exit 0
|
||
fi
|
||
cur=$(powerprofilesctl get 2>/dev/null)
|
||
# Each profile gets its themed icon from Papirus' colored
|
||
# battery-profile-* family (the -symbolic variants are #444 grey and
|
||
# vanish on dark themes). The selected row's returned text is still
|
||
# the bare profile name, so `powerprofilesctl set` is unaffected.
|
||
choice=$( {
|
||
powerprofilesctl list 2>/dev/null | sed -nE 's/^[* ] ([a-z-]+):$/\1/p' \
|
||
| while IFS= read -r p; do
|
||
case "$p" in
|
||
performance) row "$p" battery-profile-performance ;;
|
||
balanced) row "$p" battery-profile-balanced ;;
|
||
power-saver) row "$p" battery-profile-powersave ;;
|
||
*) row "$p" preferences-system-power ;;
|
||
esac
|
||
done
|
||
back
|
||
} | rofi_menu -show-icons -p "profile (now: $cur)") || exit 0
|
||
[ "$choice" = "$BACK" ] && exec "$0" system-preferences
|
||
[ -n "$choice" ] && powerprofilesctl set "$choice" ;;
|
||
|
||
powermgmt)
|
||
# Combined power menu (item 36b) — power PROFILE + battery CHARGE cap
|
||
# in one flat list, opened from BOTH the Waybar battery and
|
||
# power-profile icons (the two granular System ▸ Power profile /
|
||
# Battery limit rows stay for direct access). No new backend: profile
|
||
# rows drive powerprofilesctl like the power-profile picker, charge
|
||
# rows drive the same state-sync writer as batterylimit. Each half
|
||
# self-gates on its hardware, so a desktop with neither just toasts.
|
||
haveppd=false; command -v powerprofilesctl >/dev/null 2>&1 && haveppd=true
|
||
havecharge=false; has_charge_threshold && havecharge=true
|
||
if ! $haveppd && ! $havecharge; then
|
||
notify-send "Power" "No power-profile or charge-limit control on this machine."
|
||
exit 0
|
||
fi
|
||
curp=""; $haveppd && curp=$(powerprofilesctl get 2>/dev/null)
|
||
curc=$(nomarchy-state-sync get settings.power.batteryChargeLimit 2>/dev/null)
|
||
choice=$( {
|
||
if $haveppd; then
|
||
powerprofilesctl list 2>/dev/null | sed -nE 's/^[* ] ([a-z-]+):$/\1/p' \
|
||
| while IFS= read -r p; do
|
||
case "$p" in
|
||
performance) row "Profile: $p" battery-profile-performance ;;
|
||
balanced) row "Profile: $p" battery-profile-balanced ;;
|
||
power-saver) row "Profile: $p" battery-profile-powersave ;;
|
||
*) row "Profile: $p" preferences-system-power ;;
|
||
esac
|
||
done
|
||
fi
|
||
if $havecharge; then
|
||
row "Charge limit: 80% (recommended)" battery-080
|
||
row "Charge limit: 90%" battery-090
|
||
row "Charge limit: 60%" battery-060
|
||
row "Charge limit: Off (100%)" battery-100
|
||
fi
|
||
back
|
||
} | rofi_menu -show-icons -p "Power — profile: ''${curp:-n/a}, limit: ''${curc:-default}") || exit 0
|
||
# Persist in-flake, then re-apply via the root oneshot (polkit
|
||
# allows users to restart it) so a non-writable sysfs node still
|
||
# updates. Fallback: direct echo if the node is group-writable.
|
||
set_limit() {
|
||
nomarchy-state-sync --quiet set settings.power.batteryChargeLimit "$1" --no-switch
|
||
n="$1"; [ "$n" = null ] && n=100
|
||
# Root oneshot sets Custom charge type (Dell) + thresholds.
|
||
applied=
|
||
if systemctl restart nomarchy-battery-charge-limit.service 2>/dev/null; then
|
||
applied=1
|
||
else
|
||
for d in /sys/class/power_supply/*/; do
|
||
[ "$(cat "$d/type" 2>/dev/null)" = Battery ] || continue
|
||
[ "$(cat "$d/scope" 2>/dev/null || echo System)" = Device ] && continue
|
||
if [ "$n" -lt 100 ] 2>/dev/null; then
|
||
for tnode in charge_types charge_type; do
|
||
[ -w "$d$tnode" ] || continue
|
||
listed=$(cat "$d$tnode" 2>/dev/null || true)
|
||
case " $listed " in *" Custom "*|*"Custom"*) echo Custom > "$d$tnode" 2>/dev/null || true ;; esac
|
||
done
|
||
fi
|
||
thresh="$d/charge_control_end_threshold"
|
||
if [ -w "$thresh" ] && echo "$n" > "$thresh" 2>/dev/null; then
|
||
applied=1
|
||
fi
|
||
done
|
||
fi
|
||
actual=; ctype=
|
||
for d in /sys/class/power_supply/*/; do
|
||
[ "$(cat "$d/type" 2>/dev/null)" = Battery ] || continue
|
||
[ "$(cat "$d/scope" 2>/dev/null || echo System)" = Device ] && continue
|
||
[ -r "$d/charge_control_end_threshold" ] \
|
||
&& actual=$(cat "$d/charge_control_end_threshold" 2>/dev/null)
|
||
ctype=$(cat "$d/charge_types" 2>/dev/null || cat "$d/charge_type" 2>/dev/null || true)
|
||
[ -n "$actual" ] && break
|
||
done
|
||
# Highlight active type: "… [Custom] …"
|
||
active=$(printf '%s' "$ctype" | sed -n 's/.*\[\([^]]*\)\].*/\1/p')
|
||
extra=
|
||
[ -n "$active" ] && extra=" (type $active)"
|
||
if [ -n "$applied" ] && [ -n "$actual" ]; then
|
||
notify-send "Battery limit" "$2 — stop at $actual%$extra."
|
||
elif [ -n "$applied" ]; then
|
||
notify-send "Battery limit" "$2 — applied (could not re-read sysfs)."
|
||
else
|
||
notify-send -u critical "Battery limit" \
|
||
"Saved in theme-state but could not write sysfs (need rebuild or users-group on threshold)."
|
||
fi
|
||
}
|
||
case "$choice" in
|
||
"$BACK") exec "$0" ;;
|
||
"Profile: "*) powerprofilesctl set "''${choice#Profile: }" ;;
|
||
*"Charge limit: 80%"*) set_limit 80 "Set to 80%" ;;
|
||
*"Charge limit: 90%"*) set_limit 90 "Set to 90%" ;;
|
||
*"Charge limit: 60%"*) set_limit 60 "Set to 60%" ;;
|
||
*"Charge limit: Off"*) set_limit null "Off — charges to 100%" ;;
|
||
esac ;;
|
||
|
||
theme)
|
||
# Visual picker: a grid of real desktop previews (rows + 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-state-sync apply "$slug" ;;
|
||
|
||
clipboard)
|
||
sel=$( { cliphist list; printf '%s\n' "$BACK"; } | rofi_menu -p clip) || exit 0
|
||
[ "$sel" = "$BACK" ] && exec "$0" tools
|
||
printf '%s' "$sel" | cliphist decode | wl-copy ;;
|
||
|
||
calc)
|
||
# Live calculator (rofi-calc): re-evaluates every keystroke via
|
||
# libqalculate — which dodges the qalc CLI's "15% of 200" misparse —
|
||
# with the answer as the top entry. Enter copies the result; the
|
||
# menu stays open to chain calculations (Esc closes).
|
||
exec rofi -show calc -modi calc -no-show-match -no-sort \
|
||
-calc-command "echo -n '{result}' | wl-copy" ;;
|
||
|
||
files)
|
||
sel=$( { fd . "$HOME" --type f --hidden --exclude .git --exclude .cache 2>/dev/null \
|
||
| head -n 50000 | sed "s|^$HOME/||"; printf '%s\n' "$BACK"; } \
|
||
| rofi_menu -p file) || exit 0
|
||
[ "$sel" = "$BACK" ] && exec "$0" tools
|
||
[ -n "$sel" ] && exec xdg-open "$HOME/$sel" ;;
|
||
|
||
emoji)
|
||
# Emoji / symbol picker (rofi-emoji). The default action copies the
|
||
# glyph to the clipboard through the plugin's Wayland adapter
|
||
# (wl-copy); the alternate action types it into the focused window.
|
||
exec rofi -show emoji -modi emoji ;;
|
||
|
||
web)
|
||
q=$(rofi_menu -p search < /dev/null) || exit 0
|
||
{ [ -n "$q" ] && [ "$q" != "$BACK" ]; } || exit 0
|
||
exec xdg-open "https://www.google.com/search?q=$(urlencode "$q")" ;;
|
||
|
||
network)
|
||
# Native rofi wifi/VPN picker (networkmanager_dmenu) over the system
|
||
# NetworkManager — replaces the old nmtui-in-a-terminal flow. Reads
|
||
# its config from xdg.configFile below (told to drive rofi).
|
||
# External tool: no injected ↩ Back row possible — Esc closes.
|
||
exec networkmanager_dmenu ;;
|
||
|
||
vpn)
|
||
# VPN setup + management (nomarchy-vpn): NM VPN/WireGuard connect +
|
||
# config import + Tailscale. Distinct from `network` (the wifi picker).
|
||
exec nomarchy-vpn ;;
|
||
|
||
bluetooth)
|
||
# blueman-manager GUI (services.blueman.enable, system-side).
|
||
# Self-gated in the System menu on the CLI; guard here too in case
|
||
# it's invoked directly with nomarchy.system.bluetooth.enable=false.
|
||
command -v blueman-manager >/dev/null 2>&1 \
|
||
|| { notify-send "Bluetooth" "Not available (nomarchy.system.bluetooth off?)."; exit 0; }
|
||
# blueman shows a raw "Connection to BlueZ failed" when bluetoothd
|
||
# isn't up (BACKLOG #97) — it never runs on machines whose adapter
|
||
# is absent, unpowered, or rfkill-blocked (bluetooth.service is
|
||
# udev-triggered on /sys/class/bluetooth). Explain instead.
|
||
if ! systemctl is-active --quiet bluetooth.service; then
|
||
if ls /sys/class/bluetooth/hci* >/dev/null 2>&1; then
|
||
notify-send "Bluetooth" "The Bluetooth service is not running. Try: systemctl start bluetooth — if it fails, check 'rfkill list' for a blocked adapter."
|
||
else
|
||
notify-send "Bluetooth" "No Bluetooth adapter detected on this machine (nothing in /sys/class/bluetooth). If it has one, it may be disabled by a hardware switch or missing firmware — check 'rfkill list' and 'journalctl -k -g bluetooth'."
|
||
fi
|
||
exit 0
|
||
fi
|
||
exec blueman-manager ;;
|
||
|
||
printers)
|
||
# system-config-printer GUI (the CUPS admin app), installed by
|
||
# nomarchy.services.printing. Self-gated in the System menu; guard
|
||
# here too in case it's invoked directly.
|
||
command -v system-config-printer >/dev/null 2>&1 \
|
||
&& exec system-config-printer
|
||
notify-send "Printers" "Not available (nomarchy.services.printing off?)."; exit 0 ;;
|
||
|
||
audio)
|
||
# PipeWire (pulse) sink/source switcher via rofi-pulse-select. Self-
|
||
# gated in the System menu on the pulse socket; guarded here too.
|
||
if [ ! -e "''${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/pulse/native" ]; then
|
||
notify-send "Audio" "No PipeWire/PulseAudio socket (nomarchy.audio off?)."; exit 0
|
||
fi
|
||
# The helper owns dock-class detection + availability (regexes come
|
||
# from dock-audio-rules.nix once). This row is the manual catch-up.
|
||
candidate=
|
||
command -v nomarchy-dock-audio >/dev/null 2>&1 \
|
||
&& candidate=$(nomarchy-dock-audio candidates 2>/dev/null \
|
||
| ${pkgs.coreutils}/bin/head -n 1)
|
||
dockname=''${candidate%%"$TAB"*}
|
||
dockdesc=''${candidate#*"$TAB"}
|
||
cursink=$(${pkgs.pulseaudio}/bin/pactl get-default-sink 2>/dev/null || true)
|
||
[ "$dockname" = "$cursink" ] && dockname=
|
||
choice=$( {
|
||
[ -n "$dockname" ] && row "Send output → ''${dockdesc:-$dockname}" video-display
|
||
row "Output device" audio-volume-high
|
||
row "Input device" audio-input-microphone
|
||
back
|
||
} | rofi_menu -show-icons -p Audio) || exit 0
|
||
case "$choice" in
|
||
"$BACK") exec "$0" system-devices ;;
|
||
"Send output"*)
|
||
nomarchy-dock-audio select menu ;;
|
||
*Output*) exec rofi-pulse-select sink ;;
|
||
*Input*) exec rofi-pulse-select source ;;
|
||
esac ;;
|
||
|
||
keyboard)
|
||
command -v nomarchy-keyboard-layout >/dev/null 2>&1 \
|
||
|| { notify-send "Keyboard" "Per-device layout helper is unavailable."; exit 0; }
|
||
devices=$(nomarchy-keyboard-layout devices)
|
||
[ -n "$devices" ] \
|
||
|| { notify-send "Keyboard" "No keyboards reported by Hyprland."; exit 0; }
|
||
picked=$( {
|
||
printf '%s\n' "$devices" | while IFS="$TAB" read -r dev active; do
|
||
printf '%s · %s\n' "$dev" "$active"
|
||
done
|
||
back
|
||
} | rofi_menu -p "Keyboard") || exit 0
|
||
[ "$picked" = "$BACK" ] && exec "$0" system-devices
|
||
device=''${picked%% ·*}
|
||
saved=$(nomarchy-keyboard-layout saved "$device")
|
||
layout=$( {
|
||
[ -n "$saved" ] && printf 'Use session default · forget %s\n' "$saved"
|
||
nomarchy-keyboard-layout layouts
|
||
back
|
||
} | rofi_menu -p "Layout · $device (now: ''${saved:-session default})") || exit 0
|
||
case "$layout" in
|
||
"$BACK") exec "$0" keyboard ;;
|
||
"Use session default"*) exec nomarchy-keyboard-layout forget "$device" ;;
|
||
"") exit 0 ;;
|
||
*) exec nomarchy-keyboard-layout apply "$device" "$layout" ;;
|
||
esac ;;
|
||
|
||
display)
|
||
# Per-output resolution picker. Applies the chosen mode INSTANTLY via
|
||
# `hyprctl keyword monitor` — keeping the output's current position and
|
||
# scale, so only the resolution changes — and PERSISTS it to the in-flake
|
||
# state (settings.monitors.<name>, --no-switch = no rebuild), so the
|
||
# choice survives logout/reboot. A later rebuild bakes the same
|
||
# resolution into the generated monitor rule (hyprland.nix overlays
|
||
# settings.monitors onto nomarchy.monitors by name). Self-gated to the
|
||
# Hyprland session; guard here too in case it's invoked directly.
|
||
command -v hyprctl >/dev/null 2>&1 \
|
||
|| { notify-send "Display" "Hyprland is not running."; exit 0; }
|
||
mons=$(hyprctl monitors -j)
|
||
|
||
# Docked context: which outputs are active, which is the built-in
|
||
# panel, is anything currently soft-disabled (monitors all shows
|
||
# disabled outputs; plain monitors does not).
|
||
nActive=$(printf '%s' "$mons" | jq -r '.[].name' | grep -c .)
|
||
internal=$(printf '%s' "$mons" | jq -r '[.[].name | select(test("^(eDP|LVDS|DSI)"))][0] // empty')
|
||
external=$(printf '%s' "$mons" | jq -r '[.[].name | select(test("^(eDP|LVDS|DSI)") | not)][0] // empty')
|
||
offmon=$(printf '%s' "$(hyprctl monitors all -j)" | jq -r '[.[] | select(.disabled)][0].name // empty')
|
||
|
||
# Choose the output (skip the chooser when only one is connected and
|
||
# there are no profiles — the Profiles row needs the chooser to live in).
|
||
# Where the mode picker's Back returns to: the output chooser when it
|
||
# was shown (a real previous level), else straight to System — on a
|
||
# single-monitor laptop the chooser is skipped, so returning to
|
||
# `display` would just re-open this same mode picker (item 40).
|
||
modeBack=system
|
||
profs=; command -v nomarchy-display-profile >/dev/null 2>&1 && profs=1
|
||
if [ -n "$profs" ] || [ "$nActive" -gt 1 ] || [ -n "$offmon" ]; then
|
||
name=$( {
|
||
[ -n "$profs" ] && printf 'Profiles ›\n'
|
||
# Dock mode performs an atomic workspace handoff and only then
|
||
# disables the internal panel. The always-on hotplug watcher
|
||
# holds logind's lid-switch inhibitor across the reverse path.
|
||
[ "$nActive" -gt 1 ] && [ -n "$internal" ] && [ -n "$external" ] \
|
||
&& printf 'Dock mode · external only → %s\n' "$external"
|
||
[ -n "$offmon" ] && printf 'Screen on · %s\n' "$offmon"
|
||
[ "$nActive" -gt 1 ] && printf 'Move workspace · → next monitor\n'
|
||
[ "$nActive" -eq 2 ] && printf 'Swap workspaces · %s ⇄ %s\n' \
|
||
"$(printf '%s' "$mons" | jq -r '.[0].name')" \
|
||
"$(printf '%s' "$mons" | jq -r '.[1].name')"
|
||
printf '%s' "$mons" | jq -r '.[] | "\(.name) · \(.width)x\(.height)@\(.refreshRate|round)Hz"'
|
||
printf '%s\n' "$BACK"
|
||
} | rofi_menu -p Display ) || exit 0
|
||
[ "$name" = "$BACK" ] && exec "$0" system-devices
|
||
[ "$name" = "Profiles ›" ] && exec "$0" display-profile
|
||
case "$name" in
|
||
"Dock mode"*)
|
||
if nomarchy-display-transition dock "$internal" "$external" \
|
||
"$external, preferred, auto, 1"; then
|
||
notify-send "Display" "Dock mode — every laptop workspace moved to $external; laptop panel off."
|
||
else
|
||
notify-send "Display" "Dock transition failed; the previous layout was kept where possible."
|
||
fi
|
||
exit 0 ;;
|
||
"Screen on"*)
|
||
# preferred/auto/1 is the known-good live fallback; the declared
|
||
# scale/position come back at the next relogin/switch.
|
||
if nomarchy-display-transition enable "$offmon"; then
|
||
notify-send "Display" "$offmon is back on."
|
||
else
|
||
notify-send "Display" "Could not enable $offmon."
|
||
fi
|
||
exit 0 ;;
|
||
"Move workspace"*)
|
||
hyprctl dispatch movecurrentworkspacetomonitor +1 >/dev/null 2>&1
|
||
exit 0 ;;
|
||
"Swap workspaces"*)
|
||
hyprctl dispatch swapactiveworkspaces \
|
||
"$(printf '%s' "$mons" | jq -r '.[0].name')" \
|
||
"$(printf '%s' "$mons" | jq -r '.[1].name')" >/dev/null 2>&1
|
||
exit 0 ;;
|
||
esac
|
||
name=''${name%% ·*} # strip the " · WxH@R" hint
|
||
modeBack=display
|
||
else
|
||
name=$(printf '%s' "$mons" | jq -r '.[0].name')
|
||
fi
|
||
[ -n "$name" ] || exit 0
|
||
|
||
# Pick a mode: Hyprland tokens, then advertised modes sorted by
|
||
# pixel area (highest first). Do NOT use sort -rV on "WxH@R" —
|
||
# version sort is not geometric and mis-orders common modes (#126).
|
||
cur=$(printf '%s' "$mons" | jq -r --arg n "$name" \
|
||
'.[]|select(.name==$n)|"\(.width)x\(.height)@\(.refreshRate|round)"')
|
||
mode=$( {
|
||
printf 'preferred\nhighres\nhighrr\n'
|
||
printf '%s' "$mons" | jq -r --arg n "$name" '.[]|select(.name==$n)|.availableModes[]' \
|
||
| sed 's/Hz$//' \
|
||
| awk -F'[@x]' '
|
||
NF >= 2 {
|
||
w=$1+0; h=$2+0; r=($3==""?0:$3+0);
|
||
printf "%d\t%dx%d@%d\n", w*h, w, h, r
|
||
}' \
|
||
| sort -t$'\t' -k1,1nr -k2,2 \
|
||
| cut -f2- \
|
||
| awk 'NF && !seen[$0]++'
|
||
printf '%s\n' "$BACK"
|
||
} | rofi_menu -p "$name (now $cur)" ) || exit 0
|
||
[ "$mode" = "$BACK" ] && exec "$0" "$modeBack"
|
||
[ -n "$mode" ] || exit 0
|
||
|
||
# Apply live (keep the output's current position + scale), then persist.
|
||
# Notify with hyprctl-reported size so a QEMU fixed window that
|
||
# *looks* smaller at higher modes is not mistaken for an invert (#126).
|
||
pos=$(printf '%s' "$mons" | jq -r --arg n "$name" '.[]|select(.name==$n)|"\(.x)x\(.y)"')
|
||
scale=$(printf '%s' "$mons" | jq -r --arg n "$name" '.[]|select(.name==$n)|.scale')
|
||
if hyprctl keyword monitor "$name,$mode,$pos,$scale" >/dev/null 2>&1; then
|
||
nomarchy-state-sync --quiet set "settings.monitors.$name" "$mode" --no-switch
|
||
got=$(hyprctl monitors -j | jq -r --arg n "$name" \
|
||
'.[]|select(.name==$n)|"\(.width)x\(.height)@\(.refreshRate|round)"')
|
||
notify-send "Display" "$name → $mode (now ${got:-?})"
|
||
else
|
||
notify-send "Display" "Could not set $name to $mode."
|
||
fi ;;
|
||
|
||
capture)
|
||
# Screenshots + screen recording. Recording rows self-swap: while
|
||
# one runs, the only offer is Stop (same surface as the bar's ⏺).
|
||
choice=$( {
|
||
row "Region → clipboard${hintForBind "" "Print"}" applets-screenshooter
|
||
row "Region → file${hintForBind "SHIFT" "Print"}" applets-screenshooter
|
||
command -v satty >/dev/null 2>&1 && row "Annotate region${hintForBind "$mod SHIFT" "Print"}" edit-image
|
||
row "Full screen → clipboard" camera-photo
|
||
row "Full screen → file${hintForBind "CTRL" "Print"}" camera-photo
|
||
command -v tesseract >/dev/null 2>&1 && row "OCR region → clipboard" scanner
|
||
if command -v nomarchy-record >/dev/null 2>&1; then
|
||
if nomarchy-record active; then
|
||
row "■ Stop recording" media-playback-stop
|
||
else
|
||
row "Record region" media-record
|
||
row "Record screen" media-record
|
||
row "Record region + audio" audio-input-microphone
|
||
row "Record screen + audio" audio-input-microphone
|
||
fi
|
||
fi
|
||
back
|
||
} | rofi_menu -show-icons -markup-rows -p Capture) || exit 0
|
||
dir="$HOME/Pictures/Screenshots"
|
||
file="$dir/$(date +%Y%m%d-%H%M%S).png"
|
||
case "$choice" in
|
||
"$BACK") exec "$0" tools ;;
|
||
*"Region → clipboard"*)
|
||
grim -g "$(slurp)" - | wl-copy \
|
||
&& notify-send "Screenshot" "Region copied to clipboard." ;;
|
||
*"Region → file"*) mkdir -p "$dir"; grim -g "$(slurp)" "$file" \
|
||
&& notify-send "Screenshot saved" "$file" ;;
|
||
*"Annotate region"*) mkdir -p "$dir"; grim -g "$(slurp)" - | satty --filename - --fullscreen --output-filename "$file" ;;
|
||
*"OCR region"*)
|
||
# Select → screenshot → tesseract → clipboard. Esc in slurp
|
||
# cancels silently (the recording.nix pattern); recognizing
|
||
# nothing toasts instead of copying an empty clipboard.
|
||
geo=$(slurp) || exit 0
|
||
txt=$(grim -g "$geo" - | tesseract stdin stdout 2>/dev/null)
|
||
[ -n "''${txt//[[:space:]]/}" ] \
|
||
|| { notify-send "OCR" "No text recognized in that region."; exit 0; }
|
||
printf '%s' "$txt" | wl-copy
|
||
notify-send "OCR" "Copied $(printf '%s' "$txt" | wc -w) word(s) to the clipboard." ;;
|
||
*"Full screen → clipboard"*)
|
||
grim - | wl-copy \
|
||
&& notify-send "Screenshot" "Screen copied to clipboard." ;;
|
||
*"Full screen → file"*) mkdir -p "$dir"; grim "$file" \
|
||
&& notify-send "Screenshot saved" "$file" ;;
|
||
*"Stop recording"*) exec nomarchy-record stop ;;
|
||
*"Record region + audio"*) exec nomarchy-record start region audio ;;
|
||
*"Record screen + audio"*) exec nomarchy-record start screen audio ;;
|
||
*"Record region"*) exec nomarchy-record start region ;;
|
||
*"Record screen"*) exec nomarchy-record start screen ;;
|
||
esac ;;
|
||
|
||
lookfeel)
|
||
# Look & Feel — appearance: Theme, wallpapers, night light, blur/gaps
|
||
# (#110 moved blur/gaps out of Control Center — presets only, no
|
||
# free-text). Root stays six entries.
|
||
choice=$( {
|
||
row "Theme${menuHint "theme"}" preferences-desktop-theme
|
||
row "Next wallpaper${hintForAction "exec, nomarchy-state-sync bg next"}" preferences-desktop-wallpaper
|
||
row "Reset wallpaper (auto)" preferences-desktop-wallpaper
|
||
if [ "$(nomarchy-nightlight is-active 2>/dev/null)" = on ]
|
||
then row "Night light (on)" weather-clear-night
|
||
else row "Night light (off)" weather-clear-night
|
||
fi
|
||
if [ "$(nomarchy-state-sync get settings.autoTheme.enable 2>/dev/null)" = true ]
|
||
then row "Auto theme (on)" preferences-desktop-theme
|
||
else row "Auto theme (off)" preferences-desktop-theme
|
||
fi
|
||
case "$(nomarchy-state-sync get ui.blur 2>/dev/null)" in
|
||
true|True) row "Blur (on)" preferences-desktop-effects ;;
|
||
*) row "Blur (off)" preferences-desktop-effects ;;
|
||
esac
|
||
gaps=$(nomarchy-state-sync get ui.gapsOut 2>/dev/null || echo 12)
|
||
row "Gaps (now $gaps px)" preferences-desktop-display
|
||
back
|
||
} | rofi_menu -show-icons -markup-rows -p "Look & Feel") || exit 0
|
||
case "$choice" in
|
||
"$BACK") exec "$0" ;;
|
||
*"Auto theme"*) exec "$0" autotheme ;;
|
||
*Theme*) exec "$0" theme ;;
|
||
*"Next wallpaper"*) exec nomarchy-state-sync bg next ;;
|
||
*"Reset wallpaper"*) exec nomarchy-state-sync bg auto ;;
|
||
*"Night light"*) exec "$0" nightlight ;;
|
||
*Blur*) exec "$0" blur ;;
|
||
*Gaps*) exec "$0" gaps ;;
|
||
esac ;;
|
||
|
||
blur)
|
||
# ui.blur → Hyprland decoration; needs a home switch to bake.
|
||
case "$(nomarchy-state-sync get ui.blur 2>/dev/null)" in
|
||
true|True)
|
||
nomarchy-state-sync --quiet set ui.blur false
|
||
notify-send "Blur" "Off — desktop rebuilt." ;;
|
||
*)
|
||
nomarchy-state-sync --quiet set ui.blur true
|
||
notify-send "Blur" "On — desktop rebuilt." ;;
|
||
esac
|
||
exit 0 ;;
|
||
|
||
gaps)
|
||
# Preset-only (no free-text) — #110 keyboard/gaps free-text was unsafe.
|
||
cur=$(nomarchy-state-sync get ui.gapsOut 2>/dev/null || echo 12)
|
||
choice=$( {
|
||
for g in 0 4 8 12 16 24; do
|
||
if [ "$g" = "$cur" ]; then row "$g px (current)" preferences-desktop-display
|
||
else row "$g px" preferences-desktop-display
|
||
fi
|
||
done
|
||
back
|
||
} | rofi_menu -show-icons -p "Outer gaps (now $cur px)") || exit 0
|
||
case "$choice" in
|
||
"$BACK") exec "$0" lookfeel ;;
|
||
*)
|
||
g=''${choice%% *}
|
||
case "$g" in *[!0-9]*) exit 0 ;; esac
|
||
nomarchy-state-sync --quiet set ui.gapsOut "$g"
|
||
notify-send "Gaps" "Outer gaps $g px — desktop rebuilt." ;;
|
||
esac
|
||
exit 0 ;;
|
||
|
||
display-profile)
|
||
# Named multi-output layouts (nomarchy.displayProfiles). Applying is
|
||
# instant (hyprctl per entry, via nomarchy-display-profile) and
|
||
# persisted in-flake (settings.displayProfile) so the next rebuild
|
||
# bakes it. "Base layout" clears the state and hyprctl-reloads.
|
||
command -v nomarchy-display-profile >/dev/null 2>&1 \
|
||
|| { notify-send "Display profiles" "None declared (nomarchy.displayProfiles)."; exit 0; }
|
||
active=$(nomarchy-display-profile active)
|
||
choice=$( {
|
||
nomarchy-display-profile list | while IFS= read -r p; do
|
||
if [ "$p" = "$active" ]; then printf '● %s\n' "$p"; else printf '○ %s\n' "$p"; fi
|
||
done
|
||
printf 'Base layout\n'
|
||
if [ "$(nomarchy-state-sync get settings.displayProfileAuto 2>/dev/null)" = true ]
|
||
then printf 'Auto-switch (on)\n'
|
||
else printf 'Auto-switch (off)\n'
|
||
fi
|
||
printf '%s\n' "$BACK"
|
||
} | rofi_menu -p Profiles ) || exit 0
|
||
case "$choice" in
|
||
"$BACK") exec "$0" display ;;
|
||
"Base layout") exec nomarchy-display-profile base ;;
|
||
*"Auto-switch"*)
|
||
# Instant in-flake flag; the watcher reads it live on the next
|
||
# output change — no rebuild, no unit juggling.
|
||
if [ "$(nomarchy-state-sync get settings.displayProfileAuto 2>/dev/null)" = true ]; then
|
||
nomarchy-state-sync --quiet set settings.displayProfileAuto false --no-switch
|
||
notify-send "Display profiles" "Auto-switch off — profiles change only from this menu."
|
||
else
|
||
nomarchy-state-sync --quiet set settings.displayProfileAuto true --no-switch
|
||
notify-send "Display profiles" "Auto-switch on — plugging/unplugging outputs picks the matching profile."
|
||
fi
|
||
exec "$0" display-profile ;;
|
||
*) exec nomarchy-display-profile apply "''${choice#[●○] }" ;;
|
||
esac ;;
|
||
|
||
colorpicker)
|
||
# Pick a pixel's color (hyprpicker's zoom loupe) → clipboard, hex
|
||
# in the confirmation toast. Esc/cancel exits silently. Self-gated
|
||
# in the Tools row; guarded here too for direct invocation.
|
||
command -v hyprpicker >/dev/null 2>&1 \
|
||
|| { notify-send "Color picker" "hyprpicker is not installed."; exit 0; }
|
||
color=$(hyprpicker) || exit 0
|
||
[ -n "$color" ] || exit 0
|
||
printf '%s' "$color" | wl-copy
|
||
notify-send "Color picker" "$color copied to the clipboard." ;;
|
||
|
||
keybinds)
|
||
# Read-only cheatsheet, generated from keybinds.nix at build time.
|
||
# Enter on a row is a deliberate no-op (nothing to launch); ↩ Back
|
||
# returns to the root picker, which is also where it's launched.
|
||
sel=$( { cat ${cheatsheetFile}; printf '%s\n' "$BACK"; } \
|
||
| rofi_menu -p keys ) || exit 0
|
||
[ "$sel" = "$BACK" ] && exec "$0"
|
||
exit 0 ;;
|
||
|
||
ask)
|
||
# Free-text question → the provider's web chat in the default browser
|
||
# (#140). It used to open claude-code in a terminal: a coding agent, an
|
||
# npm fetch on first run and a REPL left sitting there, all to answer
|
||
# "what's the syntax for…". The prompt was the right surface; the
|
||
# destination was not.
|
||
#
|
||
# The provider is read from the in-flake state on every invocation, so
|
||
# switching it in Preferences takes effect immediately — no rebuild —
|
||
# and an unknown/absent value falls back rather than opening nothing.
|
||
q=$(rofi_menu -p ask < /dev/null) || exit 0
|
||
{ [ -n "$q" ] && [ "$q" != "$BACK" ]; } || exit 0
|
||
p=$(nomarchy-state-sync get settings.ask.provider 2>/dev/null)
|
||
case "$p" in ${lib.concatStringsSep "|" (lib.attrNames askProviders)}) ;; *) p=${askDefaultProvider} ;; esac
|
||
enc=$(printf '%s' "$q" | ${pkgs.jq}/bin/jq -sRr @uri)
|
||
case "$p" in
|
||
${lib.concatStringsSep "\n" (lib.mapAttrsToList
|
||
(name: url: " ${name}) url='${url}'\"$enc\" ;;") askProviders)}
|
||
esac
|
||
# Prefill is the *vendor's* feature, not ours: each can change or drop
|
||
# `?q=` whenever they like, and the failure is silent — you land in an
|
||
# empty chat having already typed the question. So the text goes to the
|
||
# clipboard too. Worst case is one paste, not retyping.
|
||
printf '%s' "$q" | ${pkgs.wl-clipboard}/bin/wl-copy 2>/dev/null || true
|
||
exec xdg-open "$url" ;;
|
||
|
||
dnd)
|
||
# Toggle swaync Do-Not-Disturb (-d prints the new state). With DND
|
||
# on, notifications are suppressed — the Waybar bell-off icon is the
|
||
# cue — so only confirm with a toast when turning it back off.
|
||
[ "$(swaync-client -d)" = "false" ] \
|
||
&& notify-send "Do Not Disturb off" "Notifications resumed."
|
||
exit 0 ;;
|
||
|
||
nightlight)
|
||
# Force the scheduled blue-light filter on/off for the session by
|
||
# starting/stopping hyprsunset (nomarchy-nightlight, from nightlight.nix).
|
||
# Self-gated in the menu; guard here too in case it's invoked directly.
|
||
command -v nomarchy-nightlight >/dev/null 2>&1 \
|
||
&& exec nomarchy-nightlight toggle
|
||
notify-send "Night light" "Not available (nomarchy.nightlight off?)."; exit 0 ;;
|
||
|
||
airplane)
|
||
# Runtime Wi-Fi + Bluetooth kill-switch (#104). Priors restored on
|
||
# disengage; Waybar plane glyph self-hides when off.
|
||
command -v nomarchy-airplane >/dev/null 2>&1 \
|
||
&& exec nomarchy-airplane toggle
|
||
notify-send "Airplane mode" "Not available."; exit 0 ;;
|
||
|
||
autotheme)
|
||
# Look & Feel › Auto theme — automatic day/night theme switch
|
||
# (settings.autoTheme, applied by the nomarchy-auto-theme timer from
|
||
# autotheme.nix). The enable flag GATES the timer's install, so
|
||
# turning it *on* must rebuild; day/night/times are read live by
|
||
# `nomarchy-state-sync auto`, so those writes are instant
|
||
# (--no-switch). Enabling writes the flag then `auto --force`, so a
|
||
# single rebuild both installs the timer and applies the right theme
|
||
# now. Disabling is instant: the flag flips and `auto` self-gates to
|
||
# a no-op, so the lingering timer does nothing until the next rebuild.
|
||
at_get() { nomarchy-state-sync get "settings.autoTheme.$1" 2>/dev/null; }
|
||
en=$(at_get enable); day=$(at_get day); night=$(at_get night)
|
||
sunrise=$(at_get sunrise); sunset=$(at_get sunset)
|
||
choice=$( {
|
||
if [ "$en" = true ]
|
||
then row "Auto theme (on)" preferences-desktop-theme
|
||
else row "Auto theme (off)" preferences-desktop-theme
|
||
fi
|
||
row "Day theme: ''${day:-— pick}" weather-clear
|
||
row "Night theme: ''${night:-— pick}" weather-clear-night
|
||
row "Sunrise: ''${sunrise:-07:00}" preferences-system-time
|
||
row "Sunset: ''${sunset:-20:00}" preferences-system-time
|
||
back
|
||
} | rofi_menu -show-icons -markup-rows -p "Auto theme") || exit 0
|
||
case "$choice" in
|
||
"$BACK") exec "$0" lookfeel ;;
|
||
*"Auto theme"*)
|
||
if [ "$en" = true ]; then
|
||
nomarchy-state-sync --quiet set settings.autoTheme.enable false --no-switch
|
||
notify-send "Auto theme" "Off — current theme stays."
|
||
exec "$0" autotheme
|
||
fi
|
||
# Need a day + night pair before enabling; default the summer pair.
|
||
[ -n "$day" ] || nomarchy-state-sync --quiet set settings.autoTheme.day summer-day --no-switch
|
||
[ -n "$night" ] || nomarchy-state-sync --quiet set settings.autoTheme.night summer-night --no-switch
|
||
nomarchy-state-sync --quiet set settings.autoTheme.enable true --no-switch
|
||
notify-send "Auto theme" "On — applying the theme for now…"
|
||
exec nomarchy-state-sync auto --force ;; # one rebuild: install timer + apply
|
||
"Day theme"*)
|
||
slug=$( { nomarchy-state-sync list; printf '%s\n' "$BACK"; } | rofi_menu -p "Day theme") || exec "$0" autotheme
|
||
[ "$slug" != "$BACK" ] && [ -n "$slug" ] \
|
||
&& nomarchy-state-sync --quiet set settings.autoTheme.day "$slug" --no-switch
|
||
exec "$0" autotheme ;;
|
||
"Night theme"*)
|
||
slug=$( { nomarchy-state-sync list; printf '%s\n' "$BACK"; } | rofi_menu -p "Night theme") || exec "$0" autotheme
|
||
[ "$slug" != "$BACK" ] && [ -n "$slug" ] \
|
||
&& nomarchy-state-sync --quiet set settings.autoTheme.night "$slug" --no-switch
|
||
exec "$0" autotheme ;;
|
||
"Sunrise"*|"Sunset"*)
|
||
key=sunrise; label=Sunrise; cur="''${sunrise:-07:00}"
|
||
case "$choice" in "Sunset"*) key=sunset; label=Sunset; cur="''${sunset:-20:00}" ;; esac
|
||
t=$(rofi_menu -p "$label HH:MM (now $cur)" < /dev/null) || exec "$0" autotheme
|
||
case "$t" in
|
||
""|"$BACK") : ;;
|
||
[01][0-9]:[0-5][0-9]|2[0-3]:[0-5][0-9])
|
||
nomarchy-state-sync --quiet set "settings.autoTheme.$key" "$t" --no-switch ;;
|
||
*) notify-send "Auto theme" "Ignored '$t' — use HH:MM (24-hour)." ;;
|
||
esac
|
||
exec "$0" autotheme ;;
|
||
esac ;;
|
||
|
||
autotimezone)
|
||
# Toggle automatic timezone detection (geoclue + automatic-timezoned).
|
||
# A SYSTEM service, so flipping it writes the in-flake flag and runs a
|
||
# system rebuild (sudo) + a home switch — in a terminal, like Snapshots.
|
||
command -v nomarchy-autotimezone >/dev/null 2>&1 \
|
||
|| { notify-send "Auto timezone" "Unavailable on this machine."; exit 0; }
|
||
exec ${cfg.terminal} -e nomarchy-autotimezone toggle ;;
|
||
|
||
autologin)
|
||
# Toggle booting straight into the session (greetd initial_session).
|
||
# A SYSTEM setting baked at rebuild, so this sudos a system switch in
|
||
# a terminal, like Auto timezone — and the change shows on the NEXT
|
||
# boot, not now. Off is what makes the greeter ask for a password (or
|
||
# a finger, when System › Fingerprint is on).
|
||
command -v nomarchy-autologin >/dev/null 2>&1 \
|
||
|| { notify-send "Auto-login" "Unavailable on this machine."; exit 0; }
|
||
exec ${cfg.terminal} -e nomarchy-autologin toggle ;;
|
||
|
||
autocommit)
|
||
# Toggle opt-in auto-commit: every menu/theme mutation also commits
|
||
# state.json (that file only) in the downstream flake, so
|
||
# settings history is `git log`; nomarchy-pull/-rebuild/-home
|
||
# additionally sweep everything else dirty (hand edits, lock bumps)
|
||
# into a commit first. Nothing in Nix consumes the flag —
|
||
# the tool reads the live state on each write — so the toggle is
|
||
# instant, no rebuild. The off-write commits too (the tool fires
|
||
# when the flag was on before OR after), keeping history consistent.
|
||
if [ "$(nomarchy-state-sync get settings.autoCommit 2>/dev/null)" = true ]; then
|
||
nomarchy-state-sync --quiet set settings.autoCommit false --no-switch
|
||
notify-send "Auto-commit" "Off — menu changes stay uncommitted in your flake."
|
||
else
|
||
nomarchy-state-sync --quiet set settings.autoCommit true --no-switch
|
||
notify-send "Auto-commit" "On — menu changes commit; rebuilds sweep pending edits in too."
|
||
fi
|
||
exit 0 ;;
|
||
|
||
snapshot)
|
||
# Snapshot browse/restore — the btrfs-assistant GUI via its pkexec
|
||
# launcher. The "2.2 segfaults on 26.05" diagnosis was HALF right:
|
||
# only UNPRIVILEGED runs crash (libbtrfsutil unprivileged subvolume
|
||
# iteration, btrfs-progs 6.17.1; fixed upstream after) — as root it
|
||
# runs fine, and the launcher runs it as root. The prompt needs the
|
||
# session polkit agent (hyprpolkitagent, exec-once in hyprland.nix).
|
||
# Fallback: nomarchy-snapshots, the keyboard-driven fzf browser in a
|
||
# terminal (kept installed — also nice over SSH).
|
||
# Self-gated to nomarchy.system.snapper, so it no-ops elsewhere.
|
||
command -v btrfs-assistant-launcher >/dev/null 2>&1 \
|
||
&& exec btrfs-assistant-launcher
|
||
command -v nomarchy-snapshots >/dev/null 2>&1 \
|
||
|| { notify-send "Snapshots" "Snapshot tools unavailable (nomarchy.system.snapper off?)."; exit 0; }
|
||
exec ${cfg.terminal} -e sudo nomarchy-snapshots ;;
|
||
|
||
doctor)
|
||
# Read-only health sheet in a floating, centered kitty window
|
||
# (class matched by hyprland.nix windowrules). Same pattern as
|
||
# nomarchy-calendar, sizing itself for the same reason (#139).
|
||
# Exit code doesn't matter; the sheet is the product.
|
||
exec ${termSheet}/bin/nomarchy-term-sheet com.nomarchy.doctor 55 70 \
|
||
sh -c "nomarchy-doctor
|
||
printf '\nEnter to close.'; read -r _" ;;
|
||
|
||
firmware)
|
||
# System ▸ Firmware — fwupd/LVFS update surface (HARDWARE.md §4).
|
||
# Never auto-flashes: refresh metadata, list what LVFS offers, then
|
||
# hand to `fwupdmgr update`, which confirms each device and prompts
|
||
# for the reboot a capsule needs (pillar 1 — no silent BIOS writes).
|
||
# Self-gated on fwupdmgr in System; guard here too.
|
||
command -v fwupdmgr >/dev/null 2>&1 \
|
||
|| { notify-send "Firmware" "fwupd not available (services.fwupd.enable off?)."; exit 0; }
|
||
exec ${cfg.terminal} -e sh -c '
|
||
echo "== Refreshing firmware metadata (LVFS) =="
|
||
fwupdmgr refresh || true
|
||
echo
|
||
echo "== Firmware updates available =="
|
||
if fwupdmgr get-updates; then
|
||
echo
|
||
printf "Apply these updates? fwupd confirms each device. [y/N] "
|
||
read -r ans
|
||
case "$ans" in
|
||
[yY]*) fwupdmgr update ;;
|
||
*) echo "Cancelled — no firmware written." ;;
|
||
esac
|
||
else
|
||
echo "Nothing to apply on this machine."
|
||
fi
|
||
printf "\nEnter to close."; read -r _' ;;
|
||
|
||
fingerprint)
|
||
# System ▸ Fingerprint — enroll/list/delete + optional PAM for
|
||
# login/sudo (HARDWARE.md §5, BACKLOG #55). Self-gated on fprintd
|
||
# CLI (services.fprintd). Enroll is interactive → terminal.
|
||
command -v fprintd-list >/dev/null 2>&1 \
|
||
|| { notify-send "Fingerprint" "fprintd not available (nomarchy.hardware.fingerprint.enable?)."; exit 0; }
|
||
pam=$(nomarchy-state-sync get settings.fingerprint.pam 2>/dev/null || echo false)
|
||
case "$pam" in true|True) pam_label="Fingerprint (on)" ;; *) pam_label="Fingerprint (off)" ;; esac
|
||
choice=$( {
|
||
# The switch leads: it is the decision this menu exists for, and
|
||
# the rest (enroll/list/verify/delete) is the plumbing behind it —
|
||
# all still usable while the switch is off, since turning it on
|
||
# requires an enrolled finger.
|
||
row "$pam_label" system-lock-screen
|
||
row "Enroll finger" preferences-desktop-user-password
|
||
row "List enrolled" view-list
|
||
row "Verify" dialog-password
|
||
row "Delete all" edit-delete
|
||
back
|
||
} | rofi_menu -show-icons -p Fingerprint) || exit 0
|
||
case "$choice" in
|
||
"$BACK") exec "$0" system-devices ;;
|
||
*Enroll*)
|
||
exec ${cfg.terminal} -e sh -c '
|
||
echo "== Enroll fingerprint for $USER =="
|
||
fprintd-enroll || true
|
||
printf "\nEnter to close."; read -r _' ;;
|
||
*List*)
|
||
exec ${cfg.terminal} -e sh -c '
|
||
echo "== Enrolled fingers for $USER =="
|
||
fprintd-list "$USER" || true
|
||
printf "\nEnter to close."; read -r _' ;;
|
||
*Verify*)
|
||
exec ${cfg.terminal} -e sh -c '
|
||
echo "== Verify fingerprint for $USER =="
|
||
fprintd-verify || true
|
||
printf "\nEnter to close."; read -r _' ;;
|
||
*"Delete all"*)
|
||
exec ${cfg.terminal} -e sh -c '
|
||
echo "== Delete all fingerprints for $USER =="
|
||
printf "Type yes to delete every enrolled finger: "
|
||
read -r ans
|
||
case "$ans" in
|
||
yes) fprintd-delete "$USER" || true ;;
|
||
*) echo "Cancelled." ;;
|
||
esac
|
||
printf "\nEnter to close."; read -r _' ;;
|
||
*"Fingerprint ("*)
|
||
# Drives BOTH sides off one state key (sudo/login PAM + the
|
||
# hyprlock unlock), so it sudos a system rebuild and runs a home
|
||
# switch — in a terminal, like Auto timezone. The enrolled-finger
|
||
# guard and the state write live in the tool, so the CLI and this
|
||
# row can't disagree.
|
||
command -v nomarchy-fingerprint >/dev/null 2>&1 \
|
||
|| { notify-send "Fingerprint" "nomarchy-fingerprint not on PATH (hardware.fingerprint.enable?)."; exit 0; }
|
||
exec ${cfg.terminal} -e nomarchy-fingerprint toggle ;;
|
||
esac ;;
|
||
|
||
whatchanged)
|
||
# Plain-language last rebuild (system + desktop nvd). Toast the
|
||
# one-liners; open a floating terminal for the full report when
|
||
# the user wants detail (same kitty class pattern as Doctor).
|
||
if ! command -v nomarchy-what-changed >/dev/null 2>&1; then
|
||
notify-send "What changed" "nomarchy-what-changed not on PATH (lifecycle package?)."
|
||
exit 0
|
||
fi
|
||
summary=$(nomarchy-what-changed --summary 2>/dev/null || true)
|
||
[ -n "$summary" ] && notify-send -a Nomarchy "What changed" "$summary"
|
||
exec kitty --class=com.nomarchy.doctor \
|
||
-e sh -c "nomarchy-what-changed
|
||
printf '\nEnter to close.'; read -r _" ;;
|
||
|
||
# #111 Scope-first Recovery — one menu for every undo path.
|
||
# Desktop = Home Manager generations (themes/home config).
|
||
# System boot = NixOS generations in the firmware boot menu.
|
||
# Files = BTRFS snapper (files on disk / whole-root roll).
|
||
# Each path is listed once; labels say what is restored and the cost
|
||
# (instant / reboot / confirm) before the action.
|
||
recovery)
|
||
choice=$( {
|
||
row "Desktop generation — themes & home config (instant, reversible)" document-open-recent
|
||
row "System boot generation — older NixOS (reboot, then pick in boot menu)" system-reboot
|
||
command -v nomarchy-snapshots >/dev/null 2>&1 \
|
||
&& row "Files (BTRFS) — restore files or roll root (password + confirm)" timeshift
|
||
command -v nomarchy-what-changed >/dev/null 2>&1 \
|
||
&& row "What changed? — last system & desktop rebuild" view-refresh
|
||
back
|
||
} | rofi_menu -show-icons -markup-rows -p Recovery) || exit 0
|
||
case "$choice" in
|
||
"$BACK") exec "$0" system ;;
|
||
*Desktop*) exec "$0" recovery-desktop ;;
|
||
*System\ boot*)
|
||
notify-send "System boot generation" \
|
||
"Reboot and pick an older NixOS generation in the boot menu (last 10 kept). That boot is temporary — make it stick: revert in ~/.nomarchy, then nomarchy-rebuild. Details: docs/RECOVERY.md §3." ;;
|
||
*Files*) exec "$0" snapshot ;;
|
||
*"What changed"*) exec "$0" whatchanged ;;
|
||
esac ;;
|
||
|
||
recovery-desktop)
|
||
# Home Manager generations only — desktop half of RECOVERY.md §1.
|
||
gens=$(home-manager generations 2>/dev/null | head -10)
|
||
choice=$( {
|
||
if [ -n "$gens" ]; then
|
||
printf '%s\n' "$gens" | awk '{
|
||
printf "Gen %s — %s %s%s\n", $5, $1, $2, (NR==1 ? " (current)" : "")
|
||
}' | while IFS= read -r line; do row "$line" document-open-recent; done
|
||
else
|
||
row "(no Home Manager generations found)" dialog-information
|
||
fi
|
||
back
|
||
} | rofi_menu -show-icons -p "Desktop generation") || exit 0
|
||
case "$choice" in
|
||
"$BACK"|"(no "*) exec "$0" recovery ;;
|
||
"Gen "*)
|
||
num=''${choice#Gen }; num=''${num%% *}
|
||
path=$(printf '%s\n' "$gens" | awk -v n="$num" '$5 == n { print $7 }')
|
||
[ -x "$path/activate" ] \
|
||
|| { notify-send "Desktop generation" "Generation $num not found on disk."; exit 1; }
|
||
exec ${cfg.terminal} -e sh -c "
|
||
echo \"Activating Home Manager generation $num (desktop only)…\"
|
||
echo \"This does not change the NixOS system or BTRFS files.\"
|
||
echo
|
||
\"$path/activate\"
|
||
echo
|
||
echo \"Desktop is now generation $num.\"
|
||
echo \"Roll forward the same way — newer generations stay listed.\"
|
||
printf 'Enter to close.'; read -r _" ;;
|
||
esac ;;
|
||
|
||
# Compat aliases — old menu ids and muscle memory.
|
||
rollback) exec "$0" recovery ;;
|
||
|
||
tools)
|
||
# Tools submenu — utilities you invoke. Each leaf is also reachable
|
||
# directly (SUPER+CTRL+<mnemonic>); this just keeps the root short.
|
||
choice=$( {
|
||
# Live ISO only: nomarchy-install is systemPackages on the live
|
||
# host (BACKLOG #57). Installed systems never ship the package.
|
||
command -v nomarchy-install >/dev/null 2>&1 \
|
||
&& row "Install Nomarchy" system-software-install
|
||
row "Calculator${menuHint "calc"}" accessories-calculator
|
||
row "Clipboard${menuHint "clipboard"}" edit-paste
|
||
row "Emoji${menuHint "emoji"}" face-smile-big
|
||
row "Files${menuHint "files"}" system-file-manager
|
||
row "Web search${menuHint "web"}" system-search
|
||
row "Capture${menuHint "capture"}" applets-screenshooter
|
||
command -v hyprpicker >/dev/null 2>&1 && row "Color picker${menuHint "colorpicker"}" preferences-desktop-color
|
||
row "Ask AI${menuHint "ask"}" internet-chat
|
||
back
|
||
} | rofi_menu -show-icons -markup-rows -p Tools) || exit 0
|
||
case "$choice" in
|
||
"$BACK") exec "$0" ;;
|
||
*"Install Nomarchy"*)
|
||
command -v nomarchy-install >/dev/null 2>&1 \
|
||
&& exec ${cfg.terminal} -e nomarchy-install
|
||
notify-send "Install" "nomarchy-install is not on PATH (live ISO only)."
|
||
exit 0 ;;
|
||
*Calc*) exec "$0" calc ;;
|
||
*Clipboard*) exec "$0" clipboard ;;
|
||
*Emoji*) exec "$0" emoji ;;
|
||
*Files*) exec "$0" files ;;
|
||
*Web*) exec "$0" web ;;
|
||
*Capture*) exec "$0" capture ;;
|
||
*Color*) exec "$0" colorpicker ;;
|
||
*Ask*) exec "$0" ask ;;
|
||
esac ;;
|
||
|
||
system)
|
||
# #105 System IA — four groups under the same System entry so the
|
||
# root menu stays at six items (Apps / Look & Feel / Tools / System /
|
||
# Power / Keybindings). Leaves keep SUPER+CTRL mnemonics via their
|
||
# own modules; this only organizes the picker.
|
||
choice=$( {
|
||
row "Connectivity" network-wireless
|
||
row "Devices" preferences-desktop-peripherals
|
||
row "Recovery${menuHint "recovery"}" edit-undo
|
||
row "Preferences" preferences-system
|
||
back
|
||
} | rofi_menu -show-icons -markup-rows -p System) || exit 0
|
||
case "$choice" in
|
||
"$BACK") exec "$0" ;;
|
||
*Connectivity*) exec "$0" system-connectivity ;;
|
||
*Devices*) exec "$0" system-devices ;;
|
||
*Recovery*) exec "$0" recovery ;;
|
||
*Preferences*) exec "$0" system-preferences ;;
|
||
esac ;;
|
||
|
||
system-connectivity)
|
||
choice=$( {
|
||
row "Network${menuHint "network"}" network-wireless
|
||
row "VPN" network-vpn
|
||
if [ "$(nomarchy-airplane is-active 2>/dev/null)" = on ]
|
||
then row "Airplane mode (on)${menuHint "airplane"}" network-wireless-offline
|
||
else row "Airplane mode (off)${menuHint "airplane"}" network-wireless-offline
|
||
fi
|
||
command -v blueman-manager >/dev/null 2>&1 \
|
||
&& row "Bluetooth${menuHint "bluetooth"}" bluetooth
|
||
back
|
||
} | rofi_menu -show-icons -markup-rows -p Connectivity) || exit 0
|
||
case "$choice" in
|
||
"$BACK") exec "$0" system ;;
|
||
*Network*) exec "$0" network ;;
|
||
*VPN*) exec "$0" vpn ;;
|
||
*"Airplane mode"*) exec "$0" airplane ;;
|
||
*Bluetooth*) exec "$0" bluetooth ;;
|
||
esac ;;
|
||
|
||
system-devices)
|
||
choice=$( {
|
||
[ -e "''${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/pulse/native" ] \
|
||
&& row "Audio" audio-volume-high
|
||
row "Display" video-display
|
||
command -v nomarchy-keyboard-layout >/dev/null 2>&1 \
|
||
&& row "Keyboard${menuHint "keyboard"}" preferences-desktop-keyboard
|
||
command -v system-config-printer >/dev/null 2>&1 && row "Printers" printer
|
||
command -v fprintd-list >/dev/null 2>&1 \
|
||
&& row "Fingerprint" preferences-desktop-user-password
|
||
command -v fwupdmgr >/dev/null 2>&1 && row "Firmware" firmware-manager
|
||
back
|
||
} | rofi_menu -show-icons -markup-rows -p Devices) || exit 0
|
||
case "$choice" in
|
||
"$BACK") exec "$0" system ;;
|
||
*Audio*) exec "$0" audio ;;
|
||
*Display*) exec "$0" display ;;
|
||
*Keyboard*) exec "$0" keyboard ;;
|
||
*Printers*) exec "$0" printers ;;
|
||
*Fingerprint*) exec "$0" fingerprint ;;
|
||
*Firmware*) exec "$0" firmware ;;
|
||
esac ;;
|
||
|
||
system-preferences)
|
||
# Preferences + former Control Center system toggles (#110).
|
||
# Bluetooth/Printing flip NixOS bridges → rebuild in a terminal.
|
||
# Updates is HM-side (state-sync rebuilds home).
|
||
choice=$( {
|
||
row "Do Not Disturb${menuHint "dnd"}" notification-disabled
|
||
if [ "$(nomarchy-state-sync get settings.autoTimezone 2>/dev/null)" = true ]
|
||
then row "Auto timezone (on)" preferences-system-time
|
||
else row "Auto timezone (off)" preferences-system-time
|
||
fi
|
||
if command -v nomarchy-autologin >/dev/null 2>&1; then
|
||
case "$(nomarchy-state-sync get settings.greeter.autoLogin 2>/dev/null)" in
|
||
null|""|None) row "Auto-login (off)" system-users ;;
|
||
*) row "Auto-login (on)" system-users ;;
|
||
esac
|
||
fi
|
||
if [ -e "''${NOMARCHY_PATH:-$HOME/.nomarchy}/.git" ]; then
|
||
if [ "$(nomarchy-state-sync get settings.autoCommit 2>/dev/null)" = true ]
|
||
then row "Auto-commit (on)" git
|
||
else row "Auto-commit (off)" git
|
||
fi
|
||
fi
|
||
case "$(nomarchy-state-sync get settings.updates.enable 2>/dev/null)" in
|
||
true|True) row "Update checks (on)" system-software-update ;;
|
||
*) row "Update checks (off)" system-software-update ;;
|
||
esac
|
||
askp=$(nomarchy-state-sync get settings.ask.provider 2>/dev/null)
|
||
case "$askp" in ${lib.concatStringsSep "|" (lib.attrNames askProviders)}) ;; *) askp=${askDefaultProvider} ;; esac
|
||
row "Ask provider ($askp)" system-users
|
||
case "$(nomarchy-state-sync get settings.bluetooth.enable 2>/dev/null)" in
|
||
false|False) row "Bluetooth package (off)" bluetooth ;;
|
||
*) row "Bluetooth package (on)" bluetooth ;;
|
||
esac
|
||
case "$(nomarchy-state-sync get settings.printing.enable 2>/dev/null)" in
|
||
true|True) row "Printing (on)" printer ;;
|
||
*) row "Printing (off)" printer ;;
|
||
esac
|
||
if has_system_battery && command -v powerprofilesctl >/dev/null 2>&1; then
|
||
row "Power profile" preferences-system-power
|
||
fi
|
||
has_system_battery && row "Battery limit" battery-080
|
||
# #115: only offer when hibernate can work (self-gate).
|
||
if can_hibernate; then
|
||
case "$(nomarchy-state-sync get settings.power.suspendThenHibernate 2>/dev/null)" in
|
||
false|False) row "Suspend then hibernate (off)" system-suspend ;;
|
||
*) row "Suspend then hibernate (on)" system-suspend ;;
|
||
esac
|
||
fi
|
||
command -v nomarchy-doctor >/dev/null 2>&1 && row "Doctor" utilities-system-monitor
|
||
back
|
||
} | rofi_menu -show-icons -markup-rows -p Preferences) || exit 0
|
||
case "$choice" in
|
||
"$BACK") exec "$0" system ;;
|
||
*"Do Not Disturb"*) exec "$0" dnd ;;
|
||
*"Auto timezone"*) exec "$0" autotimezone ;;
|
||
*"Auto-login"*) exec "$0" autologin ;;
|
||
*"Auto-commit"*) exec "$0" autocommit ;;
|
||
*"Update checks"*) exec "$0" updates-toggle ;;
|
||
*"Ask provider"*) exec "$0" ask-provider ;;
|
||
*"Bluetooth package"*) exec "$0" bluetooth-package ;;
|
||
*Printing*) exec "$0" printing-toggle ;;
|
||
*"Power profile"*) exec "$0" power-profile ;;
|
||
*"Battery limit"*) exec "$0" batterylimit ;;
|
||
*"Suspend then hibernate"*) exec "$0" suspend-then-hibernate-toggle ;;
|
||
*Doctor*) exec "$0" doctor ;;
|
||
esac ;;
|
||
|
||
ask-provider)
|
||
# settings.ask.provider → which web chat SUPER+CTRL+A opens (#140).
|
||
# `--no-switch`: the ask handler reads this key at run time, so there
|
||
# is nothing to rebuild — the next question just goes elsewhere.
|
||
cur=$(nomarchy-state-sync get settings.ask.provider 2>/dev/null)
|
||
case "$cur" in ${lib.concatStringsSep "|" (lib.attrNames askProviders)}) ;; *) cur=${askDefaultProvider} ;; esac
|
||
choice=$( {
|
||
${lib.concatStringsSep "\n" (map
|
||
(name: " row \"${name}\" system-users") (lib.attrNames askProviders))}
|
||
back
|
||
} | rofi_menu -show-icons -p "Ask provider (now: $cur)") || exit 0
|
||
[ "$choice" = "$BACK" ] && exec "$0" system-preferences
|
||
case "$choice" in
|
||
${lib.concatStringsSep "|" (lib.attrNames askProviders)})
|
||
nomarchy-state-sync --quiet set settings.ask.provider "$choice" --no-switch
|
||
notify-send "Ask" "SUPER+CTRL+A now opens $choice." ;;
|
||
esac
|
||
exec "$0" system-preferences ;;
|
||
|
||
updates-toggle)
|
||
# settings.updates.enable → HM nomarchy.updates (Waybar checker).
|
||
case "$(nomarchy-state-sync get settings.updates.enable 2>/dev/null)" in
|
||
true|True)
|
||
nomarchy-state-sync --quiet set settings.updates.enable false
|
||
notify-send "Update checks" "Off — desktop rebuilt." ;;
|
||
*)
|
||
nomarchy-state-sync --quiet set settings.updates.enable true
|
||
notify-send "Update checks" "On — desktop rebuilt." ;;
|
||
esac
|
||
exit 0 ;;
|
||
|
||
bluetooth-package)
|
||
# settings.bluetooth.enable → NixOS hardware.bluetooth (state bridge).
|
||
# Needs a system rebuild; run it in a terminal with a clear story.
|
||
case "$(nomarchy-state-sync get settings.bluetooth.enable 2>/dev/null)" in
|
||
false|False) new=true; label=On ;;
|
||
*) new=false; label=Off ;;
|
||
esac
|
||
exec ${cfg.terminal} -e sh -c "
|
||
nomarchy-state-sync --quiet set settings.bluetooth.enable $new --no-switch
|
||
echo \"Bluetooth package → $label (system rebuild)…\"
|
||
echo
|
||
nomarchy-rebuild
|
||
echo
|
||
printf 'Enter to close.'; read -r _" ;;
|
||
|
||
suspend-then-hibernate-toggle)
|
||
# #115: settings.power.suspendThenHibernate → logind + sleep.conf.
|
||
# hypridle/Power menu pick it up live via nomarchy-suspend; lid policy
|
||
# needs the system rebuild (same shape as Bluetooth package).
|
||
can_hibernate \
|
||
|| { notify-send "Suspend then hibernate" \
|
||
"Hibernate is not available on this machine (no resume/swap path)."; exit 0; }
|
||
case "$(nomarchy-state-sync get settings.power.suspendThenHibernate 2>/dev/null)" in
|
||
false|False) new=true; label=On ;;
|
||
*) new=false; label=Off ;;
|
||
esac
|
||
exec ${cfg.terminal} -e sh -c "
|
||
nomarchy-state-sync --quiet set settings.power.suspendThenHibernate $new --no-switch
|
||
echo \"Suspend then hibernate → $label (1h on battery; system rebuild)…\"
|
||
echo
|
||
nomarchy-rebuild
|
||
echo
|
||
printf 'Enter to close.'; read -r _" ;;
|
||
|
||
printing-toggle)
|
||
case "$(nomarchy-state-sync get settings.printing.enable 2>/dev/null)" in
|
||
true|True) new=false; label=Off ;;
|
||
*) new=true; label=On ;;
|
||
esac
|
||
exec ${cfg.terminal} -e sh -c "
|
||
nomarchy-state-sync --quiet set settings.printing.enable $new --no-switch
|
||
echo \"Printing → $label (system rebuild)…\"
|
||
echo
|
||
nomarchy-rebuild
|
||
echo
|
||
printf 'Enter to close.'; read -r _" ;;
|
||
|
||
batterylimit)
|
||
# Battery charge-limit preset picker — the rofi-System counterpart of
|
||
# (Bernardo: charge limit belongs in the menu, not a raw TUI). Writes
|
||
# the baked NixOS option
|
||
# Persist + live sysfs apply (same as powermgmt). Boot/AC re-apply
|
||
# stays with the oneshot in power.nix.
|
||
has_charge_threshold \
|
||
|| { notify-send "Battery limit" \
|
||
"This machine's firmware does not expose a charge-stop control (no charge_control_end_threshold in /sys), so a charge limit cannot be set here. The battery still works normally — this is a hardware capability, not a Nomarchy problem."
|
||
exit 0; }
|
||
cur=$(nomarchy-state-sync get settings.power.batteryChargeLimit 2>/dev/null)
|
||
sel=$( {
|
||
row "80% (recommended)" battery-080
|
||
row "90%" battery-090
|
||
row "60%" battery-060
|
||
row "Off — charge to 100%" battery-100
|
||
row "Custom…" battery
|
||
back
|
||
} | rofi_menu -show-icons -p "Battery limit (now: ''${cur:-default})") || exit 0
|
||
set_limit() {
|
||
nomarchy-state-sync --quiet set settings.power.batteryChargeLimit "$1" --no-switch
|
||
n="$1"; [ "$n" = null ] && n=100
|
||
applied=
|
||
if systemctl restart nomarchy-battery-charge-limit.service 2>/dev/null; then
|
||
applied=1
|
||
else
|
||
for d in /sys/class/power_supply/*/; do
|
||
[ "$(cat "$d/type" 2>/dev/null)" = Battery ] || continue
|
||
[ "$(cat "$d/scope" 2>/dev/null || echo System)" = Device ] && continue
|
||
if [ "$n" -lt 100 ] 2>/dev/null; then
|
||
for tnode in charge_types charge_type; do
|
||
[ -w "$d$tnode" ] || continue
|
||
listed=$(cat "$d$tnode" 2>/dev/null || true)
|
||
case " $listed " in *" Custom "*|*"Custom"*) echo Custom > "$d$tnode" 2>/dev/null || true ;; esac
|
||
done
|
||
fi
|
||
thresh="$d/charge_control_end_threshold"
|
||
if [ -w "$thresh" ] && echo "$n" > "$thresh" 2>/dev/null; then
|
||
applied=1
|
||
fi
|
||
done
|
||
fi
|
||
actual=; ctype=
|
||
for d in /sys/class/power_supply/*/; do
|
||
[ "$(cat "$d/type" 2>/dev/null)" = Battery ] || continue
|
||
[ "$(cat "$d/scope" 2>/dev/null || echo System)" = Device ] && continue
|
||
[ -r "$d/charge_control_end_threshold" ] \
|
||
&& actual=$(cat "$d/charge_control_end_threshold" 2>/dev/null)
|
||
ctype=$(cat "$d/charge_types" 2>/dev/null || cat "$d/charge_type" 2>/dev/null || true)
|
||
[ -n "$actual" ] && break
|
||
done
|
||
active=$(printf '%s' "$ctype" | sed -n 's/.*\[\([^]]*\)\].*/\1/p')
|
||
extra=; [ -n "$active" ] && extra=" (type $active)"
|
||
if [ -n "$applied" ] && [ -n "$actual" ]; then
|
||
notify-send "Battery limit" "$2 — stop at $actual%$extra."
|
||
elif [ -n "$applied" ]; then
|
||
notify-send "Battery limit" "$2 — applied (could not re-read sysfs)."
|
||
else
|
||
notify-send -u critical "Battery limit" \
|
||
"Saved in theme-state but could not write sysfs (need rebuild or users-group on threshold)."
|
||
fi
|
||
}
|
||
case "$sel" in
|
||
"$BACK") exec "$0" system-preferences ;;
|
||
# Order: Off (100%) before 90/80/60 so "100%" does not hit *0%* patterns.
|
||
*Off*|*100%*) set_limit null "Off — charges to 100%" ;;
|
||
*90%*) set_limit 90 "Set to 90%" ;;
|
||
*80%*) set_limit 80 "Set to 80%" ;;
|
||
*60%*) set_limit 60 "Set to 60%" ;;
|
||
*Custom*)
|
||
limit=$(rofi_menu -p "Charge limit % (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${menuHint "tools"}" applications-utilities
|
||
row "System${menuHint "system"}" preferences-system
|
||
row "Power${menuHint "power"}" system-shutdown
|
||
row "Keybindings${menuHint "keybinds"}" preferences-desktop-keyboard
|
||
} | rofi_menu -show-icons -markup-rows -p Menu) || exit 0
|
||
case "$choice" in
|
||
*Apps*) exec rofi -show drun -theme launcher ;;
|
||
*Look*) exec "$0" lookfeel ;;
|
||
*Tools*) exec "$0" tools ;;
|
||
*System*) exec "$0" system ;;
|
||
*Power*) exec "$0" power ;;
|
||
*Keybindings*) exec "$0" keybinds ;;
|
||
esac ;;
|
||
|
||
*)
|
||
echo "usage: nomarchy-menu [lookfeel|tools|system|system-connectivity|system-devices|system-preferences|recovery|power|power-profile|powermgmt|batterylimit|theme|clipboard|calc|files|emoji|web|network|bluetooth|airplane|audio|display|display-profile|keyboard|printers|capture|colorpicker|keybinds|ask|dnd|nightlight|autotimezone|autologin|autocommit|vpn|snapshot|doctor|firmware|fingerprint|rollback|whatchanged|blur|gaps|updates-toggle|bluetooth-package|printing-toggle|suspend-then-hibernate-toggle]" >&2
|
||
exit 64 ;;
|
||
esac
|
||
'';
|
||
|
||
# VPN setup + management — a dedicated System submenu (and the custom/vpn
|
||
# Waybar indicator's click target). Goes past networkmanager_dmenu (which
|
||
# only connects existing VPNs) to importing configs and driving Tailscale:
|
||
# · NetworkManager VPN/WireGuard connections, ● active / ○ inactive, toggled
|
||
# up/down (networkmanager-group users need no sudo).
|
||
# · Import a WireGuard .conf or OpenVPN .ovpn (nmcli import; OpenVPN needs the
|
||
# networkmanager-openvpn plugin, shipped system-side).
|
||
# · Tailscale (self-gated on the CLI being present = nomarchy.services.tailscale,
|
||
# which makes the login user the operator): status is read-only; up/down/
|
||
# exit-node run inline without sudo thanks to the operator grant, falling
|
||
# back to a sudo terminal if it's absent. First login goes to a terminal.
|
||
# Secrets stay in NetworkManager's / Tailscale's own store — no new manager.
|
||
nomarchy-vpn = pkgs.writeShellScriptBin "nomarchy-vpn" ''
|
||
set -u
|
||
BACK="↩ Back"
|
||
# Own helper — this is a separate package from nomarchy-menu, so the
|
||
# menu's rofi_menu function is not in scope (was a silent no-op: every
|
||
# VPN submenu died with "rofi_menu: command not found").
|
||
rofi_menu() {
|
||
local out rc
|
||
# Release Left from kb-move-char-back first — rofi hard-fails on a
|
||
# duplicate binding (same fix as the menu's rofi_menu above).
|
||
out=$(command rofi -dmenu -i -kb-move-char-back Control+b \
|
||
-kb-custom-1 Left "$@" </dev/stdin)
|
||
rc=$?
|
||
if [ "$rc" -eq 10 ]; then
|
||
printf '%s\n' "$BACK"
|
||
return 0
|
||
elif [ "$rc" -eq 0 ]; then
|
||
printf '%s\n' "$out"
|
||
return 0
|
||
else
|
||
return 1
|
||
fi
|
||
}
|
||
|
||
conn_rows() {
|
||
nmcli -t -f NAME,TYPE,STATE connection show 2>/dev/null \
|
||
| awk -F: '$2=="vpn"||$2=="wireguard"{ printf "%s %s\n", ($3=="activated"?"●":"○"), $1 }'
|
||
}
|
||
|
||
toggle_conn() {
|
||
mark=''${1%% *}; name=''${1#* }
|
||
err=
|
||
if [ "$mark" = "●" ]; then
|
||
err=$(nmcli connection down "$name" 2>&1) \
|
||
&& notify-send "VPN" "Disconnected $name" \
|
||
|| notify-send "VPN" "Could not disconnect $name''${err:+ — $err}"
|
||
else
|
||
err=$(nmcli connection up "$name" 2>&1) \
|
||
&& notify-send "VPN" "Connected $name" \
|
||
|| notify-send "VPN" "Could not connect $name''${err:+ — $err}"
|
||
fi
|
||
}
|
||
|
||
import_config() {
|
||
file=$( { fd -e conf -e ovpn . "$HOME" --type f --hidden \
|
||
--exclude .git --exclude .cache 2>/dev/null \
|
||
| sed "s|^$HOME/||"; printf '%s\n' "$BACK"; } \
|
||
| rofi_menu -p "Import VPN config" ) || return
|
||
{ [ -z "$file" ] || [ "$file" = "$BACK" ]; } && return
|
||
case "$file" in *.ovpn) type=openvpn ;; *) type=wireguard ;; esac
|
||
err=$(nmcli connection import type "$type" file "$HOME/$file" 2>&1) \
|
||
&& notify-send "VPN" "Imported $type config: $(basename "$file")" \
|
||
|| notify-send "VPN" "Import failed''${err:+ — $err}"
|
||
}
|
||
|
||
ts_state() { tailscale status --json 2>/dev/null | jq -r '.BackendState // "Unknown"'; }
|
||
# Run a privileged tailscale subcommand. nomarchy.services.tailscale makes
|
||
# the login user the operator, so this runs inline (no sudo); if the operator
|
||
# grant is absent (downstream dropped it) it falls back to a sudo terminal.
|
||
ts_priv() {
|
||
if tailscale "$@" >/dev/null 2>&1; then notify-send "Tailscale" "tailscale $*"
|
||
else ${cfg.terminal} -e sudo tailscale "$@"; fi
|
||
}
|
||
ts_exit_node() {
|
||
# Rows show "hostname — Country City" (Mullvad-style nodes carry a
|
||
# location). The COUNTRY/CITY columns are fixed-width and multi-word,
|
||
# so they're sliced by the header's column offsets, never by field
|
||
# splitting; hostnames have no spaces, so the pick strips back to the
|
||
# hostname at the first space. Locationless nodes stay a bare hostname.
|
||
node=$( { printf 'none\n'; tailscale exit-node list 2>/dev/null | awk '
|
||
NR==1 { c=index($0,"COUNTRY"); s=index($0,"STATUS"); next }
|
||
$2 {
|
||
loc=""
|
||
if (c > 0) {
|
||
end = (s > c) ? s : length($0) + 1
|
||
loc = substr($0, c, end - c)
|
||
gsub(/ +/, " ", loc); sub(/^ /, "", loc); sub(/ +$/, "", loc)
|
||
}
|
||
if (loc != "" && loc !~ /^[- ]+$/) print $2 " — " loc
|
||
else print $2
|
||
}'; \
|
||
printf '%s\n' "$BACK"; } | rofi_menu -p "Exit node" ) || return
|
||
{ [ -z "$node" ] || [ "$node" = "$BACK" ]; } && return
|
||
node=''${node%% *} # display row → hostname
|
||
[ "$node" = none ] && node=""
|
||
ts_priv set --exit-node="$node"
|
||
}
|
||
tailscale_menu() {
|
||
while :; do
|
||
st=$(ts_state)
|
||
choice=$( {
|
||
if [ "$st" = Running ]; then printf 'Disconnect\n'; else printf 'Connect\n'; fi
|
||
printf 'Exit node…\n'
|
||
printf '%s\n' "$BACK"
|
||
} | rofi_menu -p "Tailscale ($st)" ) || return
|
||
case "$choice" in
|
||
"$BACK"|"") return ;;
|
||
# Authed-but-down → bring it up inline; otherwise it needs an
|
||
# interactive login, so show it in a terminal (URL visible).
|
||
Connect) if [ "$st" = Stopped ]; then ts_priv up; else ${cfg.terminal} -e sudo tailscale up; fi ;;
|
||
Disconnect) ts_priv down ;;
|
||
"Exit node…") ts_exit_node ;;
|
||
esac
|
||
done
|
||
}
|
||
|
||
while :; do
|
||
have_ts=""
|
||
command -v tailscale >/dev/null 2>&1 && have_ts=1
|
||
rows=$(conn_rows)
|
||
choice=$( {
|
||
if [ -n "$rows" ]; then printf '%s\n' "$rows"
|
||
else printf 'No VPNs yet — import a config or enable Tailscale\n'
|
||
fi
|
||
printf 'Import config…\n'
|
||
[ -n "$have_ts" ] && printf 'Tailscale ›\n'
|
||
printf '%s\n' "$BACK"
|
||
} | rofi_menu -p VPN ) || exit 0
|
||
case "$choice" in
|
||
"$BACK"|"") exec nomarchy-menu system ;;
|
||
"No VPNs yet"*) ;; # hint row — stay in menu
|
||
"Import config…") import_config ;;
|
||
"Tailscale ›") tailscale_menu ;;
|
||
"● "*|"○ "*) toggle_conn "$choice" ;;
|
||
esac
|
||
done
|
||
'';
|
||
in
|
||
{
|
||
config = lib.mkIf cfg.rofi.enable {
|
||
home.packages = [
|
||
nomarchy-menu
|
||
nomarchy-vpn # VPN submenu (NM connect/import + Tailscale)
|
||
pkgs.fd # files module (fuzzy search over $HOME)
|
||
pkgs.xdg-utils # xdg-open for the files + web modules
|
||
pkgs.networkmanager_dmenu # network module: rofi wifi/VPN picker
|
||
pkgs.rofi-pulse-select # audio module: sink/source switcher
|
||
# Capture › OCR region: English only — the unscoped wrapper drags
|
||
# in every language's traineddata, a real cost on a compile-from-
|
||
# source distro. More languages: override in your home.nix.
|
||
(pkgs.tesseract.override { enableLanguages = [ "eng" ]; })
|
||
];
|
||
|
||
# networkmanager_dmenu drives rofi (not bare dmenu) and uses rofi's
|
||
# active/urgent row styling for the connected/available networks, so it
|
||
# inherits the generated theme like every other menu module. Editing a
|
||
# connection drops to nmtui in the configured terminal. force: the tool
|
||
# writes a default config on first run, so an unmanaged file may already
|
||
# sit at this path — we fully own it, so overwrite rather than abort.
|
||
# compact: with False the script inserts EMPTY rows as section
|
||
# separators (eth/wifi/vpn) — blank entries in a themed picker read as
|
||
# breakage (item 22). True drops them and uses the unpadded row format,
|
||
# which also sits better in rofi's proportional font than the
|
||
# space-aligned columns. (A hidden-SSID AP still renders one nameless
|
||
# row — sec+bars only; not filterable via config, dedup keeps one.)
|
||
xdg.configFile."networkmanager-dmenu/config.ini" = {
|
||
force = true;
|
||
text = ''
|
||
[dmenu]
|
||
# -i: networkmanager_dmenu uses dmenu mode (case-sensitive by
|
||
# default); match nomarchy-menu's case-insensitive filter.
|
||
dmenu_command = rofi -i
|
||
rofi_highlight = True
|
||
compact = True
|
||
wifi_chars = ▂▄▆█
|
||
|
||
[editor]
|
||
terminal = ${cfg.terminal}
|
||
gui_if_available = False
|
||
'';
|
||
};
|
||
|
||
# The launcher theme lives beside HM's active base theme, which it
|
||
# @imports so the default launcher is byte-for-byte that theme's list.
|
||
# HM names that base by its SOURCE: a generated (attrset) theme lands as
|
||
# custom.rasi, but a whole-swap theme (a path — themes/<slug>/rofi.rasi)
|
||
# keeps its basename, rofi.rasi. Import must match, or `-theme launcher`
|
||
# dies with "custom.rasi not found" (rofi resolves the missing import
|
||
# against $HOME) on every whole-swap theme.
|
||
xdg.dataFile."rofi/themes/launcher.rasi".text =
|
||
if hasLauncherOverride
|
||
then builtins.readFile launcherOverride
|
||
else ''@import "${if hasRasiOverride then "rofi.rasi" else "custom.rasi"}"'';
|
||
|
||
programs.rofi = {
|
||
enable = true;
|
||
terminal = cfg.terminal;
|
||
font = "${t.fonts.ui} ${toString t.fonts.size}";
|
||
|
||
# Native rofi modi backing two menu modules: calc (live, via
|
||
# libqalculate — no qalc CLI needed) and emoji (glyph picker, copies
|
||
# via its bundled Wayland adapter). Both render through the
|
||
# generated/whole-swap theme like every other modi.
|
||
plugins = [ pkgs.rofi-calc pkgs.rofi-emoji ];
|
||
|
||
extraConfig = {
|
||
modi = "drun,run";
|
||
# Rofi defaults to the output containing the mouse pointer (-5).
|
||
# After docking with the lid shut that pointer can remain on the hidden
|
||
# laptop workspace while keyboard focus is on the external display.
|
||
monitor = -1; # Hyprland's currently focused output
|
||
show-icons = true; # app icons via the theme's icon set
|
||
icon-theme = t.iconTheme; # resolved in theme.nix (Papirus-*/per-theme)
|
||
drun-display-format = "{name}";
|
||
display-drun = "apps";
|
||
display-run = "run";
|
||
# Size the window to the actual number of rows instead of always
|
||
# reserving `lines` (8) — so a 6-entry menu doesn't leave empty space,
|
||
# while the app launcher still fills + scrolls past `lines`.
|
||
fixed-num-lines = false;
|
||
# Search: case-insensitive, substring first + fuzzy forgiveness.
|
||
# "ste" must find "Steam", "system" finds "System", "fzr" still
|
||
# reaches "Firefox". Comma list = match if ANY method hits (rofi 1.7+).
|
||
#
|
||
# History: fuzzy-only + fzf sorter mis-ranked (ghostty over Steam);
|
||
# fuzzy-only still failed short lowercase queries on hardware
|
||
# 2026-07-10 despite case-sensitive=false. normal covers the
|
||
# prefix/substring case; fuzzy stays for typo-tolerant hits.
|
||
# case-smart off so all-lowercase never flips to case-sensitive.
|
||
matching = "normal,fuzzy";
|
||
case-sensitive = false;
|
||
case-smart = false;
|
||
sort = true;
|
||
sorting-method = "normal";
|
||
};
|
||
|
||
# Whole-swap themes bring their own rofi.rasi; otherwise the theme
|
||
# is generated from the palette.
|
||
theme =
|
||
if hasRasiOverride then rasiOverride
|
||
else {
|
||
"*" = {
|
||
bg = mkLiteral c.base;
|
||
fg = mkLiteral c.text;
|
||
# @dim/@surface are fg-derived #RRGGBBAA tints, NOT the
|
||
# palette's subtext/surface roles — "on-surface" palettes
|
||
# (flexoki-light: subtext==base, surface==text) made the
|
||
# inputbar, textbox and alternate rows invisible (item 27;
|
||
# guarded by tools/check-theme-contrast.py).
|
||
dim = mkLiteral (c.text + "80");
|
||
surface = mkLiteral (c.text + "1a");
|
||
accent = mkLiteral c.accent;
|
||
|
||
background-color = mkLiteral "transparent";
|
||
text-color = mkLiteral "@fg";
|
||
margin = 0;
|
||
padding = 0;
|
||
spacing = 0;
|
||
};
|
||
|
||
"window" = {
|
||
background-color = mkLiteral "@bg";
|
||
border = px t.ui.borderSize;
|
||
border-color = mkLiteral "@accent";
|
||
border-radius = px t.ui.rounding;
|
||
# Sized in `ch` — the width of one digit in *this theme's* font —
|
||
# not in % of the monitor (#131). A menu has to fit its longest
|
||
# label, and that is a count of characters, so the window must
|
||
# scale with the font: 40% of a 1366 panel and 40% of a 2560 one
|
||
# give the same row 546px and 1024px, while a 14pt monospace theme
|
||
# needs ~25% more room than an 11pt one on both. 84ch fits the
|
||
# longest row we ship (69 chars) plus icon and padding, measured on
|
||
# hardware: 756px in GeistMono 11, 924px in JetBrainsMono 14.
|
||
# The `min` cap is what keeps that honest on a small screen — the
|
||
# window may never eat more than 65% of the panel, so a big font on
|
||
# a 1366 gets a capped 888px rather than sprawling. Grid views
|
||
# (theme picker, drun) override this per-invocation; they are sized
|
||
# by icons, not text.
|
||
width = mkLiteral "calc( 84ch min 65% )";
|
||
padding = px 8;
|
||
};
|
||
|
||
"mainbox" = {
|
||
children = map mkLiteral [ "inputbar" "message" "listview" ];
|
||
};
|
||
|
||
"inputbar" = {
|
||
background-color = mkLiteral "@surface";
|
||
border-radius = px t.ui.rounding;
|
||
padding = mkLiteral "8px 12px";
|
||
spacing = px 8;
|
||
children = map mkLiteral [ "prompt" "entry" ];
|
||
};
|
||
"prompt" = { text-color = mkLiteral "@accent"; };
|
||
"entry" = {
|
||
placeholder = "Search…";
|
||
placeholder-color = mkLiteral "@dim";
|
||
};
|
||
|
||
"listview" = {
|
||
lines = 8;
|
||
columns = 1;
|
||
dynamic = true;
|
||
# Scrollbar only draws when lines < rows (rofi self-hides on
|
||
# short menus). Overflow hint for long lists / launcher grids.
|
||
scrollbar = true;
|
||
spacing = px 4; # a little air so the rounded zebra rows separate
|
||
padding = mkLiteral "8px 0px 0px 0px";
|
||
};
|
||
|
||
"scrollbar" = {
|
||
width = px 4;
|
||
handle-width = px 4;
|
||
handle-color = mkLiteral "@accent";
|
||
background-color = mkLiteral "@surface";
|
||
border = px 0;
|
||
};
|
||
|
||
# Rounded, roomy rows with subtle zebra striping: alternate rows
|
||
# lift to @surface, the selected row to @accent. normal/alternate/
|
||
# selected are rofi's row parities (the `.normal` suffix is the
|
||
# match-state, untouched by our menus).
|
||
"element" = {
|
||
padding = mkLiteral "10px 14px";
|
||
border-radius = px t.ui.rounding;
|
||
spacing = px 12; # gap between the (larger) icon and the label
|
||
};
|
||
"element normal.normal" = {
|
||
background-color = mkLiteral "@bg";
|
||
text-color = mkLiteral "@fg";
|
||
};
|
||
"element alternate.normal" = {
|
||
background-color = mkLiteral "@surface";
|
||
text-color = mkLiteral "@fg";
|
||
};
|
||
"element selected.normal" = {
|
||
background-color = mkLiteral "@accent";
|
||
text-color = mkLiteral "@bg";
|
||
};
|
||
"element-icon" = {
|
||
background-color = mkLiteral "transparent";
|
||
size = px t.ui.iconSize; # real icons (Papirus), not 1em font glyphs
|
||
};
|
||
"element-text" = {
|
||
background-color = mkLiteral "transparent";
|
||
text-color = mkLiteral "inherit";
|
||
};
|
||
|
||
"message" = { padding = mkLiteral "8px 0px 0px 0px"; };
|
||
"textbox" = {
|
||
background-color = mkLiteral "@surface";
|
||
text-color = mkLiteral "@fg";
|
||
border-radius = px t.ui.rounding;
|
||
padding = mkLiteral "8px 12px";
|
||
};
|
||
};
|
||
};
|
||
};
|
||
}
|