All checks were successful
Check / eval (push) Successful in 3m7s
Bernardo, post-reboot: "Use for login" was the wrong question. Whether the
finger works is one decision, not two, and whether login prompts at all is
a different decision that was never in the menu.
System › Fingerprint is now a single Fingerprint (on/off) switch, leading
the menu with enroll/list/verify/delete as the plumbing behind it. It
writes the one settings.fingerprint.pam key, and modules/home/idle.nix now
defaults idle.fingerprint from that same key — so the lock screen and
login/sudo move together instead of drifting apart the way they did until
e2de906. nomarchy-fingerprint does the two rebuilds this needs (sudo
system for PAM, home switch for hyprlock) and refuses to turn on with no
finger enrolled.
System › Auto-login is new (nomarchy-autologin), and it is what decides
whether anything is asked at boot: auto-login on means no prompt whatever
the fingerprint switch says; off means the greeter asks, for a password or
a finger. Installer-seeded ON for LUKS machines — the passphrase already
gates the disk — and off without it, where the greeter is the only thing
between power-on and the desktop.
Both had to become state-owned to be toggleable at all, which surfaced two
real bugs:
* nomarchy.system.greeter.autoLogin defaulted from
`config.nomarchy.settings…` — an attribute that exists ONLY on the Home
Manager side. On NixOS it is absent and `or null` swallowed the error,
so the default silently evaluated to null on every machine ever built.
That is why the installer baked a Nix line: the state path never
worked. Now read via theme-state-read.nix (the hardware.nix/timezone.nix
pattern) and mkDefault'd, so the menu owns it and a hand-set line still
pins it. Two more options read the same phantom bridge — BACKLOG #116.
* `theme-sync get` printed Python's "None" for a JSON null, so every
`case … null)` a caller writes would miss. Now prints "null", as the
comment above it already promised for booleans.
The installer seeds the state instead of emitting the system.nix line,
because that line outranks the state and would strand the toggle.
V1 (V3 pending: HARDWARE-QUEUE). nix flake check --no-build, installer-
safety and option-docs all pass. Proved by eval/build, not assumed: a state
carrying autoLogin yields greetd initial_session {"user":"bernardo"}, the
template state (no autoLogin) yields none, and a hand-set null beats a state
that says otherwise; a state with only fingerprint.pam=true — nothing set by
hand — renders the hyprlock auth.fingerprint block; both new tools pass
bash -n and land in systemPackages (nomarchy-fingerprint only with a
reader); the patcher writes settings.greeter.autoLogin and no system.nix
line; and the get round trip prints null, so the menu reads "Auto-login
(off)" where it would have read "(on)".
The reader itself, the two rebuilds, and the reboot are hardware — queued.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1675 lines
84 KiB
Nix
1675 lines
84 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;
|
||
|
||
# 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-theme-sync applies (pretty names ≠ slugs, hence the map). Themes
|
||
# with no preview degrade to a plain-name row.
|
||
themeRows = lib.concatMapStringsSep "\n" (th:
|
||
let label = th.name + lib.optionalString (th.slug == t.slug) " ✓";
|
||
in if th.hasPreview
|
||
then "row ${lib.escapeShellArg label} ${themeThumbs}/${th.slug}.png"
|
||
else "printf '%s\\n' ${lib.escapeShellArg label}")
|
||
orderedThemes;
|
||
themeSlugMap = lib.concatMapStringsSep "\n" (th:
|
||
" [${lib.escapeShellArg th.name}]=${lib.escapeShellArg th.slug}")
|
||
orderedThemes;
|
||
|
||
# Per-invocation grid layout (cards: a 16:9 preview above a centered name),
|
||
# overriding the single-column list theme just for this menu.
|
||
# · The icon box is sized 16:9 (rofi 2.0 takes a two-value `size`) to
|
||
# match the 480×270 previews exactly — they fill it with no letterbox.
|
||
# · The window is sized to the *content* (3 × icon + paddings), not a
|
||
# percentage, so a column is no wider than its card — otherwise the
|
||
# selection highlight balloons out around the image.
|
||
# · flow: horizontal lays cards out row-by-row (rofi's default Vertical
|
||
# fills column-by-column, so Down at a column's foot jumped to the top
|
||
# of the next column instead of scrolling the page).
|
||
# Grid dial — the previews are as big as the icon px allows; screen *height*
|
||
# caps it, so showing 2 rows (not 3) lets each card grow a lot. Scrolling
|
||
# reaches the rest. Tune themeGridIconW for size; the window resizes to fit.
|
||
# rofi's element-icon `size` is a SINGLE value (the manual only documents
|
||
# one) — a two-value "WxH" is silently collapsed, which is why the preview
|
||
# used to render tiny inside a too-wide column. So: size = the card *width*,
|
||
# rofi fits the 16:9 image tight to it (no square letterbox), and the window
|
||
# is sized so a column is exactly that width — the preview fills the cell.
|
||
themeGridCols = 3;
|
||
themeGridLines = 3;
|
||
themeGridIconW = 240; # square preview card side (px) — the size knob
|
||
themeGridPad = 0; # margin around the preview inside its cell (px)
|
||
themeGridGap = 8; # gap between cards (px)
|
||
themeGridThemeStr = lib.escapeShellArg (lib.concatStringsSep " " [
|
||
# window width = cards + their padding + inter-card gaps + chrome (~20px)
|
||
"window { width: ${toString (themeGridCols * (themeGridIconW + 2 * themeGridPad) + (themeGridCols - 1) * themeGridGap + 20)}px; }"
|
||
"listview { columns: ${toString themeGridCols}; lines: ${toString themeGridLines}; spacing: ${toString themeGridGap}px; flow: horizontal; }"
|
||
"element { orientation: vertical; padding: ${toString themeGridPad}px; spacing: 2px; }"
|
||
"element-icon { size: ${toString themeGridIconW}px; }"
|
||
"element-text { horizontal-align: 0.5; }"
|
||
]);
|
||
|
||
# Keybindings cheatsheet (SUPER+? → the `keybinds` menu module). Built
|
||
# from the SAME ./keybinds.nix that hyprland.nix binds, so it can never
|
||
# drift from the live shortcuts. "$mod" reads as SUPER; rows are padded
|
||
# into two columns. extra[] carries the generated/mouse binds.
|
||
keybinds = import ./keybinds.nix;
|
||
padRight = w: s:
|
||
let n = w - builtins.stringLength s;
|
||
in s + (if n > 0 then lib.concatStrings (builtins.genList (_: " ") n) else " ");
|
||
prettyKeys = b:
|
||
let m = lib.replaceStrings [ "$mod" ] [ "SUPER" ] b.mods;
|
||
prefix = if m == "" then "" else lib.concatStringsSep " + " (lib.splitString " " m) + " + ";
|
||
key = lib.replaceStrings
|
||
[ "question" "slash" "left" "right" "up" "down" ]
|
||
[ "?" "/" "←" "→" "↑" "↓" ] b.key;
|
||
# The bind is `SHIFT + slash` (base keysym; keybinds.nix), which
|
||
# renders "SHIFT + /". `?` IS Shift+/, so collapse that back to the
|
||
# single documented glyph — the cheatsheet reads "SUPER + ?".
|
||
in lib.replaceStrings [ "SHIFT + /" ] [ "?" ] (prefix + key);
|
||
cheatRows =
|
||
map (b: padRight 22 (prettyKeys b) + b.desc) (keybinds.binds
|
||
++ lib.optionals (lib.hasInfix "," cfg.keyboard.layout)
|
||
keybinds.multiLayoutBinds)
|
||
# Launch-or-focus binds (nomarchy.launchOrFocus, generated in
|
||
# hyprland.nix) — same renderer, so they land in the cheatsheet too.
|
||
++ map (e: padRight 22 (prettyKeys e)
|
||
+ (if e.desc != "" then e.desc
|
||
else "Focus or launch ${if e.command == "" then lib.toLower e.class else e.command}"))
|
||
cfg.launchOrFocus
|
||
++ map (e: padRight 22 e.keys + e.desc) keybinds.extra;
|
||
cheatsheetFile = pkgs.writeText "nomarchy-keybinds.txt"
|
||
(lib.concatStringsSep "\n" cheatRows + "\n");
|
||
|
||
# Dimmed keybind hints on menu rows (item 28c) — the same keybinds.nix
|
||
# + prettyKeys the cheatsheet uses, so they can never drift from the
|
||
# live binds. Pango spans; the menus that show them pass -markup-rows.
|
||
# Empty (no hint) when nothing binds the action.
|
||
hintForAction = action:
|
||
let m = lib.filter (b: b.action == action) keybinds.binds;
|
||
in lib.optionalString (m != [ ])
|
||
" <span size='small' alpha='55%'>${prettyKeys (lib.head m)}</span>";
|
||
menuHint = module: hintForAction "exec, nomarchy-menu ${module}";
|
||
# Apps opens rofi drun directly, not a nomarchy-menu module.
|
||
appsHint = " <span size='small' alpha='55%'>SUPER + 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-theme-sync, systemctl, hyprctl and friends. Menu entries
|
||
# carry real icons from the theme's icon set (Papirus) via rofi's
|
||
# per-row icon protocol — see row() below. `nomarchy-menu` with no
|
||
# argument shows the module picker.
|
||
|
||
# System batteries: type=Battery, not scope=Device — same filter as
|
||
# nomarchy-battery-notify. Name-agnostic (BAT0, CMB0, test_battery)
|
||
# so charge-limit / power-profile gates match notify (BACKLOG #60).
|
||
has_system_battery() {
|
||
local d
|
||
for d in /sys/class/power_supply/*/; do
|
||
[ "$(cat "$d/type" 2>/dev/null)" = Battery ] || continue
|
||
[ "$(cat "$d/scope" 2>/dev/null || echo System)" = Device ] && continue
|
||
return 0
|
||
done
|
||
return 1
|
||
}
|
||
has_charge_threshold() {
|
||
local d
|
||
for d in /sys/class/power_supply/*/; do
|
||
[ "$(cat "$d/type" 2>/dev/null)" = Battery ] || continue
|
||
[ "$(cat "$d/scope" 2>/dev/null || echo System)" = Device ] && continue
|
||
[ -e "$d/charge_control_end_threshold" ] && return 0
|
||
done
|
||
return 1
|
||
}
|
||
|
||
urlencode() {
|
||
local s="$1" out="" ch i
|
||
for ((i = 0; i < ''${#s}; i++)); do
|
||
ch=''${s:i:1}
|
||
case "$ch" in
|
||
[a-zA-Z0-9.~_-]) out+="$ch" ;;
|
||
' ') out+="+" ;;
|
||
*) printf -v ch '%%%02X' "'$ch"; out+="$ch" ;;
|
||
esac
|
||
done
|
||
printf '%s' "$out"
|
||
}
|
||
|
||
# Emit one dmenu row with a themed icon: `row "Label" icon-name`. The NUL
|
||
# + 0x1f field separator is rofi's per-row icon protocol; printed straight
|
||
# to the pipe because bash can't store NUL in a variable. Rendered when
|
||
# rofi has show-icons (on globally; passed explicitly on the icon menus).
|
||
row() { printf '%s\0icon\x1f%s\n' "$1" "$2"; }
|
||
|
||
# Back-audit exceptions (item 24): external modi/tools (calc, emoji,
|
||
# networkmanager_dmenu, rofi-pulse-select) can't take an injected row —
|
||
# Esc is their back path; free-text prompts (web, ask) have no list to
|
||
# append to — Esc cancels. Everything hand-rolled gets the row below.
|
||
# General menu convention: every list menu ends with a "↩ Back" entry that
|
||
# returns one level up (so you never have to Esc out and reopen). The `↩`
|
||
# glyph in the label IS the arrow — `back` must not also attach a
|
||
# go-previous icon, or icon menus render two arrows (item 34). So `back`
|
||
# is now just the plain-label emit, identical to what pick-lists append —
|
||
# one arrow everywhere. Always matched EXACTLY ("↩ Back") so it can't
|
||
# collide with clipboard or filename content.
|
||
BACK="↩ Back"
|
||
TAB=$(printf '\t')
|
||
back() { printf '%s\n' "$BACK"; }
|
||
|
||
# dmenu mode is case-sensitive by default (dmenu compatibility), even
|
||
# when config.rasi has case-sensitive: false for drun/modi — so
|
||
# SUPER+Space (apps) matched "ste"→Steam while the main Menu filter
|
||
# did not match "sys"→System. Always pass -i on menu pickers.
|
||
#
|
||
# Left arrow → one level up (same as choosing "↩ Back"). rofi's
|
||
# -kb-custom-1 returns exit 10; Esc still returns non-zero and quits
|
||
# the whole menu. Free-text prompts should treat "$BACK" as cancel.
|
||
# Pass --grid as the first arg to skip Left=Back (multi-column theme
|
||
# picker needs Left for column motion).
|
||
# Left is bound to kb-move-char-back by default and rofi hard-fails
|
||
# on a duplicate binding (error dialog, no menu) — every binding we
|
||
# steal must be released from its default first (Ctrl+b remains for
|
||
# cursor motion in text prompts).
|
||
rofi_menu() {
|
||
local out rc left_back=1
|
||
if [ "''${1:-}" = --grid ]; then
|
||
left_back=0
|
||
shift
|
||
fi
|
||
if [ "$left_back" -eq 1 ]; then
|
||
out=$(command rofi -dmenu -i -kb-move-char-back Control+b \
|
||
-kb-custom-1 Left "$@" </dev/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) systemctl suspend ;;
|
||
*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
|
||
[ -n "$choice" ] && powerprofilesctl set "$choice" ;;
|
||
|
||
powermgmt)
|
||
# Combined power menu (item 36b) — power PROFILE + battery CHARGE cap
|
||
# in one flat list, opened from BOTH the Waybar battery and
|
||
# power-profile icons (the two granular System ▸ Power profile /
|
||
# Battery limit rows stay for direct access). No new backend: profile
|
||
# rows drive powerprofilesctl like the power-profile picker, charge
|
||
# rows drive the same theme-sync writer as batterylimit. Each half
|
||
# self-gates on its hardware, so a desktop with neither just toasts.
|
||
haveppd=false; command -v powerprofilesctl >/dev/null 2>&1 && haveppd=true
|
||
havecharge=false; has_charge_threshold && havecharge=true
|
||
if ! $haveppd && ! $havecharge; then
|
||
notify-send "Power" "No power-profile or charge-limit control on this machine."
|
||
exit 0
|
||
fi
|
||
curp=""; $haveppd && curp=$(powerprofilesctl get 2>/dev/null)
|
||
curc=$(nomarchy-theme-sync get settings.power.batteryChargeLimit 2>/dev/null)
|
||
choice=$( {
|
||
if $haveppd; then
|
||
powerprofilesctl list 2>/dev/null | sed -nE 's/^[* ] ([a-z-]+):$/\1/p' \
|
||
| while IFS= read -r p; do
|
||
case "$p" in
|
||
performance) row "Profile: $p" battery-profile-performance ;;
|
||
balanced) row "Profile: $p" battery-profile-balanced ;;
|
||
power-saver) row "Profile: $p" battery-profile-powersave ;;
|
||
*) row "Profile: $p" preferences-system-power ;;
|
||
esac
|
||
done
|
||
fi
|
||
if $havecharge; then
|
||
row "Charge limit: 80% (recommended)" battery-080
|
||
row "Charge limit: 90%" battery-090
|
||
row "Charge limit: 60%" battery-060
|
||
row "Charge limit: Off (100%)" battery-100
|
||
fi
|
||
back
|
||
} | rofi_menu -show-icons -p "Power — profile: ''${curp:-n/a}, limit: ''${curc:-default}") || exit 0
|
||
# Persist in-flake, then re-apply via the root oneshot (polkit
|
||
# allows users to restart it) so a non-writable sysfs node still
|
||
# updates. Fallback: direct echo if the node is group-writable.
|
||
set_limit() {
|
||
nomarchy-theme-sync --quiet set settings.power.batteryChargeLimit "$1" --no-switch
|
||
n="$1"; [ "$n" = null ] && n=100
|
||
# Root oneshot sets Custom charge type (Dell) + thresholds.
|
||
applied=
|
||
if systemctl restart nomarchy-battery-charge-limit.service 2>/dev/null; then
|
||
applied=1
|
||
else
|
||
for d in /sys/class/power_supply/*/; do
|
||
[ "$(cat "$d/type" 2>/dev/null)" = Battery ] || continue
|
||
[ "$(cat "$d/scope" 2>/dev/null || echo System)" = Device ] && continue
|
||
if [ "$n" -lt 100 ] 2>/dev/null; then
|
||
for tnode in charge_types charge_type; do
|
||
[ -w "$d$tnode" ] || continue
|
||
listed=$(cat "$d$tnode" 2>/dev/null || true)
|
||
case " $listed " in *" Custom "*|*"Custom"*) echo Custom > "$d$tnode" 2>/dev/null || true ;; esac
|
||
done
|
||
fi
|
||
thresh="$d/charge_control_end_threshold"
|
||
if [ -w "$thresh" ] && echo "$n" > "$thresh" 2>/dev/null; then
|
||
applied=1
|
||
fi
|
||
done
|
||
fi
|
||
actual=; ctype=
|
||
for d in /sys/class/power_supply/*/; do
|
||
[ "$(cat "$d/type" 2>/dev/null)" = Battery ] || continue
|
||
[ "$(cat "$d/scope" 2>/dev/null || echo System)" = Device ] && continue
|
||
[ -r "$d/charge_control_end_threshold" ] \
|
||
&& actual=$(cat "$d/charge_control_end_threshold" 2>/dev/null)
|
||
ctype=$(cat "$d/charge_types" 2>/dev/null || cat "$d/charge_type" 2>/dev/null || true)
|
||
[ -n "$actual" ] && break
|
||
done
|
||
# Highlight active type: "… [Custom] …"
|
||
active=$(printf '%s' "$ctype" | sed -n 's/.*\[\([^]]*\)\].*/\1/p')
|
||
extra=
|
||
[ -n "$active" ] && extra=" (type $active)"
|
||
if [ -n "$applied" ] && [ -n "$actual" ]; then
|
||
notify-send "Battery limit" "$2 — stop at $actual%$extra."
|
||
elif [ -n "$applied" ]; then
|
||
notify-send "Battery limit" "$2 — applied (could not re-read sysfs)."
|
||
else
|
||
notify-send -u critical "Battery limit" \
|
||
"Saved in theme-state but could not write sysfs (need rebuild or users-group on threshold)."
|
||
fi
|
||
}
|
||
case "$choice" in
|
||
"$BACK") exec "$0" ;;
|
||
"Profile: "*) powerprofilesctl set "''${choice#Profile: }" ;;
|
||
*"Charge limit: 80%"*) set_limit 80 "Set to 80%" ;;
|
||
*"Charge limit: 90%"*) set_limit 90 "Set to 90%" ;;
|
||
*"Charge limit: 60%"*) set_limit 60 "Set to 60%" ;;
|
||
*"Charge limit: Off"*) set_limit null "Off — charges to 100%" ;;
|
||
esac ;;
|
||
|
||
theme)
|
||
# Visual picker: a grid of real desktop previews (rows + Name→slug
|
||
# map generated from the presets at build time). Pick a card → apply
|
||
# that slug. The grid layout is a per-invocation -theme-str override.
|
||
declare -A THEME_SLUG=(
|
||
${themeSlugMap}
|
||
)
|
||
choice=$( {
|
||
${themeRows}
|
||
back
|
||
} | rofi_menu --grid -show-icons -p Theme -theme-str ${themeGridThemeStr}) || exit 0
|
||
[ "$choice" = "$BACK" ] && exec "$0"
|
||
choice="''${choice% ✓}" # drop the active marker if present
|
||
slug="''${THEME_SLUG[$choice]:-}"
|
||
[ -n "$slug" ] && exec nomarchy-theme-sync apply "$slug" ;;
|
||
|
||
clipboard)
|
||
sel=$( { cliphist list; printf '%s\n' "$BACK"; } | rofi_menu -p clip) || exit 0
|
||
[ "$sel" = "$BACK" ] && exec "$0" tools
|
||
printf '%s' "$sel" | cliphist decode | wl-copy ;;
|
||
|
||
calc)
|
||
# Live calculator (rofi-calc): re-evaluates every keystroke via
|
||
# libqalculate — which dodges the qalc CLI's "15% of 200" misparse —
|
||
# with the answer as the top entry. Enter copies the result; the
|
||
# menu stays open to chain calculations (Esc closes).
|
||
exec rofi -show calc -modi calc -no-show-match -no-sort \
|
||
-calc-command "echo -n '{result}' | wl-copy" ;;
|
||
|
||
files)
|
||
sel=$( { fd . "$HOME" --type f --hidden --exclude .git --exclude .cache 2>/dev/null \
|
||
| head -n 50000 | sed "s|^$HOME/||"; printf '%s\n' "$BACK"; } \
|
||
| rofi_menu -p file) || exit 0
|
||
[ "$sel" = "$BACK" ] && exec "$0" tools
|
||
[ -n "$sel" ] && exec xdg-open "$HOME/$sel" ;;
|
||
|
||
emoji)
|
||
# Emoji / symbol picker (rofi-emoji). The default action copies the
|
||
# glyph to the clipboard through the plugin's Wayland adapter
|
||
# (wl-copy); the alternate action types it into the focused window.
|
||
exec rofi -show emoji -modi emoji ;;
|
||
|
||
web)
|
||
q=$(rofi_menu -p search < /dev/null) || exit 0
|
||
{ [ -n "$q" ] && [ "$q" != "$BACK" ]; } || exit 0
|
||
exec xdg-open "https://www.google.com/search?q=$(urlencode "$q")" ;;
|
||
|
||
network)
|
||
# Native rofi wifi/VPN picker (networkmanager_dmenu) over the system
|
||
# NetworkManager — replaces the old nmtui-in-a-terminal flow. Reads
|
||
# its config from xdg.configFile below (told to drive rofi).
|
||
# External tool: no injected ↩ Back row possible — Esc closes.
|
||
exec networkmanager_dmenu ;;
|
||
|
||
vpn)
|
||
# VPN setup + management (nomarchy-vpn): NM VPN/WireGuard connect +
|
||
# config import + Tailscale. Distinct from `network` (the wifi picker).
|
||
exec nomarchy-vpn ;;
|
||
|
||
bluetooth)
|
||
# blueman-manager GUI (services.blueman.enable, system-side).
|
||
# Self-gated in the System menu on the CLI; guard here too in case
|
||
# it's invoked directly with nomarchy.system.bluetooth.enable=false.
|
||
command -v blueman-manager >/dev/null 2>&1 \
|
||
|| { notify-send "Bluetooth" "Not available (nomarchy.system.bluetooth off?)."; exit 0; }
|
||
# 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 ;;
|
||
"Send output"*)
|
||
nomarchy-dock-audio select menu ;;
|
||
*Output*) exec rofi-pulse-select sink ;;
|
||
*Input*) exec rofi-pulse-select source ;;
|
||
esac ;;
|
||
|
||
keyboard)
|
||
command -v nomarchy-keyboard-layout >/dev/null 2>&1 \
|
||
|| { notify-send "Keyboard" "Per-device layout helper is unavailable."; exit 0; }
|
||
devices=$(nomarchy-keyboard-layout devices)
|
||
[ -n "$devices" ] \
|
||
|| { notify-send "Keyboard" "No keyboards reported by Hyprland."; exit 0; }
|
||
picked=$( {
|
||
printf '%s\n' "$devices" | while IFS="$TAB" read -r dev active; do
|
||
printf '%s · %s\n' "$dev" "$active"
|
||
done
|
||
back
|
||
} | rofi_menu -p "Keyboard") || exit 0
|
||
[ "$picked" = "$BACK" ] && exec "$0" system
|
||
device=''${picked%% ·*}
|
||
saved=$(nomarchy-keyboard-layout saved "$device")
|
||
layout=$( {
|
||
[ -n "$saved" ] && printf 'Use session default · forget %s\n' "$saved"
|
||
nomarchy-keyboard-layout layouts
|
||
back
|
||
} | rofi_menu -p "Layout · $device (now: ''${saved:-session default})") || exit 0
|
||
case "$layout" in
|
||
"$BACK") exec "$0" keyboard ;;
|
||
"Use session default"*) exec nomarchy-keyboard-layout forget "$device" ;;
|
||
"") exit 0 ;;
|
||
*) exec nomarchy-keyboard-layout apply "$device" "$layout" ;;
|
||
esac ;;
|
||
|
||
display)
|
||
# Per-output resolution picker. Applies the chosen mode INSTANTLY via
|
||
# `hyprctl keyword monitor` — keeping the output's current position and
|
||
# scale, so only the resolution changes — and PERSISTS it to the in-flake
|
||
# state (settings.monitors.<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
|
||
[ "$name" = "Profiles ›" ] && exec "$0" display-profile
|
||
case "$name" in
|
||
"Dock mode"*)
|
||
if nomarchy-display-transition dock "$internal" "$external" \
|
||
"$external, preferred, auto, 1"; then
|
||
notify-send "Display" "Dock mode — every laptop workspace moved to $external; laptop panel off."
|
||
else
|
||
notify-send "Display" "Dock transition failed; the previous layout was kept where possible."
|
||
fi
|
||
exit 0 ;;
|
||
"Screen on"*)
|
||
# preferred/auto/1 is the known-good live fallback; the declared
|
||
# scale/position come back at the next relogin/switch.
|
||
if nomarchy-display-transition enable "$offmon"; then
|
||
notify-send "Display" "$offmon is back on."
|
||
else
|
||
notify-send "Display" "Could not enable $offmon."
|
||
fi
|
||
exit 0 ;;
|
||
"Move workspace"*)
|
||
hyprctl dispatch movecurrentworkspacetomonitor +1 >/dev/null 2>&1
|
||
exit 0 ;;
|
||
"Swap workspaces"*)
|
||
hyprctl dispatch swapactiveworkspaces \
|
||
"$(printf '%s' "$mons" | jq -r '.[0].name')" \
|
||
"$(printf '%s' "$mons" | jq -r '.[1].name')" >/dev/null 2>&1
|
||
exit 0 ;;
|
||
esac
|
||
name=''${name%% ·*} # strip the " · WxH@R" hint
|
||
modeBack=display
|
||
else
|
||
name=$(printf '%s' "$mons" | jq -r '.[0].name')
|
||
fi
|
||
[ -n "$name" ] || exit 0
|
||
|
||
# Pick a mode: a few Hyprland resolution tokens, then the output's
|
||
# advertised modes (rounded refresh rate, deduped, highest first). The
|
||
# prompt shows the current mode.
|
||
cur=$(printf '%s' "$mons" | jq -r --arg n "$name" \
|
||
'.[]|select(.name==$n)|"\(.width)x\(.height)@\(.refreshRate|round)"')
|
||
mode=$( {
|
||
printf 'preferred\nhighres\nhighrr\n'
|
||
printf '%s' "$mons" | jq -r --arg n "$name" '.[]|select(.name==$n)|.availableModes[]' \
|
||
| sed 's/Hz$//' | awk -F@ '{ printf "%s@%d\n", $1, $2 + 0.5 }' | sort -rV -u
|
||
printf '%s\n' "$BACK"
|
||
} | rofi_menu -p "$name (now $cur)" ) || exit 0
|
||
[ "$mode" = "$BACK" ] && exec "$0" "$modeBack"
|
||
[ -n "$mode" ] || exit 0
|
||
|
||
# Apply live (keep the output's current position + scale), then persist.
|
||
pos=$(printf '%s' "$mons" | jq -r --arg n "$name" '.[]|select(.name==$n)|"\(.x)x\(.y)"')
|
||
scale=$(printf '%s' "$mons" | jq -r --arg n "$name" '.[]|select(.name==$n)|.scale')
|
||
if hyprctl keyword monitor "$name,$mode,$pos,$scale" >/dev/null 2>&1; then
|
||
nomarchy-theme-sync --quiet set "settings.monitors.$name" "$mode" --no-switch
|
||
notify-send "Display" "$name → $mode"
|
||
else
|
||
notify-send "Display" "Could not set $name to $mode."
|
||
fi ;;
|
||
|
||
capture)
|
||
# Screenshots + screen recording. Recording rows self-swap: while
|
||
# one runs, the only offer is Stop (same surface as the bar's ⏺).
|
||
choice=$( {
|
||
row "Region → clipboard${hintForBind "" "Print"}" applets-screenshooter
|
||
row "Region → file${hintForBind "SHIFT" "Print"}" applets-screenshooter
|
||
command -v satty >/dev/null 2>&1 && row "Annotate region${hintForBind "$mod SHIFT" "Print"}" edit-image
|
||
row "Full screen → clipboard" camera-photo
|
||
row "Full screen → file${hintForBind "CTRL" "Print"}" camera-photo
|
||
command -v tesseract >/dev/null 2>&1 && row "OCR region → clipboard" scanner
|
||
if command -v nomarchy-record >/dev/null 2>&1; then
|
||
if nomarchy-record active; then
|
||
row "■ Stop recording" media-playback-stop
|
||
else
|
||
row "Record region" media-record
|
||
row "Record screen" media-record
|
||
row "Record region + audio" audio-input-microphone
|
||
row "Record screen + audio" audio-input-microphone
|
||
fi
|
||
fi
|
||
back
|
||
} | rofi_menu -show-icons -markup-rows -p Capture) || exit 0
|
||
dir="$HOME/Pictures/Screenshots"
|
||
file="$dir/$(date +%Y%m%d-%H%M%S).png"
|
||
case "$choice" in
|
||
"$BACK") exec "$0" tools ;;
|
||
*"Region → clipboard"*)
|
||
grim -g "$(slurp)" - | wl-copy \
|
||
&& notify-send "Screenshot" "Region copied to clipboard." ;;
|
||
*"Region → file"*) mkdir -p "$dir"; grim -g "$(slurp)" "$file" \
|
||
&& notify-send "Screenshot saved" "$file" ;;
|
||
*"Annotate region"*) mkdir -p "$dir"; grim -g "$(slurp)" - | satty --filename - --fullscreen --output-filename "$file" ;;
|
||
*"OCR region"*)
|
||
# Select → screenshot → tesseract → clipboard. Esc in slurp
|
||
# cancels silently (the recording.nix pattern); recognizing
|
||
# nothing toasts instead of copying an empty clipboard.
|
||
geo=$(slurp) || exit 0
|
||
txt=$(grim -g "$geo" - | tesseract stdin stdout 2>/dev/null)
|
||
[ -n "''${txt//[[:space:]]/}" ] \
|
||
|| { notify-send "OCR" "No text recognized in that region."; exit 0; }
|
||
printf '%s' "$txt" | wl-copy
|
||
notify-send "OCR" "Copied $(printf '%s' "$txt" | wc -w) word(s) to the clipboard." ;;
|
||
*"Full screen → clipboard"*)
|
||
grim - | wl-copy \
|
||
&& notify-send "Screenshot" "Screen copied to clipboard." ;;
|
||
*"Full screen → file"*) mkdir -p "$dir"; grim "$file" \
|
||
&& notify-send "Screenshot saved" "$file" ;;
|
||
*"Stop recording"*) exec nomarchy-record stop ;;
|
||
*"Record region + audio"*) exec nomarchy-record start region audio ;;
|
||
*"Record screen + audio"*) exec nomarchy-record start screen audio ;;
|
||
*"Record region"*) exec nomarchy-record start region ;;
|
||
*"Record screen"*) exec nomarchy-record start screen ;;
|
||
esac ;;
|
||
|
||
lookfeel)
|
||
# Look & Feel — appearance: Theme, wallpapers, night light.
|
||
# Root stays six entries (Apps / Look & Feel / Tools / System /
|
||
# Power / Keybindings). SUPER+T / SUPER+SHIFT+T still hit leaves.
|
||
choice=$( {
|
||
row "Theme${menuHint "theme"}" preferences-desktop-theme
|
||
row "Next wallpaper${hintForAction "exec, nomarchy-theme-sync bg next"}" preferences-desktop-wallpaper
|
||
row "Reset wallpaper (auto)" preferences-desktop-wallpaper
|
||
# Use the toggle script (knows hyprsunset vs wlsunset); a hard-coded
|
||
# hyprsunset-only check always showed (off) in geo mode and when
|
||
# the unit was still the empty HM mask from a disabled package unit.
|
||
if [ "$(nomarchy-nightlight is-active 2>/dev/null)" = on ]
|
||
then row "Night light (on)" weather-clear-night
|
||
else row "Night light (off)" weather-clear-night
|
||
fi
|
||
if [ "$(nomarchy-theme-sync get settings.autoTheme.enable 2>/dev/null)" = true ]
|
||
then row "Auto theme (on)" preferences-desktop-theme
|
||
else row "Auto theme (off)" preferences-desktop-theme
|
||
fi
|
||
back
|
||
} | rofi_menu -show-icons -markup-rows -p "Look & Feel") || exit 0
|
||
case "$choice" in
|
||
"$BACK") exec "$0" ;;
|
||
*"Auto theme"*) exec "$0" autotheme ;;
|
||
*Theme*) exec "$0" theme ;;
|
||
*"Next wallpaper"*) exec nomarchy-theme-sync bg next ;;
|
||
*"Reset wallpaper"*) exec nomarchy-theme-sync bg auto ;;
|
||
*"Night light"*) exec "$0" nightlight ;;
|
||
esac ;;
|
||
|
||
display-profile)
|
||
# Named multi-output layouts (nomarchy.displayProfiles). Applying is
|
||
# instant (hyprctl per entry, via nomarchy-display-profile) and
|
||
# persisted in-flake (settings.displayProfile) so the next rebuild
|
||
# bakes it. "Base layout" clears the state and hyprctl-reloads.
|
||
command -v nomarchy-display-profile >/dev/null 2>&1 \
|
||
|| { notify-send "Display profiles" "None declared (nomarchy.displayProfiles)."; exit 0; }
|
||
active=$(nomarchy-display-profile active)
|
||
choice=$( {
|
||
nomarchy-display-profile list | while IFS= read -r p; do
|
||
if [ "$p" = "$active" ]; then printf '● %s\n' "$p"; else printf '○ %s\n' "$p"; fi
|
||
done
|
||
printf 'Base layout\n'
|
||
if [ "$(nomarchy-theme-sync get settings.displayProfileAuto 2>/dev/null)" = true ]
|
||
then printf 'Auto-switch (on)\n'
|
||
else printf 'Auto-switch (off)\n'
|
||
fi
|
||
printf '%s\n' "$BACK"
|
||
} | rofi_menu -p Profiles ) || exit 0
|
||
case "$choice" in
|
||
"$BACK") exec "$0" display ;;
|
||
"Base layout") exec nomarchy-display-profile base ;;
|
||
*"Auto-switch"*)
|
||
# Instant in-flake flag; the watcher reads it live on the next
|
||
# output change — no rebuild, no unit juggling.
|
||
if [ "$(nomarchy-theme-sync get settings.displayProfileAuto 2>/dev/null)" = true ]; then
|
||
nomarchy-theme-sync --quiet set settings.displayProfileAuto false --no-switch
|
||
notify-send "Display profiles" "Auto-switch off — profiles change only from this menu."
|
||
else
|
||
nomarchy-theme-sync --quiet set settings.displayProfileAuto true --no-switch
|
||
notify-send "Display profiles" "Auto-switch on — plugging/unplugging outputs picks the matching profile."
|
||
fi
|
||
exec "$0" display-profile ;;
|
||
*) exec nomarchy-display-profile apply "''${choice#[●○] }" ;;
|
||
esac ;;
|
||
|
||
colorpicker)
|
||
# Pick a pixel's color (hyprpicker's zoom loupe) → clipboard, hex
|
||
# in the confirmation toast. Esc/cancel exits silently. Self-gated
|
||
# in the Tools row; guarded here too for direct invocation.
|
||
command -v hyprpicker >/dev/null 2>&1 \
|
||
|| { notify-send "Color picker" "hyprpicker is not installed."; exit 0; }
|
||
color=$(hyprpicker) || exit 0
|
||
[ -n "$color" ] || exit 0
|
||
printf '%s' "$color" | wl-copy
|
||
notify-send "Color picker" "$color copied to the clipboard." ;;
|
||
|
||
keybinds)
|
||
# Read-only cheatsheet, generated from keybinds.nix at build time.
|
||
# Enter on a row is a deliberate no-op (nothing to launch); ↩ Back
|
||
# returns to the root picker, which is also where it's launched.
|
||
sel=$( { cat ${cheatsheetFile}; printf '%s\n' "$BACK"; } \
|
||
| rofi_menu -p keys ) || exit 0
|
||
[ "$sel" = "$BACK" ] && exec "$0"
|
||
exit 0 ;;
|
||
|
||
ask)
|
||
# Free-text question → claude CLI in a terminal (OAuth, no API key).
|
||
# Pulled fresh from npm via npx rather than nixpkgs' claude-code,
|
||
# which lags behind model releases; npx caches after first run. The
|
||
# REPL stays open for follow-ups.
|
||
q=$(rofi_menu -p claude < /dev/null) || exit 0
|
||
{ [ -n "$q" ] && [ "$q" != "$BACK" ]; } || exit 0
|
||
exec ${cfg.terminal} -e npx --yes @anthropic-ai/claude-code@latest "$q" ;;
|
||
|
||
dnd)
|
||
# Toggle swaync Do-Not-Disturb (-d prints the new state). With DND
|
||
# on, notifications are suppressed — the Waybar bell-off icon is the
|
||
# cue — so only confirm with a toast when turning it back off.
|
||
[ "$(swaync-client -d)" = "false" ] \
|
||
&& notify-send "Do Not Disturb off" "Notifications resumed."
|
||
exit 0 ;;
|
||
|
||
nightlight)
|
||
# Force the scheduled blue-light filter on/off for the session by
|
||
# starting/stopping hyprsunset (nomarchy-nightlight, from nightlight.nix).
|
||
# Self-gated in the menu; guard here too in case it's invoked directly.
|
||
command -v nomarchy-nightlight >/dev/null 2>&1 \
|
||
&& exec nomarchy-nightlight toggle
|
||
notify-send "Night light" "Not available (nomarchy.nightlight off?)."; exit 0 ;;
|
||
|
||
autotheme)
|
||
# Look & Feel › Auto theme — automatic day/night theme switch
|
||
# (settings.autoTheme, applied by the nomarchy-auto-theme timer from
|
||
# autotheme.nix). The enable flag GATES the timer's install, so
|
||
# turning it *on* must rebuild; day/night/times are read live by
|
||
# `nomarchy-theme-sync auto`, so those writes are instant
|
||
# (--no-switch). Enabling writes the flag then `auto --force`, so a
|
||
# single rebuild both installs the timer and applies the right theme
|
||
# now. Disabling is instant: the flag flips and `auto` self-gates to
|
||
# a no-op, so the lingering timer does nothing until the next rebuild.
|
||
at_get() { nomarchy-theme-sync get "settings.autoTheme.$1" 2>/dev/null; }
|
||
en=$(at_get enable); day=$(at_get day); night=$(at_get night)
|
||
sunrise=$(at_get sunrise); sunset=$(at_get sunset)
|
||
choice=$( {
|
||
if [ "$en" = true ]
|
||
then row "Auto theme (on)" preferences-desktop-theme
|
||
else row "Auto theme (off)" preferences-desktop-theme
|
||
fi
|
||
row "Day theme: ''${day:-— pick}" weather-clear
|
||
row "Night theme: ''${night:-— pick}" weather-clear-night
|
||
row "Sunrise: ''${sunrise:-07:00}" preferences-system-time
|
||
row "Sunset: ''${sunset:-20:00}" preferences-system-time
|
||
back
|
||
} | rofi_menu -show-icons -markup-rows -p "Auto theme") || exit 0
|
||
case "$choice" in
|
||
"$BACK") exec "$0" lookfeel ;;
|
||
*"Auto theme"*)
|
||
if [ "$en" = true ]; then
|
||
nomarchy-theme-sync --quiet set settings.autoTheme.enable false --no-switch
|
||
notify-send "Auto theme" "Off — current theme stays."
|
||
exec "$0" autotheme
|
||
fi
|
||
# Need a day + night pair before enabling; default the summer pair.
|
||
[ -n "$day" ] || nomarchy-theme-sync --quiet set settings.autoTheme.day summer-day --no-switch
|
||
[ -n "$night" ] || nomarchy-theme-sync --quiet set settings.autoTheme.night summer-night --no-switch
|
||
nomarchy-theme-sync --quiet set settings.autoTheme.enable true --no-switch
|
||
notify-send "Auto theme" "On — applying the theme for now…"
|
||
exec nomarchy-theme-sync auto --force ;; # one rebuild: install timer + apply
|
||
"Day theme"*)
|
||
slug=$( { nomarchy-theme-sync list; printf '%s\n' "$BACK"; } | rofi_menu -p "Day theme") || exec "$0" autotheme
|
||
[ "$slug" != "$BACK" ] && [ -n "$slug" ] \
|
||
&& nomarchy-theme-sync --quiet set settings.autoTheme.day "$slug" --no-switch
|
||
exec "$0" autotheme ;;
|
||
"Night theme"*)
|
||
slug=$( { nomarchy-theme-sync list; printf '%s\n' "$BACK"; } | rofi_menu -p "Night theme") || exec "$0" autotheme
|
||
[ "$slug" != "$BACK" ] && [ -n "$slug" ] \
|
||
&& nomarchy-theme-sync --quiet set settings.autoTheme.night "$slug" --no-switch
|
||
exec "$0" autotheme ;;
|
||
"Sunrise"*|"Sunset"*)
|
||
key=sunrise; label=Sunrise; cur="''${sunrise:-07:00}"
|
||
case "$choice" in "Sunset"*) key=sunset; label=Sunset; cur="''${sunset:-20:00}" ;; esac
|
||
t=$(rofi_menu -p "$label HH:MM (now $cur)" < /dev/null) || exec "$0" autotheme
|
||
case "$t" in
|
||
""|"$BACK") : ;;
|
||
[01][0-9]:[0-5][0-9]|2[0-3]:[0-5][0-9])
|
||
nomarchy-theme-sync --quiet set "settings.autoTheme.$key" "$t" --no-switch ;;
|
||
*) notify-send "Auto theme" "Ignored '$t' — use HH:MM (24-hour)." ;;
|
||
esac
|
||
exec "$0" autotheme ;;
|
||
esac ;;
|
||
|
||
autotimezone)
|
||
# Toggle automatic timezone detection (geoclue + automatic-timezoned).
|
||
# A SYSTEM service, so flipping it writes the in-flake flag and runs a
|
||
# system rebuild (sudo) + a home switch — in a terminal, like Snapshots.
|
||
command -v nomarchy-autotimezone >/dev/null 2>&1 \
|
||
|| { notify-send "Auto timezone" "Unavailable on this machine."; exit 0; }
|
||
exec ${cfg.terminal} -e nomarchy-autotimezone toggle ;;
|
||
|
||
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
|
||
# theme-state.json (that file only) in the downstream flake, so
|
||
# settings history is `git log`; nomarchy-pull/-rebuild/-home
|
||
# additionally sweep everything else dirty (hand edits, lock bumps)
|
||
# into a commit first. Nothing in Nix consumes the flag —
|
||
# the tool reads the live state on each write — so the toggle is
|
||
# instant, no rebuild. The off-write commits too (the tool fires
|
||
# when the flag was on before OR after), keeping history consistent.
|
||
if [ "$(nomarchy-theme-sync get settings.autoCommit 2>/dev/null)" = true ]; then
|
||
nomarchy-theme-sync --quiet set settings.autoCommit false --no-switch
|
||
notify-send "Auto-commit" "Off — menu changes stay uncommitted in your flake."
|
||
else
|
||
nomarchy-theme-sync --quiet set settings.autoCommit true --no-switch
|
||
notify-send "Auto-commit" "On — menu changes commit; rebuilds sweep pending edits in too."
|
||
fi
|
||
exit 0 ;;
|
||
|
||
snapshot)
|
||
# Snapshot browse/restore — the btrfs-assistant GUI via its pkexec
|
||
# launcher. The "2.2 segfaults on 26.05" diagnosis was HALF right:
|
||
# only UNPRIVILEGED runs crash (libbtrfsutil unprivileged subvolume
|
||
# iteration, btrfs-progs 6.17.1; fixed upstream after) — as root it
|
||
# runs fine, and the launcher runs it as root. The prompt needs the
|
||
# session polkit agent (hyprpolkitagent, exec-once in hyprland.nix).
|
||
# Fallback: nomarchy-snapshots, the keyboard-driven fzf browser in a
|
||
# terminal (kept installed — also nice over SSH).
|
||
# Self-gated to nomarchy.system.snapper, so it no-ops elsewhere.
|
||
command -v btrfs-assistant-launcher >/dev/null 2>&1 \
|
||
&& exec btrfs-assistant-launcher
|
||
command -v nomarchy-snapshots >/dev/null 2>&1 \
|
||
|| { notify-send "Snapshots" "Snapshot tools unavailable (nomarchy.system.snapper off?)."; exit 0; }
|
||
exec ${cfg.terminal} -e sudo nomarchy-snapshots ;;
|
||
|
||
doctor)
|
||
# Read-only health sheet in a floating, centered ghostty window
|
||
# (class matched by hyprland.nix windowrules). --gtk-single-instance
|
||
# =false forces a fresh window — same pattern as nomarchy-calendar.
|
||
# Exit code doesn't matter; the sheet itself is the product.
|
||
exec ghostty --class=com.nomarchy.doctor --gtk-single-instance=false \
|
||
-e sh -c "nomarchy-doctor
|
||
printf '\nEnter to close.'; read -r _" ;;
|
||
|
||
firmware)
|
||
# System ▸ Firmware — fwupd/LVFS update surface (HARDWARE.md §4).
|
||
# Never auto-flashes: refresh metadata, list what LVFS offers, then
|
||
# hand to `fwupdmgr update`, which confirms each device and prompts
|
||
# for the reboot a capsule needs (pillar 1 — no silent BIOS writes).
|
||
# Self-gated on fwupdmgr in System; guard here too.
|
||
command -v fwupdmgr >/dev/null 2>&1 \
|
||
|| { notify-send "Firmware" "fwupd not available (services.fwupd.enable off?)."; exit 0; }
|
||
exec ${cfg.terminal} -e sh -c '
|
||
echo "== Refreshing firmware metadata (LVFS) =="
|
||
fwupdmgr refresh || true
|
||
echo
|
||
echo "== Firmware updates available =="
|
||
if fwupdmgr get-updates; then
|
||
echo
|
||
printf "Apply these updates? fwupd confirms each device. [y/N] "
|
||
read -r ans
|
||
case "$ans" in
|
||
[yY]*) fwupdmgr update ;;
|
||
*) echo "Cancelled — no firmware written." ;;
|
||
esac
|
||
else
|
||
echo "Nothing to apply on this machine."
|
||
fi
|
||
printf "\nEnter to close."; read -r _' ;;
|
||
|
||
fingerprint)
|
||
# System ▸ Fingerprint — enroll/list/delete + optional PAM for
|
||
# login/sudo (HARDWARE.md §5, BACKLOG #55). Self-gated on fprintd
|
||
# CLI (services.fprintd). Enroll is interactive → terminal.
|
||
command -v fprintd-list >/dev/null 2>&1 \
|
||
|| { notify-send "Fingerprint" "fprintd not available (nomarchy.hardware.fingerprint.enable?)."; exit 0; }
|
||
pam=$(nomarchy-theme-sync get settings.fingerprint.pam 2>/dev/null || echo false)
|
||
case "$pam" in true|True) pam_label="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 ;;
|
||
*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 ;;
|
||
|
||
controlcenter)
|
||
# Nomarchy TUI Control Center.
|
||
exec ${cfg.terminal} -e nomarchy-control-center ;;
|
||
|
||
whatchanged)
|
||
# Plain-language last rebuild (system + desktop nvd). Toast the
|
||
# one-liners; open a floating terminal for the full report when
|
||
# the user wants detail (same ghostty class pattern as Doctor).
|
||
if ! command -v nomarchy-what-changed >/dev/null 2>&1; then
|
||
notify-send "What changed" "nomarchy-what-changed not on PATH (lifecycle package?)."
|
||
exit 0
|
||
fi
|
||
summary=$(nomarchy-what-changed --summary 2>/dev/null || true)
|
||
[ -n "$summary" ] && notify-send -a Nomarchy "What changed" "$summary"
|
||
exec ghostty --class=com.nomarchy.doctor --gtk-single-instance=false \
|
||
-e sh -c "nomarchy-what-changed
|
||
printf '\nEnter to close.'; read -r _" ;;
|
||
|
||
rollback)
|
||
# Undo, one menu away (informative + rock-stable). Desktop =
|
||
# Home Manager generations: theme/config history is already one
|
||
# generation per change, and running an older generation's
|
||
# activate script is the supported HM rollback — reversible by
|
||
# activating a newer one again, so no typed-yes gate here.
|
||
# System-level undo deliberately LINKS OUT instead of putting
|
||
# destructive flows in rofi: boot-menu generations and snapper
|
||
# rollback have their own homes and gates (docs/RECOVERY.md,
|
||
# nomarchy-snapshots).
|
||
gens=$(home-manager generations 2>/dev/null | head -10)
|
||
choice=$( {
|
||
if [ -n "$gens" ]; then
|
||
printf '%s\n' "$gens" | awk '{
|
||
printf "Desktop gen %s — %s %s%s\n", $5, $1, $2, (NR==1 ? " (current)" : "")
|
||
}' | while IFS= read -r line; do row "$line" document-open-recent; done
|
||
fi
|
||
command -v nomarchy-snapshots >/dev/null 2>&1 \
|
||
&& row "System files → Snapshots" timeshift
|
||
row "System config → boot an older generation (how)" help-about
|
||
back
|
||
} | rofi_menu -show-icons -p Rollback) || exit 0
|
||
case "$choice" in
|
||
"$BACK") exec "$0" system ;;
|
||
"Desktop gen "*)
|
||
num=''${choice#Desktop gen }; num=''${num%% *}
|
||
path=$(printf '%s\n' "$gens" | awk -v n="$num" '$5 == n { print $7 }')
|
||
[ -x "$path/activate" ] \
|
||
|| { notify-send "Rollback" "Generation $num not found on disk."; exit 1; }
|
||
exec ${cfg.terminal} -e sh -c "
|
||
echo \"Activating Home Manager generation $num…\"; echo
|
||
\"$path/activate\"
|
||
echo; echo \"Desktop rolled back to generation $num.\"
|
||
echo \"(Roll forward the same way — newer generations stay listed.)\"
|
||
printf 'Enter to close.'; read -r _" ;;
|
||
*Snapshots*) exec "$0" snapshot ;;
|
||
*"older generation"*)
|
||
notify-send "System rollback" "Reboot and pick an older NixOS generation in the boot menu (the last 10 are kept). Make it stick: revert the change in ~/.nomarchy, then nomarchy-rebuild. Details: docs/RECOVERY.md §3." ;;
|
||
esac ;;
|
||
|
||
tools)
|
||
# Tools submenu — utilities you invoke. Each leaf is also reachable
|
||
# directly (SUPER+CTRL+<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)
|
||
# System submenu — connectivity and machine state. Snapshots and the
|
||
# power-profile picker self-gate (BTRFS+snapper / laptop on ppd), so
|
||
# the submenu shrinks to match the hardware, like the Waybar modules.
|
||
choice=$( {
|
||
row "Network${menuHint "network"}" network-wireless
|
||
row "VPN" network-vpn
|
||
command -v blueman-manager >/dev/null 2>&1 \
|
||
&& row "Bluetooth${menuHint "bluetooth"}" bluetooth
|
||
[ -e "''${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/pulse/native" ] \
|
||
&& row "Audio" audio-volume-high
|
||
row "Display" video-display
|
||
command -v nomarchy-keyboard-layout >/dev/null 2>&1 \
|
||
&& row "Keyboard${menuHint "keyboard"}" preferences-desktop-keyboard
|
||
command -v system-config-printer >/dev/null 2>&1 && row "Printers" printer
|
||
row "Do Not Disturb${menuHint "dnd"}" notification-disabled
|
||
if [ "$(nomarchy-theme-sync get settings.autoTimezone 2>/dev/null)" = true ]
|
||
then row "Auto timezone (on)" preferences-system-time
|
||
else row "Auto timezone (off)" preferences-system-time
|
||
fi
|
||
# Auto-login — self-gated on the tool (greeter.enable). Off = the
|
||
# greeter asks; that's the prompt the Fingerprint switch feeds.
|
||
if command -v nomarchy-autologin >/dev/null 2>&1; then
|
||
# Unset (get exits 1, empty) and null both read as off. "None" is
|
||
# what an older theme-sync printed for null — accepted so a menu
|
||
# from a newer generation can't misreport against an older tool.
|
||
case "$(nomarchy-theme-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-theme-sync get settings.autoCommit 2>/dev/null)" = true ]
|
||
then row "Auto-commit (on)" git
|
||
else row "Auto-commit (off)" git
|
||
fi
|
||
fi
|
||
command -v nomarchy-snapshots >/dev/null 2>&1 && row "Snapshots" timeshift
|
||
row "Rollback" edit-undo
|
||
# Plain-language last-rebuild summary (nvd; BACKLOG #82).
|
||
command -v nomarchy-what-changed >/dev/null 2>&1 \
|
||
&& row "What changed?" view-refresh
|
||
command -v nomarchy-doctor >/dev/null 2>&1 && row "Doctor" utilities-system-monitor
|
||
# Firmware/LVFS updates — self-gated on fwupd (default-on, but off
|
||
# on VMs/headless via services.fwupd.enable=false), like Printers.
|
||
command -v fwupdmgr >/dev/null 2>&1 && row "Firmware" firmware-manager
|
||
# Fingerprint — self-gated on fprintd CLI (services.fprintd; BACKLOG #55).
|
||
command -v fprintd-list >/dev/null 2>&1 && row "Fingerprint" preferences-desktop-user-password
|
||
command -v nomarchy-control-center >/dev/null 2>&1 && row "Control Center" preferences-desktop
|
||
if has_system_battery && command -v powerprofilesctl >/dev/null 2>&1; then
|
||
row "Power profile" preferences-system-power
|
||
fi
|
||
# Gated on a battery (like Power profile), NOT on the
|
||
# charge-threshold sysfs node: firmware without the control must
|
||
# get the leaf's explanation, not a silently missing row that
|
||
# reads as a broken feature (BACKLOG #96, Acer M5-481T).
|
||
has_system_battery && row "Battery limit" battery-080
|
||
back
|
||
} | rofi_menu -show-icons -markup-rows -p System) || exit 0
|
||
case "$choice" in
|
||
"$BACK") exec "$0" ;;
|
||
*Network*) exec "$0" network ;;
|
||
*VPN*) exec "$0" vpn ;;
|
||
*Bluetooth*) exec "$0" bluetooth ;;
|
||
*Audio*) exec "$0" audio ;;
|
||
*Display*) exec "$0" display ;;
|
||
*Keyboard*) exec "$0" keyboard ;;
|
||
*Printers*) exec "$0" printers ;;
|
||
*"Do Not Disturb"*) exec "$0" dnd ;;
|
||
*"Auto timezone"*) exec "$0" autotimezone ;;
|
||
*"Auto-login"*) exec "$0" autologin ;;
|
||
*"Auto-commit"*) exec "$0" autocommit ;;
|
||
*Snapshots*) exec "$0" snapshot ;;
|
||
*Rollback*) exec "$0" rollback ;;
|
||
*"What changed"*) exec "$0" whatchanged ;;
|
||
*Doctor*) exec "$0" doctor ;;
|
||
*Firmware*) exec "$0" firmware ;;
|
||
*Fingerprint*) exec "$0" fingerprint ;;
|
||
*"Control Center"*) exec "$0" controlcenter ;;
|
||
*"Power profile"*) exec "$0" power-profile ;;
|
||
*"Battery limit"*) exec "$0" batterylimit ;;
|
||
esac ;;
|
||
|
||
batterylimit)
|
||
# Battery charge-limit preset picker — the rofi-System counterpart of
|
||
# the control-center's toggle (Bernardo: he wanted this in the menu,
|
||
# not buried in the gum TUI). Writes the baked NixOS option
|
||
# Persist + live sysfs apply (same as powermgmt). Boot/AC re-apply
|
||
# stays with the oneshot in power.nix.
|
||
has_charge_threshold \
|
||
|| { notify-send "Battery limit" \
|
||
"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-theme-sync get settings.power.batteryChargeLimit 2>/dev/null)
|
||
sel=$( {
|
||
row "80% (recommended)" battery-080
|
||
row "90%" battery-090
|
||
row "60%" battery-060
|
||
row "Off — charge to 100%" battery-100
|
||
row "Custom…" battery
|
||
back
|
||
} | rofi_menu -show-icons -p "Battery limit (now: ''${cur:-default})") || exit 0
|
||
set_limit() {
|
||
nomarchy-theme-sync --quiet set settings.power.batteryChargeLimit "$1" --no-switch
|
||
n="$1"; [ "$n" = null ] && n=100
|
||
applied=
|
||
if systemctl restart nomarchy-battery-charge-limit.service 2>/dev/null; then
|
||
applied=1
|
||
else
|
||
for d in /sys/class/power_supply/*/; do
|
||
[ "$(cat "$d/type" 2>/dev/null)" = Battery ] || continue
|
||
[ "$(cat "$d/scope" 2>/dev/null || echo System)" = Device ] && continue
|
||
if [ "$n" -lt 100 ] 2>/dev/null; then
|
||
for tnode in charge_types charge_type; do
|
||
[ -w "$d$tnode" ] || continue
|
||
listed=$(cat "$d$tnode" 2>/dev/null || true)
|
||
case " $listed " in *" Custom "*|*"Custom"*) echo Custom > "$d$tnode" 2>/dev/null || true ;; esac
|
||
done
|
||
fi
|
||
thresh="$d/charge_control_end_threshold"
|
||
if [ -w "$thresh" ] && echo "$n" > "$thresh" 2>/dev/null; then
|
||
applied=1
|
||
fi
|
||
done
|
||
fi
|
||
actual=; ctype=
|
||
for d in /sys/class/power_supply/*/; do
|
||
[ "$(cat "$d/type" 2>/dev/null)" = Battery ] || continue
|
||
[ "$(cat "$d/scope" 2>/dev/null || echo System)" = Device ] && continue
|
||
[ -r "$d/charge_control_end_threshold" ] \
|
||
&& actual=$(cat "$d/charge_control_end_threshold" 2>/dev/null)
|
||
ctype=$(cat "$d/charge_types" 2>/dev/null || cat "$d/charge_type" 2>/dev/null || true)
|
||
[ -n "$actual" ] && break
|
||
done
|
||
active=$(printf '%s' "$ctype" | sed -n 's/.*\[\([^]]*\)\].*/\1/p')
|
||
extra=; [ -n "$active" ] && extra=" (type $active)"
|
||
if [ -n "$applied" ] && [ -n "$actual" ]; then
|
||
notify-send "Battery limit" "$2 — stop at $actual%$extra."
|
||
elif [ -n "$applied" ]; then
|
||
notify-send "Battery limit" "$2 — applied (could not re-read sysfs)."
|
||
else
|
||
notify-send -u critical "Battery limit" \
|
||
"Saved in theme-state but could not write sysfs (need rebuild or users-group on threshold)."
|
||
fi
|
||
}
|
||
case "$sel" in
|
||
"$BACK") exec "$0" system ;;
|
||
# Order: Off (100%) before 90/80/60 so "100%" does not hit *0%* patterns.
|
||
*Off*|*100%*) set_limit null "Off — charges to 100%" ;;
|
||
*90%*) set_limit 90 "Set to 90%" ;;
|
||
*80%*) set_limit 80 "Set to 80%" ;;
|
||
*60%*) set_limit 60 "Set to 60%" ;;
|
||
*Custom*)
|
||
limit=$(rofi_menu -p "Charge limit % (50–100)" < /dev/null) || exit 0
|
||
{ [ -n "$limit" ] && [ "$limit" != "$BACK" ]; } || exit 0
|
||
case "$limit" in
|
||
*[!0-9]*) notify-send "Battery limit" "Not a whole number: $limit"; exit 0 ;;
|
||
esac
|
||
if [ "$limit" -ge 50 ] && [ "$limit" -le 100 ]; then
|
||
set_limit "$limit" "Set to $limit%"
|
||
else
|
||
notify-send "Battery limit" "Out of range (50–100): $limit"
|
||
fi ;;
|
||
esac ;;
|
||
|
||
"")
|
||
# Root picker — grouped into category submenus so the top level stays
|
||
# short and scannable. Apps/Theme/Power/Keybindings are frequent enough
|
||
# to keep at the top; everything else lives under Tools or System.
|
||
choice=$( {
|
||
row "Apps${appsHint}" applications-all
|
||
row "Look & Feel" preferences-desktop-theme
|
||
row "Tools${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|power|power-profile|powermgmt|batterylimit|theme|clipboard|calc|files|emoji|web|network|bluetooth|audio|display|display-profile|keyboard|printers|capture|colorpicker|keybinds|ask|dnd|nightlight|autotimezone|autologin|autocommit|vpn|snapshot|doctor|firmware|fingerprint|controlcenter|rollback|whatchanged]" >&2
|
||
exit 64 ;;
|
||
esac
|
||
'';
|
||
|
||
# VPN setup + management — a dedicated System submenu (and the custom/vpn
|
||
# Waybar indicator's click target). Goes past networkmanager_dmenu (which
|
||
# only connects existing VPNs) to importing configs and driving Tailscale:
|
||
# · NetworkManager VPN/WireGuard connections, ● active / ○ inactive, toggled
|
||
# up/down (networkmanager-group users need no sudo).
|
||
# · Import a WireGuard .conf or OpenVPN .ovpn (nmcli import; OpenVPN needs the
|
||
# networkmanager-openvpn plugin, shipped system-side).
|
||
# · Tailscale (self-gated on the CLI being present = nomarchy.services.tailscale,
|
||
# which makes the login user the operator): status is read-only; up/down/
|
||
# exit-node run inline without sudo thanks to the operator grant, falling
|
||
# back to a sudo terminal if it's absent. First login goes to a terminal.
|
||
# Secrets stay in NetworkManager's / Tailscale's own store — no new manager.
|
||
nomarchy-vpn = pkgs.writeShellScriptBin "nomarchy-vpn" ''
|
||
set -u
|
||
BACK="↩ Back"
|
||
# Own helper — this is a separate package from nomarchy-menu, so the
|
||
# menu's rofi_menu function is not in scope (was a silent no-op: every
|
||
# VPN submenu died with "rofi_menu: command not found").
|
||
rofi_menu() {
|
||
local out rc
|
||
# Release Left from kb-move-char-back first — rofi hard-fails on a
|
||
# duplicate binding (same fix as the menu's rofi_menu above).
|
||
out=$(command rofi -dmenu -i -kb-move-char-back Control+b \
|
||
-kb-custom-1 Left "$@" </dev/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";
|
||
};
|
||
};
|
||
};
|
||
};
|
||
}
|