Files
command-center/scripts/desk.sh
T
Frank f63d11de85 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)
2026-10-09 14:40:26 -07:00

167 lines
4.4 KiB
Bash
Executable File

#!/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 "$@"