feat(blankpad): desk.sh profile switcher, hotplug autoswitch daemon, keybinds doc generator + bats suite
- desk.sh: list/current/switch/cycle/match/capture over cfg/desk-*.kdl profiles; apply = relink display.kdl, niri validate (rollback on fail), load-config-file, noctalia notification; match scores // requires: lines (EDID long names) against live niri msg outputs - desk-autoswitch.sh: watch niri msg --json event-stream for OutputsChanged, debounced desk.sh match --apply, reconnect loop - blankpad-desk-autoswitch.service: ConditionHost=blankpad user unit - gen-keybinds.sh: keybinds.kdl -> markdown cheat sheet (sections from // --- headers, hotkey-overlay-title preferred over action) - tests/: 27 bats cases, all green (desk 16, gen-keybinds 6, niri-config 5 incl. real niri validate on the assembled tree with each desk profile active)
This commit is contained in:
Executable
+50
@@ -0,0 +1,50 @@
|
||||
#!/usr/bin/env bash
|
||||
# scripts/desk-autoswitch.sh — hotplug daemon: apply the matching desk profile
|
||||
# whenever niri reports an OutputsChanged event.
|
||||
#
|
||||
# Usage: desk-autoswitch.sh watch
|
||||
#
|
||||
# Environment overrides:
|
||||
# DESK_AUTO_HOSTNAME only run on this hostname (default: blankpad)
|
||||
# DESK_SCRIPT path to desk.sh (default ~/.local/bin/desk.sh)
|
||||
# DESK_NIRI niri binary (default niri)
|
||||
# DESK_DEBOUNCE_S debounce window in seconds (default 1.5)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
HOST="${DESK_AUTO_HOSTNAME:-blankpad}"
|
||||
DESK="${DESK_SCRIPT:-$HOME/.local/bin/desk.sh}"
|
||||
NIRI="${DESK_NIRI:-niri}"
|
||||
DEBOUNCE="${DESK_DEBOUNCE_S:-1.5}"
|
||||
|
||||
usage() {
|
||||
sed -n '2,12p' "$0" | sed 's/^# \{0,1\}//'
|
||||
}
|
||||
|
||||
watch_mode() {
|
||||
[ "$(hostname)" = "$HOST" ] || { echo "desk-autoswitch: not on $HOST, exiting"; exit 0; }
|
||||
echo "desk-autoswitch: initial match"
|
||||
"$DESK" match --apply || true
|
||||
|
||||
local last=0 now
|
||||
# One JSON event per line; reconnect forever (niri restart drops the stream).
|
||||
while "$NIRI" msg --json event-stream 2>/dev/null | while IFS= read -r line; do
|
||||
case "$line" in
|
||||
*OutputsChanged*)
|
||||
now="$(date +%s.%N)"
|
||||
# swallow bursts inside the debounce window
|
||||
if awk -v l="$last" -v n="$now" -v d="$DEBOUNCE" 'BEGIN { exit !(n - l >= d) }'; then
|
||||
last="$now"
|
||||
echo "desk-autoswitch: OutputsChanged"
|
||||
"$DESK" match --apply || true
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
done; do :; done
|
||||
}
|
||||
|
||||
case "${1:-}" in
|
||||
watch) watch_mode ;;
|
||||
-h|--help) usage ;;
|
||||
*) usage >&2; exit 1 ;;
|
||||
esac
|
||||
Executable
+166
@@ -0,0 +1,166 @@
|
||||
#!/usr/bin/env bash
|
||||
# scripts/desk.sh — Desk profile management for niri (blankpad)
|
||||
#
|
||||
# Desk profiles are cfg/desk-*.kdl files. The active profile is whatever
|
||||
# file cfg/display.kdl symlinks to. Profiles declare the monitors they
|
||||
# need via comment lines:
|
||||
#
|
||||
# // requires: <niri output long name>
|
||||
# // optional: <free text>
|
||||
#
|
||||
# Subcommands:
|
||||
# list list available desk profiles
|
||||
# current show currently active profile (basename of display.kdl)
|
||||
# switch <name> apply a specific profile (name or *.kdl)
|
||||
# cycle apply the next profile (wraps)
|
||||
# match [--apply] pick the profile matching connected outputs
|
||||
# capture print `// requires:` lines for current outputs
|
||||
#
|
||||
# Environment overrides (for tests and deploys):
|
||||
# DESK_CFG_DIR config dir (default ~/.config/niri/cfg)
|
||||
# DESK_NIRI niri binary (default niri)
|
||||
# DESK_NOTIFY notification command prefix (default "noctalia msg notification-show")
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
CFG_DIR="${DESK_CFG_DIR:-$HOME/.config/niri/cfg}"
|
||||
NIRI="${DESK_NIRI:-niri}"
|
||||
NOTIFY="${DESK_NOTIFY:-noctalia msg notification-show}"
|
||||
|
||||
usage() {
|
||||
sed -n '2,20p' "$0" | sed 's/^# \{0,1\}//'
|
||||
}
|
||||
|
||||
# Profile names (basenames without .kdl), sorted.
|
||||
profiles() {
|
||||
local f
|
||||
for f in "$CFG_DIR"/desk-*.kdl; do
|
||||
[ -e "$f" ] || continue
|
||||
basename "$f" .kdl
|
||||
done | sort
|
||||
}
|
||||
|
||||
# Active profile name, or "none".
|
||||
current() {
|
||||
if [ -L "$CFG_DIR/display.kdl" ]; then
|
||||
basename "$(readlink -f "$CFG_DIR/display.kdl")" .kdl
|
||||
else
|
||||
echo none
|
||||
fi
|
||||
}
|
||||
|
||||
# Connected output long names, one per line.
|
||||
outputs() {
|
||||
"$NIRI" msg outputs 2>/dev/null | grep -oP 'Output "\K[^"]+' || true
|
||||
}
|
||||
|
||||
# requires lines of a profile, one per line.
|
||||
requires() {
|
||||
grep -oP '^//\s*requires:\s*\K.+' "$CFG_DIR/$1.kdl" 2>/dev/null || true
|
||||
}
|
||||
|
||||
notify() {
|
||||
# shellcheck disable=SC2086 # word splitting of the prefix is intended
|
||||
$NOTIFY "Desk" "$1" 2>/dev/null || true
|
||||
}
|
||||
|
||||
# Apply a profile: relink, validate live config, roll back on failure,
|
||||
# then reload + notify.
|
||||
apply_profile() {
|
||||
local name="$1"
|
||||
local file="$CFG_DIR/$name.kdl"
|
||||
local link="$CFG_DIR/display.kdl"
|
||||
|
||||
[ -f "$file" ] || { echo "error: no such profile: $name" >&2; return 1; }
|
||||
|
||||
local old=""
|
||||
[ -L "$link" ] && old="$(readlink "$link")"
|
||||
|
||||
ln -sfn "$name.kdl" "$link"
|
||||
|
||||
if ! "$NIRI" validate 2>/dev/null; then
|
||||
if [ -n "$old" ]; then ln -sfn "$old" "$link"; else rm -f "$link"; fi
|
||||
echo "error: niri validate failed; kept $( [ -n "$old" ] && basename "$old" .kdl || echo 'no' ) profile" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
"$NIRI" msg action load-config-file 2>/dev/null || true
|
||||
notify "switched to $name"
|
||||
echo "switched to $name"
|
||||
}
|
||||
|
||||
cmd_switch() {
|
||||
local target="${1:-}"
|
||||
[ -n "$target" ] || { echo "usage: desk.sh switch <profile>" >&2; return 1; }
|
||||
target="${target%.kdl}"
|
||||
apply_profile "$target"
|
||||
}
|
||||
|
||||
cmd_match() {
|
||||
local apply="${1:-}"
|
||||
local connected best="" best_n=0
|
||||
connected="$(outputs)"
|
||||
|
||||
local name req count
|
||||
while IFS= read -r name; do
|
||||
[ -n "$name" ] || continue
|
||||
count=0
|
||||
local ok=1
|
||||
while IFS= read -r req; do
|
||||
[ -n "$req" ] || continue
|
||||
if grep -qxF "$req" <<<"$connected"; then
|
||||
count=$((count + 1))
|
||||
else
|
||||
ok=0
|
||||
break
|
||||
fi
|
||||
done < <(requires "$name")
|
||||
# requires=0 profiles never match; ties go to more requires, then alphabetical
|
||||
[ "$ok" -eq 1 ] && [ "$count" -gt 0 ] && [ "$count" -gt "$best_n" ] && { best="$name"; best_n=$count; }
|
||||
done < <(profiles)
|
||||
|
||||
if [ -z "$best" ]; then
|
||||
echo "no desk profile matches connected outputs" >&2
|
||||
return 2
|
||||
fi
|
||||
if [ "$apply" = "--apply" ]; then
|
||||
apply_profile "$best"
|
||||
else
|
||||
echo "$best"
|
||||
fi
|
||||
}
|
||||
|
||||
cmd_cycle() {
|
||||
local list cur next
|
||||
list="$(profiles)"
|
||||
[ -n "$list" ] || { echo "no desk profiles found in $CFG_DIR" >&2; return 1; }
|
||||
cur="$(current)"
|
||||
next="$(awk -v cur="$cur" '
|
||||
{ a[NR]=$0 }
|
||||
END { for (i=1;i<=NR;i++) if (a[i]==cur) { print a[(i%NR)+1]; exit }
|
||||
print a[1] }' <<<"$list")"
|
||||
apply_profile "$next"
|
||||
}
|
||||
|
||||
cmd_capture() {
|
||||
local n
|
||||
n="$(outputs | wc -l)"
|
||||
[ "$n" -gt 0 ] || { echo "error: no connected outputs (is niri running?)" >&2; return 1; }
|
||||
echo "// paste into the profile:"
|
||||
outputs | sed 's/^/\/\/ requires: /'
|
||||
}
|
||||
|
||||
main() {
|
||||
case "${1:-}" in
|
||||
list) profiles ;;
|
||||
current) current ;;
|
||||
switch) shift; cmd_switch "$@" ;;
|
||||
cycle) cmd_cycle ;;
|
||||
match) shift; cmd_match "${1:-}" ;;
|
||||
capture) cmd_capture ;;
|
||||
-h|--help) usage ;;
|
||||
*) usage >&2; exit 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
main "$@"
|
||||
Executable
+81
@@ -0,0 +1,81 @@
|
||||
#!/usr/bin/env bash
|
||||
# scripts/gen-keybinds.sh — generate the niri keybinding cheat sheet (markdown)
|
||||
# from a keybinds.kdl file.
|
||||
#
|
||||
# Usage:
|
||||
# gen-keybinds.sh [keybinds.kdl] # markdown to stdout
|
||||
# gen-keybinds.sh [keybinds.kdl] --write OUT # markdown to OUT
|
||||
#
|
||||
# Default input: ~/.config/niri/cfg/keybinds.kdl
|
||||
# Recognized: section comments `// ─── Name ───`, bind lines with optional
|
||||
# hotkey-overlay-title="..."; the action inside { } is used when there is no title.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
usage() { sed -n '2,11p' "$0" | sed 's/^# \{0,1\}//'; }
|
||||
|
||||
KBD=""
|
||||
OUT=""
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
-h|--help) usage; exit 0 ;;
|
||||
--write) OUT="${2:?--write needs an output path}"; shift 2 ;;
|
||||
*) KBD="$1"; shift ;;
|
||||
esac
|
||||
done
|
||||
KBD="${KBD:-$HOME/.config/niri/cfg/keybinds.kdl}"
|
||||
[ -f "$KBD" ] || { echo "error: no such file: $KBD" >&2; exit 1; }
|
||||
|
||||
gen() {
|
||||
echo "# Niri Keybindings"
|
||||
echo
|
||||
echo "> GENERATED by \`scripts/gen-keybinds.sh\` from \`$(basename "$KBD")\` — do not edit by hand."
|
||||
echo
|
||||
awk '
|
||||
# section header: // ─── Name ───
|
||||
/^[[:space:]]*\/\/[[:space:]]*─+ (.+) ─+[[:space:]]*$/ {
|
||||
s = $0
|
||||
sub(/^[[:space:]]*\/\/[[:space:]]*─+ /, "", s)
|
||||
sub(/ ─+[[:space:]]*$/, "", s)
|
||||
if (in_table) print ""
|
||||
print "## " s
|
||||
print ""
|
||||
print "| Key | Action |"
|
||||
print "|-----|--------|"
|
||||
in_table = 1
|
||||
next
|
||||
}
|
||||
# bind line: KEY [flags] [hotkey-overlay-title="..."] { action; }
|
||||
/^[[:space:]]*[A-Z][A-Za-z0-9+]*( |$)/ && /\{/ {
|
||||
key = $1
|
||||
title = ""
|
||||
if (match($0, /hotkey-overlay-title="[^"]*"/)) {
|
||||
title = substr($0, RSTART + 22, RLENGTH - 23)
|
||||
}
|
||||
act = ""
|
||||
if (match($0, /\{[^}]*\}/)) {
|
||||
act = substr($0, RSTART + 1, RLENGTH - 2)
|
||||
sub(/;[[:space:]]*$/, "", act)
|
||||
gsub(/[[:space:]]+/, " ", act)
|
||||
sub(/^ /, "", act); sub(/ $/, "", act)
|
||||
}
|
||||
out = (title != "") ? title : act
|
||||
gsub(/\|/, "\\|", out)
|
||||
print "| `" key "` | " out " |"
|
||||
n++
|
||||
next
|
||||
}
|
||||
END {
|
||||
if (in_table) print ""
|
||||
printf("<!-- %d bindings -->\n", n) > "/dev/stderr"
|
||||
}
|
||||
' "$KBD"
|
||||
}
|
||||
|
||||
if [ -n "$OUT" ]; then
|
||||
mkdir -p "$(dirname "$OUT")"
|
||||
gen > "$OUT"
|
||||
echo "wrote $OUT" >&2
|
||||
else
|
||||
gen
|
||||
fi
|
||||
Reference in New Issue
Block a user