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:
1 parent
4ec47a5b7e
commit
f63d11de85
10 files changed
+703
No files matched your search
@@ -0,0 +1,19 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Desk Profile Auto-Switch for Blankpad
|
||||||
|
After=graphical-session.target
|
||||||
|
ConditionHost=blankpad
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=%u
|
||||||
|
Group=%u
|
||||||
|
ExecStart=/usr/bin/env bash %h/.omp/scripts/desk-autoswitch.sh watch
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
Environment=DESK_NIRI=/usr/bin/niri
|
||||||
|
Environment=DESK_NOTIFY=noctalia msg notification-show
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=default.target
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
../../../../scripts/desk.sh
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# Handy Settings Template
|
||||||
|
|
||||||
|
This directory contains a template for Handy's settings store. The app rewrites the live store at `~/.local/share/com.pais.handy/settings_store.json`.
|
||||||
|
|
||||||
|
Keys that might contain sensitive information (e.g., API tokens, base URLs) should stay out of the repo.
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"// template: Handy settings store, rewritten live by the app": true,
|
||||||
|
"// app rewrites live store: ~/.local/share/com.pais.handy/settings_store.json": true,
|
||||||
|
"// keys stay out of repo": true,
|
||||||
|
"autostart_enabled": false,
|
||||||
|
"post_process_enabled": true,
|
||||||
|
"provider": "custom",
|
||||||
|
"base_url": "http://192.168.0.143:7771/v1",
|
||||||
|
"model": "frank-secondary",
|
||||||
|
"prompt": "default_improve_transcriptions",
|
||||||
|
"push_to_talk": true,
|
||||||
|
"bindings": {
|
||||||
|
"transcribe": "ctrl+space",
|
||||||
|
"transcribe_with_post_process": "ctrl+shift+space",
|
||||||
|
"cancel": "escape"
|
||||||
|
},
|
||||||
|
"keyboard_implementation": "handy_keys",
|
||||||
|
"overlay_position": "bottom",
|
||||||
|
"overlay_style": "live",
|
||||||
|
"paste_method": "none",
|
||||||
|
"clipboard_handling": "copy_to_clipboard"
|
||||||
|
}
|
||||||
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
|
||||||
Executable
+223
@@ -0,0 +1,223 @@
|
|||||||
|
#!/usr/bin/env bats
|
||||||
|
# tests/desk.bats — black-box tests for scripts/desk.sh
|
||||||
|
#
|
||||||
|
# desk.sh contract (see script header): subcommands list/current/switch/
|
||||||
|
# cycle/match/capture driven by env overrides DESK_CFG_DIR, DESK_NIRI,
|
||||||
|
# DESK_NOTIFY. Everything observable is via stdout/stderr/exit status,
|
||||||
|
# the display.kdl symlink, and the stub invocation log.
|
||||||
|
|
||||||
|
setup() {
|
||||||
|
TEST_TMP="$(mktemp -d)"
|
||||||
|
CFG="$TEST_TMP/cfg"
|
||||||
|
LOG="$TEST_TMP/stub.log"
|
||||||
|
mkdir -p "$CFG"
|
||||||
|
|
||||||
|
# canned `niri msg outputs` payload (live niri human format)
|
||||||
|
cat > "$TEST_TMP/outputs.txt" <<'EOF'
|
||||||
|
Output "Sceptre Tech Inc Sceptre M34 Unknown" (DP-2)
|
||||||
|
Current mode: 3440x1440 @ 239.999 Hz (current)
|
||||||
|
Output "Lenovo Group Limited 0x40A9 Unknown" (eDP-1)
|
||||||
|
Current mode: 1920x1080 @ 60.001 Hz (current, preferred)
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# stub niri: logs every invocation, serves canned outputs,
|
||||||
|
# validate exit code controlled by $STUB_VALIDATE (default 0)
|
||||||
|
cat > "$TEST_TMP/niri" <<'EOF'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
echo "niri $*" >> "$STUB_LOG"
|
||||||
|
case "$*" in
|
||||||
|
"msg outputs") cat "$STUB_OUTPUTS" ;;
|
||||||
|
"validate") exit "${STUB_VALIDATE:-0}" ;;
|
||||||
|
"msg action load-config-file") exit 0 ;;
|
||||||
|
*) exit 0 ;;
|
||||||
|
esac
|
||||||
|
EOF
|
||||||
|
chmod +x "$TEST_TMP/niri"
|
||||||
|
|
||||||
|
# stub notifier: records what it was shown
|
||||||
|
cat > "$TEST_TMP/notify" <<'EOF'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
echo "notify $*" >> "$STUB_LOG"
|
||||||
|
EOF
|
||||||
|
chmod +x "$TEST_TMP/notify"
|
||||||
|
|
||||||
|
export STUB_LOG="$LOG"
|
||||||
|
export STUB_OUTPUTS="$TEST_TMP/outputs.txt"
|
||||||
|
export DESK_CFG_DIR="$CFG"
|
||||||
|
export DESK_NIRI="$TEST_TMP/niri"
|
||||||
|
export DESK_NOTIFY="$TEST_TMP/notify"
|
||||||
|
|
||||||
|
DESK="$BATS_TEST_DIRNAME/../scripts/desk.sh"
|
||||||
|
}
|
||||||
|
|
||||||
|
teardown() {
|
||||||
|
rm -rf "$TEST_TMP"
|
||||||
|
}
|
||||||
|
|
||||||
|
make_profiles() {
|
||||||
|
cat > "$CFG/desk-1-home.kdl" <<'EOF'
|
||||||
|
// requires: Sceptre Tech Inc Sceptre M34 Unknown
|
||||||
|
// requires: Lenovo Group Limited 0x40A9 Unknown
|
||||||
|
output "Sceptre Tech Inc Sceptre M34 Unknown" { scale 1 }
|
||||||
|
EOF
|
||||||
|
cat > "$CFG/desk-2-portable.kdl" <<'EOF'
|
||||||
|
// requires: Sceptre Tech Inc Sceptre M34 Unknown
|
||||||
|
// requires: PM1601 Portable Monitor Unknown
|
||||||
|
output "Sceptre Tech Inc Sceptre M34 Unknown" { scale 1 }
|
||||||
|
EOF
|
||||||
|
# non-profile files must be ignored
|
||||||
|
echo 'include "display.kdl"' > "$CFG/config.kdl"
|
||||||
|
echo 'output "X" {}' > "$CFG/keybinds.kdl"
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "list: shows desk profiles only, sorted" {
|
||||||
|
make_profiles
|
||||||
|
run "$DESK" list
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
[ "$output" = "$(printf 'desk-1-home\ndesk-2-portable')" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "list: no profiles yields empty output" {
|
||||||
|
run "$DESK" list
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
[ -z "$output" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "current: reports none without symlink, profile with symlink" {
|
||||||
|
make_profiles
|
||||||
|
run "$DESK" current
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
[ "$output" = "none" ]
|
||||||
|
|
||||||
|
ln -sfn desk-1-home.kdl "$CFG/display.kdl"
|
||||||
|
run "$DESK" current
|
||||||
|
[ "$output" = "desk-1-home" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "switch: relinks, validates, reloads, notifies" {
|
||||||
|
make_profiles
|
||||||
|
run "$DESK" switch desk-1-home
|
||||||
|
[[ "$output" == *"switched to desk-1-home"* ]]
|
||||||
|
[ "$(basename "$(readlink "$CFG/display.kdl")")" = "desk-1-home.kdl" ]
|
||||||
|
|
||||||
|
run cat "$LOG"
|
||||||
|
[[ "$output" == *"niri validate"* ]]
|
||||||
|
[[ "$output" == *"niri msg action load-config-file"* ]]
|
||||||
|
[[ "$output" == *"notify Desk switched to desk-1-home"* ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "switch: accepts .kdl suffix" {
|
||||||
|
make_profiles
|
||||||
|
run "$DESK" switch desk-2-portable.kdl
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
[ "$(basename "$(readlink "$CFG/display.kdl")")" = "desk-2-portable.kdl" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "switch: unknown profile fails without touching the link" {
|
||||||
|
make_profiles
|
||||||
|
ln -sfn desk-1-home.kdl "$CFG/display.kdl"
|
||||||
|
run "$DESK" switch nonexistent
|
||||||
|
[ "$status" -ne 0 ]
|
||||||
|
[[ "$output" == *"no such profile"* ]]
|
||||||
|
[ "$(basename "$(readlink "$CFG/display.kdl")")" = "desk-1-home.kdl" ]
|
||||||
|
# validate must never have run for a missing profile
|
||||||
|
run cat "$LOG"
|
||||||
|
[[ "$output" != *"niri validate"* ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "switch: failed validate rolls the link back to the previous profile" {
|
||||||
|
make_profiles
|
||||||
|
ln -sfn desk-1-home.kdl "$CFG/display.kdl"
|
||||||
|
export STUB_VALIDATE=1
|
||||||
|
run "$DESK" switch desk-2-portable
|
||||||
|
[ "$status" -ne 0 ]
|
||||||
|
[[ "$output" == *"validate failed"* ]]
|
||||||
|
[ "$(basename "$(readlink "$CFG/display.kdl")")" = "desk-1-home.kdl" ]
|
||||||
|
# no reload, no notification after a failed validate
|
||||||
|
[[ "$output" != *"load-config-file"* ]]
|
||||||
|
run cat "$LOG"
|
||||||
|
[[ "$output" != *"notify"* ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "switch: failed validate with no previous profile removes the link" {
|
||||||
|
make_profiles
|
||||||
|
export STUB_VALIDATE=1
|
||||||
|
run "$DESK" switch desk-1-home
|
||||||
|
[ "$status" -ne 0 ]
|
||||||
|
[ ! -L "$CFG/display.kdl" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "match: picks the profile whose requires are all connected" {
|
||||||
|
make_profiles
|
||||||
|
run "$DESK" match
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
[ "$output" = "desk-1-home" ]
|
||||||
|
# dry run: must not touch the symlink
|
||||||
|
[ ! -L "$CFG/display.kdl" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "match --apply: applies the winner" {
|
||||||
|
make_profiles
|
||||||
|
run "$DESK" match --apply
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
[[ "$output" == *"switched to desk-1-home"* ]]
|
||||||
|
[ "$(basename "$(readlink "$CFG/display.kdl")")" = "desk-1-home.kdl" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "match: no matching profile exits nonzero and keeps the link" {
|
||||||
|
make_profiles
|
||||||
|
ln -sfn desk-2-portable.kdl "$CFG/display.kdl"
|
||||||
|
cat > "$TEST_TMP/outputs.txt" <<'EOF'
|
||||||
|
Output "Some Other Monitor 0x1234 Unknown" (DP-9)
|
||||||
|
EOF
|
||||||
|
run "$DESK" match
|
||||||
|
[ "$status" -ne 0 ]
|
||||||
|
[[ "$output" == *"no desk profile matches"* ]]
|
||||||
|
[ "$(basename "$(readlink "$CFG/display.kdl")")" = "desk-2-portable.kdl" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "match: prefers the profile with more matched requires" {
|
||||||
|
# both profiles match the connected set; desk-1 requires 2, desk-2 requires 1
|
||||||
|
cat > "$CFG/desk-1-home.kdl" <<'EOF'
|
||||||
|
// requires: Sceptre Tech Inc Sceptre M34 Unknown
|
||||||
|
// requires: Lenovo Group Limited 0x40A9 Unknown
|
||||||
|
EOF
|
||||||
|
cat > "$CFG/desk-9-guest.kdl" <<'EOF'
|
||||||
|
// requires: Sceptre Tech Inc Sceptre M34 Unknown
|
||||||
|
EOF
|
||||||
|
run "$DESK" match
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
[ "$output" = "desk-1-home" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "cycle: advances to the next profile and wraps around" {
|
||||||
|
make_profiles
|
||||||
|
ln -sfn desk-1-home.kdl "$CFG/display.kdl"
|
||||||
|
run "$DESK" cycle
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
[[ "$output" == *"switched to desk-2-portable"* ]]
|
||||||
|
|
||||||
|
run "$DESK" cycle
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
[[ "$output" == *"switched to desk-1-home"* ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "cycle: with no active profile applies the first" {
|
||||||
|
make_profiles
|
||||||
|
run "$DESK" cycle
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
[[ "$output" == *"switched to desk-1-home"* ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "cycle: no profiles at all fails cleanly" {
|
||||||
|
run "$DESK" cycle
|
||||||
|
[ "$status" -ne 0 ]
|
||||||
|
[[ "$output" == *"no desk profiles"* ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "capture: prints requires lines for connected outputs" {
|
||||||
|
run "$DESK" capture
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
[[ "$output" == *"// requires: Sceptre Tech Inc Sceptre M34 Unknown"* ]]
|
||||||
|
[[ "$output" == *"// requires: Lenovo Group Limited 0x40A9 Unknown"* ]]
|
||||||
|
}
|
||||||
Executable
+70
@@ -0,0 +1,70 @@
|
|||||||
|
#!/usr/bin/env bats
|
||||||
|
# tests/gen-keybinds.bats — black-box tests for scripts/gen-keybinds.sh
|
||||||
|
#
|
||||||
|
# Contract: parse a keybinds.kdl into a markdown cheat sheet on stdout,
|
||||||
|
# or into a file with --write OUT. Observable surface: markdown rows,
|
||||||
|
# section headers, exit status, written file.
|
||||||
|
|
||||||
|
setup() {
|
||||||
|
GEN="$BATS_TEST_DIRNAME/../scripts/gen-keybinds.sh"
|
||||||
|
TMP="$(mktemp -d)"
|
||||||
|
cat > "$TMP/keybinds.kdl" <<'EOF'
|
||||||
|
binds {
|
||||||
|
// ─── Applications ───
|
||||||
|
Mod+Return hotkey-overlay-title="Open Terminal: Ghostty" { spawn "ghostty"; }
|
||||||
|
Mod+D hotkey-overlay-title="Open App Launcher: noctalia launcher" { spawn-sh "noctalia msg panel-toggle launcher"; }
|
||||||
|
Mod+E { spawn "nautilus"; }
|
||||||
|
|
||||||
|
// ─── Screenshots ───
|
||||||
|
Print hotkey-overlay-title="Fullscreen screenshot" { spawn-sh "noctalia msg screenshot-fullscreen"; }
|
||||||
|
|
||||||
|
// a plain comment, not a section
|
||||||
|
Mod+Shift+ESCAPE { show-hotkey-overlay; }
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
teardown() {
|
||||||
|
rm -rf "$TMP"
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "renders a row per bind with title preferred over action" {
|
||||||
|
run "$GEN" "$TMP/keybinds.kdl"
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
[[ "$output" == *'| `Mod+D` | Open App Launcher: noctalia launcher |'* ]]
|
||||||
|
[[ "$output" == *'| `Mod+Return` | Open Terminal: Ghostty |'* ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "binds without a title render their action" {
|
||||||
|
run "$GEN" "$TMP/keybinds.kdl"
|
||||||
|
[[ "$output" == *'| `Mod+E` | spawn "nautilus" |'* ]]
|
||||||
|
[[ "$output" == *'| `Mod+Shift+ESCAPE` | show-hotkey-overlay |'* ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "section comments become headers; plain comments are ignored" {
|
||||||
|
run "$GEN" "$TMP/keybinds.kdl"
|
||||||
|
[[ "$output" == *"## Applications"* ]]
|
||||||
|
[[ "$output" == *"## Screenshots"* ]]
|
||||||
|
[[ "$output" != *"a plain comment"* ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "row count matches bind count" {
|
||||||
|
run "$GEN" "$TMP/keybinds.kdl"
|
||||||
|
local rows
|
||||||
|
rows="$(grep -c '^| `' <<<"$output")"
|
||||||
|
[ "$rows" -eq 5 ]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "--write writes the markdown to the given path" {
|
||||||
|
run "$GEN" "$TMP/keybinds.kdl" --write "$TMP/out/keybindings.md"
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
[ -f "$TMP/out/keybindings.md" ]
|
||||||
|
grep -q '| `Mod+D` | Open App Launcher: noctalia launcher |' "$TMP/out/keybindings.md"
|
||||||
|
grep -q 'GENERATED' "$TMP/out/keybindings.md"
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "missing input file fails loudly" {
|
||||||
|
run "$GEN" "$TMP/nope.kdl"
|
||||||
|
[ "$status" -ne 0 ]
|
||||||
|
[[ "$output" == *"no such file"* ]]
|
||||||
|
}
|
||||||
Executable
+66
@@ -0,0 +1,66 @@
|
|||||||
|
#!/usr/bin/env bats
|
||||||
|
# tests/niri-config.bats — the assembled niri config must pass `niri validate`
|
||||||
|
#
|
||||||
|
# Assembly mirrors deploy: base dotfiles/niri config + cfg files, overlaid
|
||||||
|
# by the per-host dotfiles/zz-blankpad overrides, display.kdl symlinked to a
|
||||||
|
# desk profile, live noctalia.kdl copied in (the repo include is not optional).
|
||||||
|
|
||||||
|
setup() {
|
||||||
|
REPO="$BATS_TEST_DIRNAME/.."
|
||||||
|
NIRI_BIN="$(command -v niri || true)"
|
||||||
|
TMP="$(mktemp -d)"
|
||||||
|
}
|
||||||
|
|
||||||
|
teardown() {
|
||||||
|
rm -rf "$TMP"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Assemble a config tree with the given desk profile active.
|
||||||
|
assemble() {
|
||||||
|
local profile="${1:-desk-1-home}"
|
||||||
|
mkdir -p "$TMP/niri"
|
||||||
|
cp -r "$REPO/dotfiles/niri/.config/niri/." "$TMP/niri/"
|
||||||
|
cp -f "$REPO/dotfiles/zz-blankpad/niri/.config/niri/cfg/"*.kdl "$TMP/niri/cfg/"
|
||||||
|
ln -sfn "$profile.kdl" "$TMP/niri/cfg/display.kdl"
|
||||||
|
[ -f "$HOME/.config/niri/noctalia.kdl" ] &&
|
||||||
|
cp "$HOME/.config/niri/noctalia.kdl" "$TMP/niri/noctalia.kdl"
|
||||||
|
}
|
||||||
|
|
||||||
|
require_niri() {
|
||||||
|
[ -n "$NIRI_BIN" ] || skip "niri binary not found"
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "assembly: host overrides win over base files" {
|
||||||
|
assemble
|
||||||
|
# the cycle-desk bind only exists in the zz-blankpad keybinds
|
||||||
|
grep -q 'desk.sh cycle' "$TMP/niri/cfg/keybinds.kdl"
|
||||||
|
! grep -q 'desk.sh cycle' "$REPO/dotfiles/niri/.config/niri/cfg/keybinds.kdl"
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "validate: assembled config with desk-1-home active" {
|
||||||
|
require_niri
|
||||||
|
assemble desk-1-home
|
||||||
|
run "$NIRI_BIN" validate -c "$TMP/niri/config.kdl"
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "validate: assembled config with desk-2-portable active" {
|
||||||
|
require_niri
|
||||||
|
assemble desk-2-portable
|
||||||
|
run "$NIRI_BIN" validate -c "$TMP/niri/config.kdl"
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "validate: config is valid with the optional includes absent" {
|
||||||
|
require_niri
|
||||||
|
assemble
|
||||||
|
rm -f "$TMP/niri/cfg/switch-events.kdl" "$TMP/niri/cfg/display.kdl"
|
||||||
|
run "$NIRI_BIN" validate -c "$TMP/niri/config.kdl"
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "desk profiles: every profile declares at least one requires line" {
|
||||||
|
for f in "$REPO"/dotfiles/zz-blankpad/niri/.config/niri/cfg/desk-*.kdl; do
|
||||||
|
grep -q '^// requires:' "$f"
|
||||||
|
done
|
||||||
|
}
|
||||||
Reference in new issue
Block a user