All checks were successful
Check / eval (push) Successful in 2m53s
Follow-on to the swaync fix (a643391): the generated Waybar CSS colored
the module row and #window @subtext and dim states @muted, and the
generated rofi theme put @fg text on @surface chips — invisible where
palettes use those roles as "on-surface" (flexoki-light: subtext==base,
surface==text; gruvbox: muted/base = 1.27). flexoki-light has no
whole-swap, so it ran this CSS. Generated surfaces only — the summer
whole-swaps are hand-authored and untouched.
- waybar: secondary text → alpha(@text, 0.85); dim states (inactive
workspaces, DND bell, muted volume) → alpha(@text, 0.5).
- rofi: @dim → text+"80", @surface → text+"1a" (#RRGGBBAA fg tints).
- check-theme-contrast.py pairings/docs now cover all three generated
surfaces; status glyphs (good/warn/bad on base, worst 2.05) are
deliberately not held to a text ratio — raising them is palette
design, a human decision.
Verified: V0; V1 (checks.theme-contrast green over 21 themes;
downstream-template-home builds; rendered waybar style has zero
@subtext/@muted/@surface; rendered custom.rasi shows fg-derived
tints). Honesty: NOT visually verified — V3 queued (flexoki-light
eyeball + dark-theme regression spot-check).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
791 lines
36 KiB
Nix
791 lines
36 KiB
Nix
# 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;
|
||
|
||
# ── 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" ] [ "?" "/" ] b.key;
|
||
# ? already implies Shift — the bind needs SHIFT in its modmask
|
||
# (keybinds.nix), but showing it would read double, so collapse
|
||
# "SHIFT + ?" back to the documented "?".
|
||
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)
|
||
++ map (e: padRight 22 e.keys + e.desc) keybinds.extra;
|
||
cheatsheetFile = pkgs.writeText "nomarchy-keybinds.txt"
|
||
(lib.concatStringsSep "\n" cheatRows + "\n");
|
||
|
||
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.
|
||
|
||
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"; }
|
||
|
||
# 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). `back`
|
||
# emits it with an icon; plain pick-lists append the bare label instead.
|
||
# Always matched EXACTLY ("↩ Back") so it can't collide with clipboard or
|
||
# filename content. The arrow + go-previous icon read as "go back".
|
||
BACK="↩ Back"
|
||
back() { row "$BACK" go-previous; }
|
||
|
||
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)
|
||
choice=$( { powerprofilesctl list 2>/dev/null | sed -nE 's/^[* ] ([a-z-]+):$/\1/p'; printf '%s\n' "$BACK"; } \
|
||
| rofi -dmenu -p "profile (now: $cur)") || exit 0
|
||
[ "$choice" = "$BACK" ] && exec "$0" system
|
||
[ -n "$choice" ] && powerprofilesctl set "$choice" ;;
|
||
|
||
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).
|
||
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).
|
||
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).
|
||
if [ "$(printf '%s' "$mons" | jq -r '.[].name' | grep -c .)" -gt 1 ]; then
|
||
name=$( {
|
||
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=''${name%% ·*} # strip the " · WxH@R" hint
|
||
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" display
|
||
[ -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)
|
||
choice=$( {
|
||
row "Region → clipboard" applets-screenshooter
|
||
row "Region → file" applets-screenshooter
|
||
row "Full screen → clipboard" camera-photo
|
||
row "Full screen → file" camera-photo
|
||
back
|
||
} | rofi -dmenu -show-icons -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" ;;
|
||
*"Full screen → clipboard"*) grim - | wl-copy ;;
|
||
*"Full screen → file"*) mkdir -p "$dir"; grim "$file" \
|
||
&& notify-send "Screenshot saved" "$file" ;;
|
||
esac ;;
|
||
|
||
keybinds)
|
||
# Read-only cheatsheet, generated from keybinds.nix at build time.
|
||
rofi -dmenu -i -p keys < ${cheatsheetFile} >/dev/null || 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 ;;
|
||
|
||
tools)
|
||
# Tools submenu — utilities you invoke. Each leaf is also reachable
|
||
# directly (SUPER+CTRL+<mnemonic>); this just keeps the root short.
|
||
choice=$( {
|
||
row "Calculator" accessories-calculator
|
||
row "Clipboard" edit-paste
|
||
row "Emoji" face-smile-big
|
||
row "Files" system-file-manager
|
||
row "Web search" system-search
|
||
row "Capture" applets-screenshooter
|
||
row "Ask Claude" internet-chat
|
||
back
|
||
} | rofi -dmenu -show-icons -p Tools) || exit 0
|
||
case "$choice" in
|
||
"$BACK") exec "$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 ;;
|
||
*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.
|
||
bats=(/sys/class/power_supply/BAT*)
|
||
choice=$( {
|
||
row "Network" network-wireless
|
||
row "VPN" network-vpn
|
||
row "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" notification-disabled
|
||
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
|
||
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
|
||
if [ -e "''${bats[0]}" ] && command -v powerprofilesctl >/dev/null 2>&1; then
|
||
row "Power profile" preferences-system-power
|
||
fi
|
||
back
|
||
} | rofi -dmenu -show-icons -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 ;;
|
||
*"Night light"*) exec "$0" nightlight ;;
|
||
*"Auto timezone"*) exec "$0" autotimezone ;;
|
||
*"Auto-commit"*) exec "$0" autocommit ;;
|
||
*Snapshots*) exec "$0" snapshot ;;
|
||
*"Power profile"*) exec "$0" power-profile ;;
|
||
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" applications-all
|
||
row "Theme" preferences-desktop-theme
|
||
row "Tools" applications-utilities
|
||
row "System" preferences-system
|
||
row "Power" system-shutdown
|
||
row "Keybindings" preferences-desktop-keyboard
|
||
} | rofi -dmenu -show-icons -p Menu) || exit 0
|
||
case "$choice" in
|
||
*Apps*) exec rofi -show drun ;;
|
||
*Theme*) exec "$0" theme ;;
|
||
*Tools*) exec "$0" tools ;;
|
||
*System*) exec "$0" system ;;
|
||
*Power*) exec "$0" power ;;
|
||
*Keybindings*) exec "$0" keybinds ;;
|
||
esac ;;
|
||
|
||
*)
|
||
echo "usage: nomarchy-menu [tools|system|power|power-profile|theme|clipboard|calc|files|emoji|web|network|bluetooth|audio|display|printers|capture|keybinds|ask|dnd|nightlight|autotimezone|autocommit|vpn|snapshot]" >&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
|
||
];
|
||
|
||
# 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.
|
||
xdg.configFile."networkmanager-dmenu/config.ini" = {
|
||
force = true;
|
||
text = ''
|
||
[dmenu]
|
||
dmenu_command = rofi
|
||
rofi_highlight = True
|
||
compact = False
|
||
wifi_chars = ▂▄▆█
|
||
|
||
[editor]
|
||
terminal = ${cfg.terminal}
|
||
gui_if_available = False
|
||
'';
|
||
};
|
||
|
||
programs.rofi = {
|
||
enable = true;
|
||
terminal = cfg.terminal;
|
||
font = "${t.fonts.ui} 12";
|
||
|
||
# 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". fzf sorting ranks
|
||
# the closest match to the top (the per-keystroke modules — calc/emoji
|
||
# — opt out per-invocation with -no-sort).
|
||
matching = "fuzzy";
|
||
case-sensitive = false;
|
||
sort = true;
|
||
sorting-method = "fzf";
|
||
};
|
||
|
||
# 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 = false;
|
||
spacing = px 4; # a little air so the rounded zebra rows separate
|
||
padding = mkLiteral "8px 0px 0px 0px";
|
||
};
|
||
|
||
# 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";
|
||
};
|
||
};
|
||
};
|
||
};
|
||
}
|