All checks were successful
Check / eval (push) Successful in 6m16s
Human decision: 1h delay, battery only, Preferences enable/disable. Wire systemd suspend-then-hibernate + HibernateDelaySec=1h + HibernateOnACPower=false when settings.power.suspendThenHibernate is on and boot.resumeDevice is set. Lid: s2h undocked, plain suspend on AC. nomarchy-suspend for hypridle + Power menu (live state). LUKS unlock before encrypted hibernate so s2h→disk is not double-password. state-bridges assert the policy; HARDWARE-QUEUE #115 + suggested order. V1: nix flake check --no-build. V3: bag-carry on hardware.
1887 lines
92 KiB
Nix
1887 lines
92 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;
|
||
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 → claude CLI in a terminal (OAuth, no API key).
|
||
# Pulled fresh from npm via npx rather than nixpkgs' claude-code,
|
||
# which lags behind model releases; npx caches after first run. The
|
||
# REPL stays open for follow-ups.
|
||
q=$(rofi_menu -p claude < /dev/null) || exit 0
|
||
{ [ -n "$q" ] && [ "$q" != "$BACK" ]; } || exit 0
|
||
exec ${cfg.terminal} -e npx --yes @anthropic-ai/claude-code@latest "$q" ;;
|
||
|
||
dnd)
|
||
# Toggle swaync Do-Not-Disturb (-d prints the new state). With DND
|
||
# on, notifications are suppressed — the Waybar bell-off icon is the
|
||
# cue — so only confirm with a toast when turning it back off.
|
||
[ "$(swaync-client -d)" = "false" ] \
|
||
&& notify-send "Do Not Disturb off" "Notifications resumed."
|
||
exit 0 ;;
|
||
|
||
nightlight)
|
||
# Force the scheduled blue-light filter on/off for the session by
|
||
# starting/stopping hyprsunset (nomarchy-nightlight, from nightlight.nix).
|
||
# Self-gated in the menu; guard here too in case it's invoked directly.
|
||
command -v nomarchy-nightlight >/dev/null 2>&1 \
|
||
&& exec nomarchy-nightlight toggle
|
||
notify-send "Night light" "Not available (nomarchy.nightlight off?)."; exit 0 ;;
|
||
|
||
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. Exit code doesn't matter; the sheet is the product.
|
||
exec kitty --class=com.nomarchy.doctor \
|
||
-e sh -c "nomarchy-doctor
|
||
printf '\nEnter to close.'; read -r _" ;;
|
||
|
||
firmware)
|
||
# System ▸ Firmware — fwupd/LVFS update surface (HARDWARE.md §4).
|
||
# Never auto-flashes: refresh metadata, list what LVFS offers, then
|
||
# hand to `fwupdmgr update`, which confirms each device and prompts
|
||
# for the reboot a capsule needs (pillar 1 — no silent BIOS writes).
|
||
# Self-gated on fwupdmgr in System; guard here too.
|
||
command -v fwupdmgr >/dev/null 2>&1 \
|
||
|| { notify-send "Firmware" "fwupd not available (services.fwupd.enable off?)."; exit 0; }
|
||
exec ${cfg.terminal} -e sh -c '
|
||
echo "== Refreshing firmware metadata (LVFS) =="
|
||
fwupdmgr refresh || true
|
||
echo
|
||
echo "== Firmware updates available =="
|
||
if fwupdmgr get-updates; then
|
||
echo
|
||
printf "Apply these updates? fwupd confirms each device. [y/N] "
|
||
read -r ans
|
||
case "$ans" in
|
||
[yY]*) fwupdmgr update ;;
|
||
*) echo "Cancelled — no firmware written." ;;
|
||
esac
|
||
else
|
||
echo "Nothing to apply on this machine."
|
||
fi
|
||
printf "\nEnter to close."; read -r _' ;;
|
||
|
||
fingerprint)
|
||
# System ▸ Fingerprint — enroll/list/delete + optional PAM for
|
||
# login/sudo (HARDWARE.md §5, BACKLOG #55). Self-gated on fprintd
|
||
# CLI (services.fprintd). Enroll is interactive → terminal.
|
||
command -v fprintd-list >/dev/null 2>&1 \
|
||
|| { notify-send "Fingerprint" "fprintd not available (nomarchy.hardware.fingerprint.enable?)."; exit 0; }
|
||
pam=$(nomarchy-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 Claude${menuHint "ask"}" internet-chat
|
||
back
|
||
} | rofi_menu -show-icons -markup-rows -p Tools) || exit 0
|
||
case "$choice" in
|
||
"$BACK") exec "$0" ;;
|
||
*"Install Nomarchy"*)
|
||
command -v nomarchy-install >/dev/null 2>&1 \
|
||
&& exec ${cfg.terminal} -e nomarchy-install
|
||
notify-send "Install" "nomarchy-install is not on PATH (live ISO only)."
|
||
exit 0 ;;
|
||
*Calc*) exec "$0" calc ;;
|
||
*Clipboard*) exec "$0" clipboard ;;
|
||
*Emoji*) exec "$0" emoji ;;
|
||
*Files*) exec "$0" files ;;
|
||
*Web*) exec "$0" web ;;
|
||
*Capture*) exec "$0" capture ;;
|
||
*Color*) exec "$0" colorpicker ;;
|
||
*Ask*) exec "$0" ask ;;
|
||
esac ;;
|
||
|
||
system)
|
||
# #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
|
||
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 ;;
|
||
*"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 ;;
|
||
|
||
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.nodejs # npx, for the Ask Claude module (claude-code from npm)
|
||
pkgs.networkmanager_dmenu # network module: rofi wifi/VPN picker
|
||
pkgs.rofi-pulse-select # audio module: sink/source switcher
|
||
# Capture › OCR region: English only — the unscoped wrapper drags
|
||
# in every language's traineddata, a real cost on a compile-from-
|
||
# source distro. More languages: override in your home.nix.
|
||
(pkgs.tesseract.override { enableLanguages = [ "eng" ]; })
|
||
];
|
||
|
||
# networkmanager_dmenu drives rofi (not bare dmenu) and uses rofi's
|
||
# active/urgent row styling for the connected/available networks, so it
|
||
# inherits the generated theme like every other menu module. Editing a
|
||
# connection drops to nmtui in the configured terminal. force: the tool
|
||
# writes a default config on first run, so an unmanaged file may already
|
||
# sit at this path — we fully own it, so overwrite rather than abort.
|
||
# compact: with False the script inserts EMPTY rows as section
|
||
# separators (eth/wifi/vpn) — blank entries in a themed picker read as
|
||
# breakage (item 22). True drops them and uses the unpadded row format,
|
||
# which also sits better in rofi's proportional font than the
|
||
# space-aligned columns. (A hidden-SSID AP still renders one nameless
|
||
# row — sec+bars only; not filterable via config, dedup keeps one.)
|
||
xdg.configFile."networkmanager-dmenu/config.ini" = {
|
||
force = true;
|
||
text = ''
|
||
[dmenu]
|
||
# -i: networkmanager_dmenu uses dmenu mode (case-sensitive by
|
||
# default); match nomarchy-menu's case-insensitive filter.
|
||
dmenu_command = rofi -i
|
||
rofi_highlight = True
|
||
compact = True
|
||
wifi_chars = ▂▄▆█
|
||
|
||
[editor]
|
||
terminal = ${cfg.terminal}
|
||
gui_if_available = False
|
||
'';
|
||
};
|
||
|
||
# The launcher theme lives beside HM's active base theme, which it
|
||
# @imports so the default launcher is byte-for-byte that theme's list.
|
||
# HM names that base by its SOURCE: a generated (attrset) theme lands as
|
||
# custom.rasi, but a whole-swap theme (a path — themes/<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;
|
||
width = mkLiteral "40%";
|
||
padding = px 8;
|
||
};
|
||
|
||
"mainbox" = {
|
||
children = map mkLiteral [ "inputbar" "message" "listview" ];
|
||
};
|
||
|
||
"inputbar" = {
|
||
background-color = mkLiteral "@surface";
|
||
border-radius = px t.ui.rounding;
|
||
padding = mkLiteral "8px 12px";
|
||
spacing = px 8;
|
||
children = map mkLiteral [ "prompt" "entry" ];
|
||
};
|
||
"prompt" = { text-color = mkLiteral "@accent"; };
|
||
"entry" = {
|
||
placeholder = "Search…";
|
||
placeholder-color = mkLiteral "@dim";
|
||
};
|
||
|
||
"listview" = {
|
||
lines = 8;
|
||
columns = 1;
|
||
dynamic = true;
|
||
# Scrollbar only draws when lines < rows (rofi self-hides on
|
||
# short menus). Overflow hint for long lists / launcher grids.
|
||
scrollbar = true;
|
||
spacing = px 4; # a little air so the rounded zebra rows separate
|
||
padding = mkLiteral "8px 0px 0px 0px";
|
||
};
|
||
|
||
"scrollbar" = {
|
||
width = px 4;
|
||
handle-width = px 4;
|
||
handle-color = mkLiteral "@accent";
|
||
background-color = mkLiteral "@surface";
|
||
border = px 0;
|
||
};
|
||
|
||
# Rounded, roomy rows with subtle zebra striping: alternate rows
|
||
# lift to @surface, the selected row to @accent. normal/alternate/
|
||
# selected are rofi's row parities (the `.normal` suffix is the
|
||
# match-state, untouched by our menus).
|
||
"element" = {
|
||
padding = mkLiteral "10px 14px";
|
||
border-radius = px t.ui.rounding;
|
||
spacing = px 12; # gap between the (larger) icon and the label
|
||
};
|
||
"element normal.normal" = {
|
||
background-color = mkLiteral "@bg";
|
||
text-color = mkLiteral "@fg";
|
||
};
|
||
"element alternate.normal" = {
|
||
background-color = mkLiteral "@surface";
|
||
text-color = mkLiteral "@fg";
|
||
};
|
||
"element selected.normal" = {
|
||
background-color = mkLiteral "@accent";
|
||
text-color = mkLiteral "@bg";
|
||
};
|
||
"element-icon" = {
|
||
background-color = mkLiteral "transparent";
|
||
size = px t.ui.iconSize; # real icons (Papirus), not 1em font glyphs
|
||
};
|
||
"element-text" = {
|
||
background-color = mkLiteral "transparent";
|
||
text-color = mkLiteral "inherit";
|
||
};
|
||
|
||
"message" = { padding = mkLiteral "8px 0px 0px 0px"; };
|
||
"textbox" = {
|
||
background-color = mkLiteral "@surface";
|
||
text-color = mkLiteral "@fg";
|
||
border-radius = px t.ui.rounding;
|
||
padding = mkLiteral "8px 12px";
|
||
};
|
||
};
|
||
};
|
||
};
|
||
}
|