diff --git a/config/dot/quickshell/scripts/panama-osd b/config/dot/quickshell/scripts/panama-osd index 001aead..916c8db 100755 --- a/config/dot/quickshell/scripts/panama-osd +++ b/config/dot/quickshell/scripts/panama-osd @@ -2,6 +2,8 @@ set -u +readonly PANAMA_OSD_SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" + strict_delivery() { [[ ${PANAMA_OSD_STRICT:-false} == true || ${PANAMA_OSD_STRICT:-false} == 1 ]] } @@ -67,18 +69,151 @@ adjust_microphone() { show_volume "$target" microphone } +brightness_percent() { + local output="$1" percent + percent="$(awk -F, 'NR == 1 { value=$5; gsub(/%/, "", value); print value }' <<<"$output")" + [[ $percent =~ ^[0-9]+$ ]] || return 1 + printf '%s\n' "$percent" +} + +brightness_error() { + local detail="$1" label="External brightness unavailable" + if [[ $detail == *udev* || $detail == *accessible* || $detail == *permission* ]]; then + label="Brightness needs permission" + fi + + show_message dialog-warning-symbolic "$label" || true + if command -v notify-send >/dev/null 2>&1; then + notify-send --app-name=Panama --icon=display-brightness-symbolic \ + "Brightness unavailable" "$detail" >/dev/null 2>&1 || true + fi +} + +discover_ddc_bus() { + local helper="$1" cache_file="$2" list_json focused selected error bus connector + + command -v jq >/dev/null 2>&1 || { + brightness_error "jq is required to discover DDC/CI displays." + return 1 + } + + list_json="$("$helper" list 2>/dev/null)" || { + brightness_error "The external brightness helper could not inspect connected displays." + return 1 + } + if ! jq -e 'type == "object" and (.displays | type == "array")' >/dev/null 2>&1 <<<"$list_json"; then + brightness_error "The external brightness helper returned invalid display information." + return 1 + fi + + error="$(jq -r '.error // empty' <<<"$list_json")" + if [[ -n $error ]]; then + brightness_error "$error" + return 1 + fi + + focused="$(hyprctl -j monitors 2>/dev/null \ + | jq -r '.[] | select(.focused == true) | .name' 2>/dev/null \ + | head -n1)" + selected="$(jq -r --arg connector "$focused" ' + ([.displays[] | select(.connector == $connector)][0] // .displays[0] // empty) + | [.bus, .connector] + | @tsv + ' <<<"$list_json")" + IFS=$'\t' read -r bus connector <<<"$selected" + if [[ ! $bus =~ ^[0-9]+$ ]]; then + brightness_error "No connected monitor exposes DDC/CI brightness control." + return 1 + fi + + umask 077 + printf '%s\t%s\n' "$bus" "$connector" >"$cache_file" + printf '%s\n' "$bus" +} + +adjust_ddc_brightness() { + local action="$1" step="$2" + local helper="${PANAMA_OSD_BRIGHTNESS_HELPER:-$PANAMA_OSD_SCRIPT_DIR/panama-brightness}" + local runtime_dir="${PANAMA_OSD_RUNTIME_DIR:-${XDG_RUNTIME_DIR:-/tmp}/panama-osd-${UID}}" + local cache_file="$runtime_dir/brightness-bus" lock_file="$runtime_dir/brightness.lock" + local bus="" connector="" current target lock_fd + + [[ -x $helper ]] || { + brightness_error "The external brightness helper is not installed." + return 0 + } + mkdir -p "$runtime_dir" || return 0 + chmod 700 "$runtime_dir" 2>/dev/null || true + + exec {lock_fd}>"$lock_file" || return 0 + # DDC transactions on one I2C bus cannot safely overlap. A short wait also + # sheds an excessive key-repeat backlog instead of replaying it seconds later. + flock -w 2 "$lock_fd" || return 0 + + if [[ -r $cache_file ]]; then + IFS=$'\t' read -r bus connector <"$cache_file" || true + [[ $bus =~ ^[0-9]+$ ]] || bus="" + fi + + if [[ -n $bus ]]; then + current="$("$helper" get "$bus" 2>/dev/null)" || current="" + if [[ ! $current =~ ^[0-9]+$ ]]; then + : >"$cache_file" + bus="" + fi + fi + + if [[ -z $bus ]]; then + bus="$(discover_ddc_bus "$helper" "$cache_file")" || return 0 + current="$("$helper" get "$bus" 2>/dev/null)" || current="" + fi + if [[ ! $current =~ ^[0-9]+$ ]]; then + brightness_error "The selected monitor stopped responding over DDC/CI." + return 0 + fi + + if [[ $action == up ]]; then + target=$(( current + step )) + else + target=$(( current - step )) + fi + (( target > 100 )) && target=100 + (( target < 0 )) && target=0 + + if ! "$helper" set "$bus" "$target" >/dev/null 2>&1; then + brightness_error "The selected monitor did not accept the brightness change." + return 0 + fi + show_progress brightness "$target" "${target}%" +} + adjust_brightness() { local action="${1:-}" step="${2:-5}" output percent + [[ $step =~ ^[0-9]+$ ]] || { + printf 'Usage: panama-osd brightness up|down [step]\n' >&2 + return 2 + } case "$action" in - up) brightnessctl -e4 -n2 set "${step}%+" >/dev/null || return ;; - down) brightnessctl -e4 -n2 set "${step}%-" >/dev/null || return ;; + up|down) ;; *) printf 'Usage: panama-osd brightness up|down [step]\n' >&2; return 2 ;; esac - output="$(brightnessctl -m -c backlight 2>/dev/null)" || return 0 - percent="$(awk -F, 'NR == 1 { value=$5; gsub(/%/, "", value); print value }' <<<"$output")" - [[ $percent =~ ^[0-9]+$ ]] || return 0 - show_progress brightness "$percent" "${percent}%" + # Laptop panels expose a kernel backlight class and remain the fastest, + # most reliable path. Desktops fall through to DDC/CI monitor control. + output="$(brightnessctl -m -c backlight 2>/dev/null)" || output="" + if percent="$(brightness_percent "$output")"; then + if [[ $action == up ]]; then + brightnessctl -e4 -n2 -c backlight set "${step}%+" >/dev/null || return 0 + else + brightnessctl -e4 -n2 -c backlight set "${step}%-" >/dev/null || return 0 + fi + output="$(brightnessctl -m -c backlight 2>/dev/null)" || return 0 + percent="$(brightness_percent "$output")" || return 0 + show_progress brightness "$percent" "${percent}%" + return + fi + + adjust_ddc_brightness "$action" "$step" } media_action() { diff --git a/tests/quickshell/osd-helper-contract.sh b/tests/quickshell/osd-helper-contract.sh index 328a610..752cf94 100755 --- a/tests/quickshell/osd-helper-contract.sh +++ b/tests/quickshell/osd-helper-contract.sh @@ -26,10 +26,62 @@ printf 'brightnessctl' >>"$OSD_TEST_LOG" printf ' <%s>' "$@" >>"$OSD_TEST_LOG" printf '\n' >>"$OSD_TEST_LOG" if [[ " $* " == *" -m "* && " $* " != *" set "* ]]; then + [[ ${BACKLIGHT_AVAILABLE:-true} == true ]] || exit 1 printf '%s\n' "${BRIGHTNESS_OUTPUT:-intel_backlight,backlight,500,1000,50%}" fi SH +cat >"$scratch/bin/panama-brightness" <<'SH' +#!/bin/bash +printf 'panama-brightness' >>"$OSD_TEST_LOG" +printf ' <%s>' "$@" >>"$OSD_TEST_LOG" +printf '\n' >>"$OSD_TEST_LOG" + +case "${1:-}" in + list) + if [[ -n ${DDC_LIST_JSON:-} ]]; then + printf '%s\n' "$DDC_LIST_JSON" + else + printf '%s\n' '{"displays":[],"error":"No displays"}' + fi + ;; + get) + [[ ${DDC_FAIL_GET_BUS:-} != "${2:-}" ]] || exit 1 + if [[ -s $OSD_DDC_STATE ]]; then + cat "$OSD_DDC_STATE" + else + printf '%s\n' "${DDC_GET_VALUE:-40}" + fi + ;; + set) + if [[ -n ${DDC_SET_DELAY:-} ]]; then + if ! mkdir "$OSD_DDC_PROBE" 2>/dev/null; then + printf 'ddc-overlap\n' >>"$OSD_TEST_LOG" + fi + sleep "$DDC_SET_DELAY" + rmdir "$OSD_DDC_PROBE" 2>/dev/null || true + fi + printf '%s\n' "${3:-0}" >"$OSD_DDC_STATE" + ;; + *) exit 2 ;; +esac +SH + +cat >"$scratch/bin/hyprctl" <<'SH' +#!/bin/bash +printf 'hyprctl' >>"$OSD_TEST_LOG" +printf ' <%s>' "$@" >>"$OSD_TEST_LOG" +printf '\n' >>"$OSD_TEST_LOG" +printf '[{"name":"%s","focused":true}]\n' "${FOCUSED_MONITOR:-DP-2}" +SH + +cat >"$scratch/bin/notify-send" <<'SH' +#!/bin/bash +printf 'notify-send' >>"$OSD_TEST_LOG" +printf ' <%s>' "$@" >>"$OSD_TEST_LOG" +printf '\n' >>"$OSD_TEST_LOG" +SH + cat >"$scratch/bin/playerctl" <<'SH' #!/bin/bash printf 'playerctl' >>"$OSD_TEST_LOG" @@ -53,9 +105,21 @@ SH chmod +x "$scratch/bin/"* run_helper() { + local runtime="${OSD_RUNTIME_DIR:-$scratch/runtime-default}" + mkdir -p "$runtime" PATH="$scratch/bin:$PATH" OSD_TEST_LOG="$log" \ OSD_TEST_FAIL_QS="${OSD_TEST_FAIL_QS:-false}" \ PANAMA_OSD_STRICT="${PANAMA_OSD_STRICT:-false}" \ + PANAMA_OSD_BRIGHTNESS_HELPER="$scratch/bin/panama-brightness" \ + PANAMA_OSD_RUNTIME_DIR="$runtime" \ + OSD_DDC_STATE="$runtime/ddc-state" \ + OSD_DDC_PROBE="$runtime/ddc-probe" \ + BACKLIGHT_AVAILABLE="${BACKLIGHT_AVAILABLE:-true}" \ + DDC_LIST_JSON="${DDC_LIST_JSON:-}" \ + DDC_GET_VALUE="${DDC_GET_VALUE:-40}" \ + DDC_FAIL_GET_BUS="${DDC_FAIL_GET_BUS:-}" \ + DDC_SET_DELAY="${DDC_SET_DELAY:-}" \ + FOCUSED_MONITOR="${FOCUSED_MONITOR:-DP-2}" \ "$helper" "$@" } @@ -86,9 +150,102 @@ assert_line 'qs <72> <100> "$log" run_helper brightness up 5 -assert_line 'brightnessctl <-e4> <-n2> <5%+>' assert_line 'brightnessctl <-m> <-c> ' +assert_line 'brightnessctl <-e4> <-n2> <-c> <5%+>' assert_line 'qs <50> <100> <50%>' +if grep -Fq 'panama-brightness' "$log"; then + printf 'osd helper contract: DDC fallback ran despite a native backlight\n' >&2 + exit 1 +fi + +: >"$log" +OSD_RUNTIME_DIR="$scratch/runtime-ddc" \ +BACKLIGHT_AVAILABLE=false \ +DDC_LIST_JSON='{"displays":[{"bus":3,"connector":"HDMI-A-1","value":35},{"bus":5,"connector":"DP-2","value":40}],"error":""}' \ +run_helper brightness up 5 +assert_line 'hyprctl <-j> ' +assert_line 'panama-brightness ' +assert_line 'panama-brightness <5>' +assert_line 'panama-brightness <5> <45>' +assert_line 'qs <45> <100> <45%>' + +# A cached bus avoids the expensive display scan on subsequent key presses. +: >"$log" +OSD_RUNTIME_DIR="$scratch/runtime-ddc" \ +BACKLIGHT_AVAILABLE=false \ +DDC_LIST_JSON='{"displays":[{"bus":3,"connector":"HDMI-A-1","value":35},{"bus":5,"connector":"DP-2","value":45}],"error":""}' \ +run_helper brightness down 5 +assert_line 'panama-brightness <5>' +assert_line 'panama-brightness <5> <40>' +assert_line 'qs <40> <100> <40%>' +if grep -Fq 'panama-brightness ' "$log"; then + printf 'osd helper contract: cached DDC bus triggered another display scan\n' >&2 + exit 1 +fi + +# A disconnected cached monitor is discarded and rediscovered once. +mkdir -p "$scratch/runtime-ddc-stale" +printf '9\tDP-9\n' >"$scratch/runtime-ddc-stale/brightness-bus" +: >"$log" +OSD_RUNTIME_DIR="$scratch/runtime-ddc-stale" \ +BACKLIGHT_AVAILABLE=false \ +DDC_FAIL_GET_BUS=9 \ +DDC_LIST_JSON='{"displays":[{"bus":5,"connector":"DP-2","value":40}],"error":""}' \ +run_helper brightness up 5 +assert_line 'panama-brightness <9>' +assert_line 'panama-brightness ' +assert_line 'panama-brightness <5>' +assert_line 'panama-brightness <5> <45>' + +# If the focused output is not DDC-capable, use the first discovered display. +: >"$log" +OSD_RUNTIME_DIR="$scratch/runtime-ddc-first" \ +BACKLIGHT_AVAILABLE=false \ +FOCUSED_MONITOR='eDP-1' \ +DDC_GET_VALUE=35 \ +DDC_LIST_JSON='{"displays":[{"bus":3,"connector":"HDMI-A-1","value":35},{"bus":5,"connector":"DP-2","value":40}],"error":""}' \ +run_helper brightness down 10 +assert_line 'panama-brightness <3>' +assert_line 'panama-brightness <3> <25>' +assert_line 'qs <25> <100> <25%>' + +# Permission and discovery errors must be visible, never masquerade as 0%. +: >"$log" +OSD_RUNTIME_DIR="$scratch/runtime-ddc-error" \ +BACKLIGHT_AVAILABLE=false \ +DDC_LIST_JSON='{"displays":[],"error":"Run sudo udevadm control --reload-rules && sudo udevadm trigger --subsystem-match=i2c-dev --subsystem-match=drm"}' \ +run_helper brightness up 5 +assert_line 'qs ' +assert_line 'notify-send <--app-name=Panama> <--icon=display-brightness-symbolic> ' +if grep -Fq 'osd> ' "$log"; then + printf 'osd helper contract: unavailable brightness rendered a false percentage\n' >&2 + exit 1 +fi + +# Separate key-repeat processes must not overlap their DDC transactions. +: >"$log" +OSD_RUNTIME_DIR="$scratch/runtime-ddc-lock" \ +BACKLIGHT_AVAILABLE=false \ +DDC_SET_DELAY=0.15 \ +DDC_LIST_JSON='{"displays":[{"bus":5,"connector":"DP-2","value":40}],"error":""}' \ +run_helper brightness up 5 & +first_pid=$! +OSD_RUNTIME_DIR="$scratch/runtime-ddc-lock" \ +BACKLIGHT_AVAILABLE=false \ +DDC_SET_DELAY=0.15 \ +DDC_LIST_JSON='{"displays":[{"bus":5,"connector":"DP-2","value":40}],"error":""}' \ +run_helper brightness up 5 & +second_pid=$! +wait "$first_pid" +wait "$second_pid" +if grep -Fqx 'ddc-overlap' "$log"; then + printf 'osd helper contract: concurrent DDC transactions overlapped\n' >&2 + exit 1 +fi +if [[ $(<"$scratch/runtime-ddc-lock/ddc-state") != 50 ]]; then + printf 'osd helper contract: serialized key repeats did not both apply\n' >&2 + exit 1 +fi : >"$log" run_helper media next