Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f63b683979 |
@@ -1,44 +1,24 @@
|
||||
// Brightness, from whichever source this machine actually has.
|
||||
// Backlight slider, via brightnessctl.
|
||||
//
|
||||
// Two exist and they are not interchangeable:
|
||||
//
|
||||
// The kernel backlight class, driven by brightnessctl. Laptop panels have it;
|
||||
// this desktop does not -- brightnessctl reports only keyboard and NIC LEDs.
|
||||
//
|
||||
// DDC/CI, the channel the buttons on a monitor's bezel drive. That is the
|
||||
// only brightness an external display has, and it is per-monitor.
|
||||
//
|
||||
// A machine may have neither, either, or both, so this renders a row per source
|
||||
// found and removes itself entirely when there are none, rather than sitting
|
||||
// there as a dead control.
|
||||
//
|
||||
// Connector labels appear only when there is more than one row. A single
|
||||
// slider needs no explanation of which screen it dims.
|
||||
// This machine drives an external DisplayPort monitor and has no backlight
|
||||
// class device at all (brightnessctl only reports keyboard/NIC LEDs), so the
|
||||
// row removes itself rather than sitting there as a dead control. Probed once
|
||||
// at startup — backlight devices do not appear and disappear.
|
||||
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import qs.widgets
|
||||
import qs.config
|
||||
import qs.services
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
property bool backlightAvailable: false
|
||||
property real backlightValue: 0
|
||||
property bool available: false
|
||||
property real value: 0
|
||||
|
||||
readonly property int rowCount: (root.backlightAvailable ? 1 : 0) + Brightness.displays.length
|
||||
readonly property bool labelled: root.rowCount > 1
|
||||
|
||||
visible: root.rowCount > 0
|
||||
implicitHeight: rows.implicitHeight
|
||||
|
||||
// Probing I2C takes on the order of a second, so it waits until the panel
|
||||
// is actually on screen rather than running at shell startup. Monitors do
|
||||
// not come and go, so once is enough.
|
||||
onVisibleChanged: if (visible && !Brightness.scanned) Brightness.refresh()
|
||||
Component.onCompleted: if (root.visible && !Brightness.scanned) Brightness.refresh()
|
||||
visible: root.available
|
||||
implicitHeight: root.available ? 32 : 0
|
||||
|
||||
// `-m` is the machine-readable form: name,class,current,percent,max
|
||||
Process {
|
||||
@@ -54,85 +34,18 @@ Item {
|
||||
const fields = line.split(",");
|
||||
if (fields.length < 5 || fields[1] !== "backlight")
|
||||
continue;
|
||||
root.backlightAvailable = true;
|
||||
root.backlightValue = parseInt(fields[3]) / 100;
|
||||
root.available = true;
|
||||
root.value = parseInt(fields[3]) / 100;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function applyBacklight(v: real): void {
|
||||
root.backlightValue = v;
|
||||
function apply(v: real): void {
|
||||
root.value = v;
|
||||
// Never go fully dark: a 0% backlight looks like a broken shell.
|
||||
Quickshell.execDetached(["brightnessctl", "-c", "backlight", "-q", "set", Math.max(1, Math.round(v * 100)) + "%"]);
|
||||
}
|
||||
|
||||
Column {
|
||||
id: rows
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
spacing: 4
|
||||
|
||||
BrightnessRow {
|
||||
width: rows.width
|
||||
visible: root.backlightAvailable
|
||||
label: "Built-in"
|
||||
value: root.backlightValue
|
||||
onMoved: v => root.applyBacklight(v)
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: Brightness.displays
|
||||
|
||||
BrightnessRow {
|
||||
required property var modelData
|
||||
width: rows.width
|
||||
// Hyprland already knows what each output is called, so the
|
||||
// name comes from there rather than from a second source that
|
||||
// could disagree with the Displays page. The connector is the
|
||||
// fallback, so a display is never an unlabelled slider.
|
||||
label: Displays.monitorNamed(modelData.connector)?.description || modelData.connector
|
||||
value: modelData.value / 100
|
||||
onMoved: v => Brightness.set(modelData.bus, Math.round(v * 100))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
component BrightnessRow: Item {
|
||||
id: row
|
||||
|
||||
property string label: ""
|
||||
property real value: 0
|
||||
signal moved(real value)
|
||||
|
||||
implicitHeight: caption.height + control.height
|
||||
|
||||
Text {
|
||||
id: caption
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.leftMargin: 6
|
||||
anchors.rightMargin: 6
|
||||
anchors.top: parent.top
|
||||
visible: root.labelled
|
||||
height: visible ? implicitHeight + 2 : 0
|
||||
text: row.label
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
|
||||
// The slider and its glyph share one strip so the two stay aligned
|
||||
// whether or not a caption sits above them. Anchoring the glyph to
|
||||
// both a caption and a centre line instead would conflict, and an
|
||||
// anchor set to undefined is not released.
|
||||
Item {
|
||||
id: control
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.top: caption.bottom
|
||||
height: 32
|
||||
|
||||
// ValueSlider draws its own leading icon, but symbolic icons need
|
||||
// recolouring to be visible — see ThemedIcon.
|
||||
ThemedIcon {
|
||||
@@ -150,9 +63,7 @@ Item {
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: 32
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
value: row.value
|
||||
onMoved: v => row.moved(v)
|
||||
}
|
||||
}
|
||||
value: root.value
|
||||
onMoved: v => root.apply(v)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Displays.
|
||||
//
|
||||
// Resolution, refresh rate, scale, and rotation, plus panel brightness and the
|
||||
// gaming display policy that was already here.
|
||||
// Resolution, refresh rate, scale, and rotation, plus the gaming display
|
||||
// policy that was already here.
|
||||
//
|
||||
// Every geometry change goes through an apply-then-confirm countdown. This is
|
||||
// the one page where a wrong value can leave the screen unreadable or blank,
|
||||
@@ -32,14 +32,7 @@ SettingsPage {
|
||||
root.selectedOutput = Displays.monitors.length > 0 ? Displays.monitors[0].name : "";
|
||||
}
|
||||
|
||||
// Probing I2C for DDC-capable monitors takes on the order of a second, so
|
||||
// it runs when this page is opened rather than at shell startup. Monitors
|
||||
// do not appear while you are looking at a settings page, so once is enough.
|
||||
Component.onCompleted: {
|
||||
root.syncSelectedOutput();
|
||||
if (!Brightness.scanned)
|
||||
Brightness.refresh();
|
||||
}
|
||||
Component.onCompleted: root.syncSelectedOutput()
|
||||
Connections {
|
||||
target: Displays
|
||||
function onMonitorsChanged(): void { root.syncSelectedOutput(); }
|
||||
@@ -207,45 +200,6 @@ SettingsPage {
|
||||
SliderRow { setting: "nightLightTemperature"; divider: false }
|
||||
}
|
||||
|
||||
// Panel brightness, over DDC/CI.
|
||||
//
|
||||
// This is hardware state rather than a stored preference: the monitor
|
||||
// remembers it, the bezel buttons change it behind Panama's back, and
|
||||
// writing it into settings.json would mean restoring a value the panel had
|
||||
// already moved on from. So there is no schema key here and no SliderRow --
|
||||
// the rows read and write the display directly.
|
||||
SettingsCard {
|
||||
visible: Brightness.available || Brightness.lastError !== ""
|
||||
title: "Brightness"
|
||||
subtitle: Brightness.available
|
||||
? "Sent to the monitor over DDC/CI, the same channel its buttons use."
|
||||
: Brightness.lastError
|
||||
|
||||
Repeater {
|
||||
model: Brightness.displays
|
||||
|
||||
SettingRow {
|
||||
id: brightnessRow
|
||||
required property var modelData
|
||||
required property int index
|
||||
|
||||
label: Displays.monitorNamed(modelData.connector)?.description || modelData.connector
|
||||
detail: modelData.connector ? modelData.connector + " · " + modelData.value + "%"
|
||||
: modelData.value + "%"
|
||||
divider: brightnessRow.index < Brightness.displays.length - 1
|
||||
controlWidth: 190
|
||||
|
||||
ValueSlider {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
anchors.right: parent.right
|
||||
width: parent.width
|
||||
value: brightnessRow.modelData.value / 100
|
||||
onMoved: v => Brightness.set(brightnessRow.modelData.bus, Math.round(v * 100))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Gaming display policy"
|
||||
subtitle: "Applied immediately and restored when Panama starts."
|
||||
|
||||
@@ -1,125 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# External monitor brightness over DDC/CI.
|
||||
#
|
||||
# A desktop with no backlight class device has no brightness control at all --
|
||||
# brightnessctl only sees keyboard and NIC LEDs. The panel itself still has a
|
||||
# brightness setting, reachable over the monitor's DDC/CI channel (VCP feature
|
||||
# 0x10), which is what the buttons on the bezel drive.
|
||||
#
|
||||
# Usage:
|
||||
# panama-brightness list -> {"displays":[...],"error":""}
|
||||
# panama-brightness get <bus> -> integer percent
|
||||
# panama-brightness set <bus> <pct> -> applies, prints nothing
|
||||
#
|
||||
# Displays are enumerated from sysfs rather than from `ddcutil detect`. The
|
||||
# kernel publishes the connector-to-I2C-bus mapping directly, as
|
||||
# /sys/class/drm/<card>-<connector>/ddc, along with whether anything is plugged
|
||||
# in. That is better than parsing detect output in three ways: the format is
|
||||
# stable where detect's brief output is undocumented, the connector name comes
|
||||
# out exactly as Hyprland and the Displays page already spell it (DP-2), and
|
||||
# only connectors with a monitor attached get probed -- one bus on this machine
|
||||
# instead of fourteen, which is the difference between a fast scan and a slow
|
||||
# one, since each probe of an empty bus waits for a timeout.
|
||||
#
|
||||
# No model name is reported. Hyprland already knows the human-readable
|
||||
# description of every output, so the UI joins on the connector name rather than
|
||||
# having two sources of truth for what a monitor is called.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
readonly VCP_BRIGHTNESS=0x10
|
||||
|
||||
# Test seams. The contract needs to exercise enumeration and parsing on a
|
||||
# machine whose real monitors it must not touch, so both roots this script
|
||||
# reads are overridable. Nothing sets them in normal use.
|
||||
readonly DRM_ROOT="${PANAMA_BRIGHTNESS_DRM_ROOT:-/sys/class/drm}"
|
||||
readonly DEV_ROOT="${PANAMA_BRIGHTNESS_DEV_ROOT:-/dev}"
|
||||
|
||||
emit_error() {
|
||||
printf '{"displays":[],"error":%s}\n' "$(jq -Rn --arg e "$1" '$e')"
|
||||
exit 0
|
||||
}
|
||||
|
||||
command -v ddcutil >/dev/null 2>&1 || emit_error 'ddcutil is not installed'
|
||||
|
||||
# Reading a VCP value needs read/write access to the monitor's I2C bus. The
|
||||
# udev rule ddcutil ships grants that to the seat user through uaccess, but only
|
||||
# to devices created after the rule was installed -- so a machine that installed
|
||||
# ddcutil without rebooting has the rule in place and no access to show for it.
|
||||
# That is by far the most likely reason for an empty list, and it is fixable in
|
||||
# one command, so say so rather than reporting "no displays".
|
||||
has_accessible_bus() {
|
||||
local dev
|
||||
for dev in "$DEV_ROOT"/i2c-*; do
|
||||
[[ -r "$dev" && -w "$dev" ]] && return 0
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
cmd_list() {
|
||||
has_accessible_bus || emit_error 'no I2C bus is accessible. ddcutil ships a udev rule that grants this, but only to devices created after it was installed. Run: sudo udevadm control --reload-rules && sudo udevadm trigger --subsystem-match=i2c-dev --subsystem-match=drm'
|
||||
|
||||
local rows=() connector bus value path
|
||||
for path in "$DRM_ROOT"/card*-*; do
|
||||
[[ -e "$path/ddc" ]] || continue
|
||||
[[ "$(cat "$path/status" 2>/dev/null)" == "connected" ]] || continue
|
||||
|
||||
# card1-DP-2 -> DP-2, the name Hyprland uses.
|
||||
connector="$(basename "$path")"
|
||||
connector="${connector#card*-}"
|
||||
|
||||
bus="$(basename "$(readlink -f "$path/ddc")")"
|
||||
bus="${bus#i2c-}"
|
||||
[[ "$bus" =~ ^[0-9]+$ ]] || continue
|
||||
|
||||
# A monitor that does not implement 0x10 is not an error; it simply
|
||||
# cannot be controlled, and is left out rather than shown as a slider
|
||||
# that does nothing.
|
||||
value="$(cmd_get "$bus")" || continue
|
||||
[[ -n "$value" ]] || continue
|
||||
|
||||
rows+=("$(jq -cn \
|
||||
--argjson bus "$bus" \
|
||||
--arg connector "$connector" \
|
||||
--argjson value "$value" \
|
||||
'{bus: $bus, connector: $connector, value: $value}')")
|
||||
done
|
||||
|
||||
if [[ ${#rows[@]} -eq 0 ]]; then
|
||||
emit_error 'no connected monitor reports DDC/CI brightness. Some panels implement it only when "DDC/CI" is enabled in their on-screen menu.'
|
||||
fi
|
||||
|
||||
printf '{"displays":[%s],"error":""}\n' "$(IFS=,; printf '%s' "${rows[*]}")"
|
||||
}
|
||||
|
||||
# Prints the current brightness as a whole percent, or nothing when the display
|
||||
# cannot report it. `getvcp --brief` is documented as machine readable and
|
||||
# answers "VCP 10 C <current> <max>"; the max is almost always 100 but is not
|
||||
# guaranteed to be, so it is read rather than assumed.
|
||||
cmd_get() {
|
||||
local bus="$1" out current max
|
||||
out="$(timeout 10 ddcutil --bus "$bus" getvcp "$VCP_BRIGHTNESS" --brief 2>/dev/null)" || return 1
|
||||
read -r _ _ _ current max <<<"$out"
|
||||
[[ "$current" =~ ^[0-9]+$ && "$max" =~ ^[0-9]+$ && "$max" -gt 0 ]] || return 1
|
||||
printf '%s' "$(( current * 100 / max ))"
|
||||
}
|
||||
|
||||
cmd_set() {
|
||||
local bus="$1" percent="$2" max out
|
||||
[[ "$percent" =~ ^[0-9]+$ ]] || return 1
|
||||
(( percent > 100 )) && percent=100
|
||||
|
||||
out="$(timeout 10 ddcutil --bus "$bus" getvcp "$VCP_BRIGHTNESS" --brief 2>/dev/null)" || return 1
|
||||
read -r _ _ _ _ max <<<"$out"
|
||||
[[ "$max" =~ ^[0-9]+$ && "$max" -gt 0 ]] || max=100
|
||||
|
||||
timeout 10 ddcutil --bus "$bus" setvcp "$VCP_BRIGHTNESS" "$(( percent * max / 100 ))" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
case "${1:-list}" in
|
||||
list) cmd_list ;;
|
||||
get) cmd_get "${2:?bus required}" ;;
|
||||
set) cmd_set "${2:?bus required}" "${3:?percent required}" ;;
|
||||
*) printf 'usage: panama-brightness [list|get <bus>|set <bus> <percent>]\n' >&2; exit 2 ;;
|
||||
esac
|
||||
@@ -2,8 +2,6 @@
|
||||
|
||||
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 ]]
|
||||
}
|
||||
@@ -69,151 +67,18 @@ 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|down) ;;
|
||||
up) brightnessctl -e4 -n2 set "${step}%+" >/dev/null || return ;;
|
||||
down) brightnessctl -e4 -n2 set "${step}%-" >/dev/null || return ;;
|
||||
*) printf 'Usage: panama-osd brightness up|down [step]\n' >&2; return 2 ;;
|
||||
esac
|
||||
|
||||
# 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
|
||||
percent="$(awk -F, 'NR == 1 { value=$5; gsub(/%/, "", value); print value }' <<<"$output")"
|
||||
[[ $percent =~ ^[0-9]+$ ]] || return 0
|
||||
show_progress brightness "$percent" "${percent}%"
|
||||
return
|
||||
fi
|
||||
|
||||
adjust_ddc_brightness "$action" "$step"
|
||||
}
|
||||
|
||||
media_action() {
|
||||
|
||||
@@ -1,163 +0,0 @@
|
||||
pragma Singleton
|
||||
|
||||
// Panel brightness for external monitors, over DDC/CI.
|
||||
//
|
||||
// brightnessctl covers laptop panels through the kernel's backlight class. A
|
||||
// desktop driving a DisplayPort monitor has no such device, so it has no
|
||||
// brightness control at all -- the only way to dim the screen is the buttons on
|
||||
// the bezel. DDC/CI is the channel those buttons drive, and monitors expose it
|
||||
// over the same I2C lines that carry EDID.
|
||||
//
|
||||
// Two things shape everything here:
|
||||
//
|
||||
// Detection is slow. Probing every I2C bus takes on the order of a second,
|
||||
// which is far too slow to sit in front of a settings page opening. It runs
|
||||
// once, on demand, and afterwards each display is addressed by its bus number
|
||||
// directly.
|
||||
//
|
||||
// Writes are slow AND rate-limited by the monitor's firmware. A slider drag
|
||||
// emits values continuously; sending each one produces a queue the panel
|
||||
// works through seconds after the user let go, and some monitors drop or
|
||||
// garble writes that arrive too fast. So `value` updates immediately for the
|
||||
// UI and the hardware write is debounced, with only the latest value sent.
|
||||
//
|
||||
// Displays are keyed by DRM connector name (DP-2) so they line up with what
|
||||
// Hyprland, the Displays page, and the monitor list already call them.
|
||||
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import QtQuick
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-brightness"
|
||||
|
||||
// [{ bus, connector, model, value }] where value is 0..100.
|
||||
property var displays: []
|
||||
property bool scanning: false
|
||||
|
||||
// Empty when everything is fine. Carries the helper's explanation
|
||||
// otherwise -- most usefully the udev command that grants I2C access,
|
||||
// which is the difference between "brightness is unavailable" and
|
||||
// "brightness is one command away".
|
||||
property string lastError: ""
|
||||
|
||||
readonly property bool available: root.displays.length > 0
|
||||
|
||||
// True once a scan has completed, however it went. Lets the UI tell "not
|
||||
// looked yet" apart from "looked and found nothing", which otherwise render
|
||||
// identically and leave a permanently empty panel with no explanation.
|
||||
property bool scanned: false
|
||||
|
||||
// Pending writes, keyed by bus. A monitor being dragged accumulates exactly
|
||||
// one entry no matter how many values the slider emits.
|
||||
property var pending: ({})
|
||||
|
||||
function refresh(): void {
|
||||
if (root.scanning)
|
||||
return;
|
||||
root.scanning = true;
|
||||
scan.running = true;
|
||||
}
|
||||
|
||||
function displayFor(connector: string): var {
|
||||
return root.displays.find(display => display.connector === connector) ?? null;
|
||||
}
|
||||
|
||||
// Sets brightness for one display. The stored value moves at once so the
|
||||
// slider tracks the pointer; the hardware follows when the drag settles.
|
||||
function set(bus: int, percent: int): void {
|
||||
const clamped = Math.max(0, Math.min(100, Math.round(percent)));
|
||||
|
||||
root.displays = root.displays.map(display =>
|
||||
display.bus === bus ? Object.assign({}, display, { value: clamped }) : display);
|
||||
|
||||
const next = Object.assign({}, root.pending);
|
||||
next[String(bus)] = clamped;
|
||||
root.pending = next;
|
||||
writeDebounce.restart();
|
||||
}
|
||||
|
||||
Process {
|
||||
id: scan
|
||||
command: [root.helperPath, "list"]
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: {
|
||||
try {
|
||||
const parsed = JSON.parse(this.text);
|
||||
root.displays = Array.isArray(parsed.displays) ? parsed.displays : [];
|
||||
root.lastError = String(parsed.error ?? "");
|
||||
} catch (error) {
|
||||
root.displays = [];
|
||||
root.lastError = "Could not read the brightness helper's output.";
|
||||
console.warn("Brightness: could not parse helper output:", error);
|
||||
}
|
||||
root.scanning = false;
|
||||
root.scanned = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Long enough that a drag produces one write rather than dozens, short
|
||||
// enough that a single click still feels immediate.
|
||||
Timer {
|
||||
id: writeDebounce
|
||||
interval: 120
|
||||
onTriggered: root.pump()
|
||||
}
|
||||
|
||||
// Writes run one at a time, and each is read back.
|
||||
//
|
||||
// Serial because DDC/CI is a bus protocol with no arbitration: two ddcutil
|
||||
// processes talking to the same monitor interleave their exchanges and both
|
||||
// can come back with garbage. Read back because a write is not a promise --
|
||||
// panels clamp to their own range, ignore values while waking from standby,
|
||||
// and drop writes that arrive too quickly. Without the read the slider shows
|
||||
// what Panama asked for rather than what the monitor did, which is the same
|
||||
// class of lie as trusting `hyprctl keyword` to have applied something.
|
||||
property int writingBus: -1
|
||||
|
||||
function pump(): void {
|
||||
if (writer.running || reader.running)
|
||||
return;
|
||||
|
||||
for (const bus in root.pending) {
|
||||
const value = root.pending[bus];
|
||||
const remaining = Object.assign({}, root.pending);
|
||||
delete remaining[bus];
|
||||
root.pending = remaining;
|
||||
|
||||
root.writingBus = parseInt(bus);
|
||||
writer.command = [root.helperPath, "set", bus, String(value)];
|
||||
writer.running = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: writer
|
||||
onExited: {
|
||||
reader.command = [root.helperPath, "get", String(root.writingBus)];
|
||||
reader.running = true;
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: reader
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: {
|
||||
const actual = parseInt(this.text.trim());
|
||||
if (!isNaN(actual)) {
|
||||
root.displays = root.displays.map(display =>
|
||||
display.bus === root.writingBus
|
||||
? Object.assign({}, display, { value: actual })
|
||||
: display);
|
||||
}
|
||||
root.writingBus = -1;
|
||||
// Anything queued while this write was in flight goes now.
|
||||
root.pump();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
# Panama Health & Recovery Design
|
||||
|
||||
## Purpose
|
||||
|
||||
Panama Health & Recovery makes the desktop explain itself. It verifies the
|
||||
local services, dependencies, links, and integrations that Panama relies on,
|
||||
then presents useful recovery actions without asking the user to read logs or
|
||||
diagnose a collection of unrelated Linux processes.
|
||||
|
||||
The feature is intentionally quiet. A healthy desktop produces no notification,
|
||||
banner, or permanent bar ornament. Problems appear in Panama Settings and, when
|
||||
actionable, as one restrained bar indicator. User-initiated repairs receive
|
||||
immediate Prism OSD or inline feedback.
|
||||
|
||||
## Product boundaries
|
||||
|
||||
The first release covers Panama-owned or Panama-integrated functionality:
|
||||
|
||||
- Hyprland, Quickshell, the notification server, XDG desktop portals, PipeWire,
|
||||
Vicinae, the clipboard watcher, wallpaper, idle policy, and Panama's runtime
|
||||
configuration links.
|
||||
- The Panama command collection, screenshot and OCR dependencies, DDC
|
||||
brightness support, and the currently selected terminal and launcher.
|
||||
- Nextcloud, RustDesk, KDE Connect, BlueBubbles, Home Assistant, calendar
|
||||
aggregation, and the configured autostart entries.
|
||||
- Orphaned Panama processes and inhibitors, including duplicate Caffeine locks.
|
||||
- Versions and non-sensitive diagnostic context needed for a useful copied
|
||||
report.
|
||||
|
||||
It does not become a package manager, a generic system monitor, or a replacement
|
||||
for Fedora's troubleshooting tools. It never installs packages, invokes `sudo`,
|
||||
deletes user data, rewrites arbitrary configuration, or repairs services Panama
|
||||
does not own.
|
||||
|
||||
An optional integration that has never been configured is neutral **Not set
|
||||
up**, not a warning. A configured integration that cannot operate is degraded.
|
||||
This distinction prevents the health UI from pressuring the user to enable
|
||||
features they do not want.
|
||||
|
||||
## Information architecture
|
||||
|
||||
The existing **Startup & Services** destination becomes **System Health**. This
|
||||
avoids two pages reporting the same background services. Its existing Open and
|
||||
Refresh actions remain available through the richer health rows.
|
||||
|
||||
The page has four levels:
|
||||
|
||||
1. A compact summary hero: **Healthy**, **Needs attention**, or **Action
|
||||
required**, the last completed scan time, Refresh, and Copy Report.
|
||||
2. An issues-first section shown only when one or more checks are degraded.
|
||||
3. Grouped cards for Desktop Foundation, Input & Media, Integrations, and Panama
|
||||
Tools. Healthy rows remain visible but visually quiet.
|
||||
4. A short boundary note linking to GNOME or Fedora tools for system areas Panama
|
||||
does not own.
|
||||
|
||||
Each row contains a stable title, one-sentence observation, status label, and at
|
||||
most one primary action. Actions use concrete language such as **Restart
|
||||
Vicinae**, **Repair command link**, **Open Home settings**, or **View setup
|
||||
instructions**. There is no generic Fix Everything button.
|
||||
|
||||
The Settings sidebar's existing health footer becomes real and clickable. It
|
||||
shows the aggregate state and opens System Health. The top bar gains a small
|
||||
`HealthIndicator` only while an actionable warning or error exists; clicking it
|
||||
opens the same page. Background scans never publish Signal Glass events or
|
||||
desktop notifications.
|
||||
|
||||
## Diagnostic engine
|
||||
|
||||
`config/dot/quickshell/scripts/panama-doctor` is the single operating-system
|
||||
boundary. It supports:
|
||||
|
||||
- `panama-doctor --json` for a complete versioned snapshot.
|
||||
- `panama-doctor --summary` for a concise human-readable installer or terminal
|
||||
result.
|
||||
- `panama-doctor --repair CHECK_ID --json` for an explicitly allow-listed repair.
|
||||
|
||||
The helper emits one schema:
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"generatedAt": "2026-08-18T12:00:00Z",
|
||||
"summary": {
|
||||
"status": "warning",
|
||||
"healthy": 18,
|
||||
"warnings": 1,
|
||||
"errors": 0,
|
||||
"unconfigured": 2
|
||||
},
|
||||
"checks": [
|
||||
{
|
||||
"id": "launcher.panama-commands",
|
||||
"group": "panama-tools",
|
||||
"title": "Panama Commands",
|
||||
"status": "warning",
|
||||
"detail": "16 of 17 commands are loaded",
|
||||
"action": {
|
||||
"kind": "repair",
|
||||
"label": "Repair command link"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Allowed statuses are `ok`, `warning`, `error`, and `unconfigured`. Check IDs,
|
||||
group IDs, titles, and repair mappings are authored constants. Probe output may
|
||||
populate observations but can never become a command or executable argument.
|
||||
|
||||
Checks run concurrently where doing so is safe, with short per-probe timeouts.
|
||||
A failed or timed-out probe yields a check result rather than aborting the whole
|
||||
snapshot. Output order is deterministic so tests, copied reports, and visual
|
||||
rows do not jump between scans.
|
||||
|
||||
No secrets are read. The report may state whether a Home Assistant URL or token
|
||||
is configured, but never includes either value. It excludes clipboard contents,
|
||||
notification bodies, calendar event data, SSIDs, device addresses, environment
|
||||
values, file contents, and command output that has not been explicitly parsed.
|
||||
|
||||
## Quickshell state and refresh model
|
||||
|
||||
`services/Health.qml` owns the latest accepted snapshot, aggregate severity,
|
||||
busy state, last scan time, and the result of the most recent repair. It invokes
|
||||
`panama-doctor` with argument arrays through `Process`; UI components never
|
||||
construct shell commands.
|
||||
|
||||
Health performs one delayed scan after the shell reaches a stable startup state.
|
||||
It scans again when the System Health page is opened, when the user presses
|
||||
Refresh, and after a repair settles. There is no periodic polling loop while the
|
||||
desktop is idle. Services that already expose event-driven state remain the
|
||||
authoritative source for their own interactive controls; Health is a diagnostic
|
||||
snapshot, not a competing live service model.
|
||||
|
||||
Every scan receives a monotonically increasing generation. Late output from an
|
||||
older scan is discarded. A malformed snapshot leaves the last valid result in
|
||||
place, marks the diagnostic engine unavailable, and offers a bounded Retry.
|
||||
|
||||
The shell exposes a typed `health` IPC target with `refresh`, `status`, `open`,
|
||||
and `repair(id)` operations. Vicinae gains **Panama: Check System Health**, which
|
||||
opens the page and requests a fresh scan through the existing `panama-action`
|
||||
dispatcher.
|
||||
|
||||
## Repair policy
|
||||
|
||||
Repairs are narrow, reversible, and attached to one check. The first release may:
|
||||
|
||||
- Restart Panama's user services such as Vicinae, Hyprpaper, or Hypridle.
|
||||
- Recreate Panama-owned symlinks when their destination is known and tracked.
|
||||
- Reload Vicinae's Panama command collection.
|
||||
- Release duplicate user-owned inhibitors whose metadata identifies Panama and
|
||||
Caffeine.
|
||||
- Restart Quickshell through the verified `panama-action restart-shell` path.
|
||||
- Open the exact Panama Settings page required to finish credentials or entity
|
||||
selection.
|
||||
|
||||
Restarting a working service is not presented as a repair. Repairs that interrupt
|
||||
visible desktop chrome require a confirmation sheet in Settings. Navigation and
|
||||
setup actions do not. Package installation, privileged service changes, display
|
||||
mode writes, and destructive cleanup are never automatic; the UI shows concise
|
||||
instructions instead.
|
||||
|
||||
After a repair, Health rescans and judges success from the observed result. A
|
||||
zero exit status alone never turns a row green. Failure remains inline on the
|
||||
affected row and also produces the existing Panama action-failure notification
|
||||
when the action originated outside Settings.
|
||||
|
||||
## Visual language and interaction
|
||||
|
||||
System Health uses the established Settings cards and Prism tokens. Healthy
|
||||
states use a small muted green dot and subdued **Healthy** copy. Warnings use
|
||||
amber; red is reserved for functionality that is configured, required, and
|
||||
currently broken. `unconfigured` rows use neutral gray.
|
||||
|
||||
The summary hero does not use a decorative gauge, percentage score, pulse,
|
||||
shimmer, or animated gradient. A desktop is not “82% healthy.” The headline and
|
||||
issue count are more understandable and do not create false precision.
|
||||
|
||||
Rows keep their height stable while refreshing. The previous snapshot remains
|
||||
visible with a quiet **Checking…** label rather than replacing the page with a
|
||||
spinner. Keyboard focus order reaches Refresh, Copy Report, issue rows, repair
|
||||
actions, and external handoffs. Status is always expressed in text as well as
|
||||
color.
|
||||
|
||||
Before production components are edited, the page and degraded bar indicator
|
||||
will be shown in several static mocks using the existing Settings geometry. The
|
||||
chosen mock must preserve this information architecture and Panama's current
|
||||
Prism language rather than introduce a new visual system.
|
||||
|
||||
## Failure handling
|
||||
|
||||
- Missing required executables become actionable check results.
|
||||
- Missing optional applications remain neutral until configured.
|
||||
- A doctor crash, timeout, or malformed JSON does not clear the last good
|
||||
snapshot or crash Quickshell.
|
||||
- Concurrent refresh requests coalesce into one follow-up scan.
|
||||
- A repair request for an unknown or non-repairable ID is rejected before any
|
||||
process starts.
|
||||
- Copy Report uses only the already-redacted snapshot and reports clipboard
|
||||
failure inline.
|
||||
- If the Settings window is closed during a scan or repair, the process may
|
||||
finish; reopening the page shows the settled result.
|
||||
|
||||
## Verification
|
||||
|
||||
- Run the real helper against isolated fake command, config, state, and runtime
|
||||
directories and prove every status transition deterministically.
|
||||
- Validate the JSON schema, stable check IDs, deterministic ordering, and
|
||||
uniqueness of each ID.
|
||||
- Prove unconfigured integrations remain neutral while configured failures are
|
||||
degraded.
|
||||
- Prove reports contain no fixture secrets, clipboard text, calendar data,
|
||||
addresses, or unparsed environment values.
|
||||
- Exercise every repair through the allow-list, assert its exact command, and
|
||||
prove unknown IDs cannot execute anything.
|
||||
- Test scan generations, malformed snapshots, refresh coalescing, repair
|
||||
rescans, and preservation of the last valid state in a Quickshell harness.
|
||||
- Verify Settings routing, search entries, the live sidebar footer, and the
|
||||
degraded-only bar indicator without QML warnings.
|
||||
- Validate the Vicinae command and typed IPC surface.
|
||||
- Run a read-only doctor scan on the real workstation and compare key results to
|
||||
direct service checks. State-changing live repair tests require an actually
|
||||
degraded disposable target or explicit user approval.
|
||||
- Restart the live shell, inspect the fresh log, and visually review healthy,
|
||||
warning, error, unconfigured, refreshing, and repair-result states.
|
||||
|
||||
## Delivery slices
|
||||
|
||||
1. Diagnostic schema, read-only probes, redaction, and contract tests.
|
||||
2. `Health.qml`, typed IPC, startup/manual refresh, and fixture harness.
|
||||
3. System Health Settings page, live sidebar footer, search, and report copy.
|
||||
4. Degraded-only bar indicator and Vicinae command.
|
||||
5. Allow-listed repairs, confirmations, post-repair verification, and live audit.
|
||||
|
||||
The slices are one feature and land together. Their order keeps the UI backed by
|
||||
real diagnostics from its first production render.
|
||||
@@ -1,142 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# panama-brightness enumerates monitors from sysfs and speaks DDC/CI to them.
|
||||
#
|
||||
# The parts worth pinning down are the ones that decide whether a slider appears
|
||||
# at all, and whether it appears attached to the right screen:
|
||||
#
|
||||
# * only connectors with something plugged in are probed, because probing an
|
||||
# empty bus costs a timeout each and there are fourteen of them here;
|
||||
# * a panel that cannot report brightness is omitted rather than shown as a
|
||||
# control that does nothing;
|
||||
# * the connector name matches what Hyprland calls the output, since the UI
|
||||
# joins on it to get the monitor's description;
|
||||
# * no I2C access produces the udev command that fixes it, not "no displays".
|
||||
#
|
||||
# Runs entirely against fixtures. Real monitors are never touched: both the
|
||||
# sysfs root and the device root are redirected, and ddcutil is replaced on PATH.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
helper="$repo_dir/config/dot/quickshell/scripts/panama-brightness"
|
||||
|
||||
fail() {
|
||||
printf 'brightness helper contract: %s\n' "$1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
fixture="$(mktemp -d /tmp/panama-brightness.XXXXXX)"
|
||||
trap 'rm -rf "$fixture"' EXIT
|
||||
|
||||
mkdir -p "$fixture/drm" "$fixture/dev" "$fixture/bin" "$fixture/i2c"
|
||||
|
||||
# Two connectors with a monitor, two without. DP-2 answers DDC; DP-3 is
|
||||
# connected but does not implement brightness. HDMI-A-1 and DP-1 are empty and
|
||||
# must never be probed at all.
|
||||
make_connector() {
|
||||
local name="$1" bus="$2" status="$3"
|
||||
mkdir -p "$fixture/drm/$name"
|
||||
printf '%s\n' "$status" >"$fixture/drm/$name/status"
|
||||
mkdir -p "$fixture/i2c/i2c-$bus"
|
||||
ln -sfn "$fixture/i2c/i2c-$bus" "$fixture/drm/$name/ddc"
|
||||
}
|
||||
make_connector card1-DP-1 4 disconnected
|
||||
make_connector card1-DP-2 5 connected
|
||||
make_connector card1-DP-3 6 connected
|
||||
make_connector card1-HDMI-A-1 7 disconnected
|
||||
|
||||
# has_accessible_bus only needs one readable/writable node to exist.
|
||||
touch "$fixture/dev/i2c-5"
|
||||
|
||||
# Stub ddcutil. Records every bus it is asked about so the test can prove the
|
||||
# disconnected ones were skipped. Bus 6 refuses, standing in for a panel without
|
||||
# VCP 0x10.
|
||||
#
|
||||
# Bus 5 reports its brightness out of 200 rather than 100. Most panels do use
|
||||
# 100, which is exactly the problem: with a maximum of 100 the scaling
|
||||
# arithmetic is the identity, so a helper that ignored the reported maximum
|
||||
# entirely would pass every assertion. 200 makes reads and writes that skip the
|
||||
# conversion visibly wrong.
|
||||
cat >"$fixture/bin/ddcutil" <<'STUB'
|
||||
#!/usr/bin/env bash
|
||||
bus=""
|
||||
args=("$@")
|
||||
for ((i = 0; i < ${#args[@]}; i++)); do
|
||||
[[ "${args[$i]}" == "--bus" ]] && bus="${args[$((i + 1))]}"
|
||||
done
|
||||
printf '%s\n' "$bus" >>"$DDCUTIL_PROBE_LOG"
|
||||
|
||||
for arg in "$@"; do
|
||||
if [[ "$arg" == "setvcp" ]]; then
|
||||
printf 'set %s %s\n' "$bus" "${args[-1]}" >>"$DDCUTIL_SET_LOG"
|
||||
exit 0
|
||||
fi
|
||||
done
|
||||
|
||||
case "$bus" in
|
||||
5) printf 'VCP 10 C 120 200\n'; exit 0 ;;
|
||||
*) exit 1 ;;
|
||||
esac
|
||||
STUB
|
||||
chmod +x "$fixture/bin/ddcutil"
|
||||
|
||||
export DDCUTIL_PROBE_LOG="$fixture/probes.log"
|
||||
export DDCUTIL_SET_LOG="$fixture/sets.log"
|
||||
: >"$DDCUTIL_PROBE_LOG"
|
||||
: >"$DDCUTIL_SET_LOG"
|
||||
|
||||
run_helper() {
|
||||
PATH="$fixture/bin:$PATH" \
|
||||
PANAMA_BRIGHTNESS_DRM_ROOT="$fixture/drm" \
|
||||
PANAMA_BRIGHTNESS_DEV_ROOT="$fixture/dev" \
|
||||
"$helper" "$@"
|
||||
}
|
||||
|
||||
# ── Enumeration ──────────────────────────────────────────────────────────────
|
||||
listing="$(run_helper list)"
|
||||
jq -e . >/dev/null 2>&1 <<<"$listing" || fail "list did not emit JSON: $listing"
|
||||
|
||||
[[ "$(jq -r '.displays | length' <<<"$listing")" == "1" ]] \
|
||||
|| fail "expected exactly one controllable display, got: $listing"
|
||||
|
||||
[[ "$(jq -r '.displays[0].connector' <<<"$listing")" == "DP-2" ]] \
|
||||
|| fail "the connector name must match Hyprland's output name: $listing"
|
||||
|
||||
[[ "$(jq -r '.displays[0].bus' <<<"$listing")" == "5" ]] \
|
||||
|| fail "the display was mapped to the wrong I2C bus: $listing"
|
||||
|
||||
# 120 of a maximum of 200 is 60%.
|
||||
[[ "$(jq -r '.displays[0].value' <<<"$listing")" == "60" ]] \
|
||||
|| fail "brightness was not read as a percent of the reported maximum: $listing"
|
||||
|
||||
[[ "$(jq -r '.error' <<<"$listing")" == "" ]] \
|
||||
|| fail "a successful listing must not carry an error: $listing"
|
||||
|
||||
# A connected panel that cannot report brightness is dropped, not listed.
|
||||
jq -e '.displays | map(.connector) | index("DP-3") == null' >/dev/null <<<"$listing" \
|
||||
|| fail 'a display without VCP 0x10 was listed as controllable'
|
||||
|
||||
# ── Disconnected connectors are never probed ─────────────────────────────────
|
||||
if grep -qxE '4|7' "$DDCUTIL_PROBE_LOG"; then
|
||||
fail "a disconnected connector was probed -- each empty bus costs a timeout: $(tr '\n' ' ' <"$DDCUTIL_PROBE_LOG")"
|
||||
fi
|
||||
|
||||
# ── Writes scale to the reported maximum ─────────────────────────────────────
|
||||
run_helper set 5 40
|
||||
[[ "$(tail -1 "$DDCUTIL_SET_LOG")" == "set 5 80" ]] \
|
||||
|| fail "set did not scale to the display's maximum: $(cat "$DDCUTIL_SET_LOG")"
|
||||
|
||||
run_helper set 5 500
|
||||
[[ "$(tail -1 "$DDCUTIL_SET_LOG")" == "set 5 200" ]] \
|
||||
|| fail "an out-of-range percent was not clamped: $(cat "$DDCUTIL_SET_LOG")"
|
||||
|
||||
# ── No I2C access explains itself ────────────────────────────────────────────
|
||||
rm -f "$fixture/dev"/i2c-*
|
||||
denied="$(run_helper list)"
|
||||
[[ "$(jq -r '.displays | length' <<<"$denied")" == "0" ]] \
|
||||
|| fail "displays were reported without I2C access: $denied"
|
||||
grep -q 'udevadm' <<<"$(jq -r '.error' <<<"$denied")" \
|
||||
|| fail "the no-access error must name the command that fixes it, got: $(jq -r '.error' <<<"$denied")"
|
||||
|
||||
printf 'brightness helper contract: PASS\n'
|
||||
@@ -26,62 +26,10 @@ 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"
|
||||
@@ -105,21 +53,9 @@ 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" "$@"
|
||||
}
|
||||
|
||||
@@ -150,102 +86,9 @@ assert_line 'qs <ipc> <call> <osd> <progress> <microphone-muted> <72> <100> <Mut
|
||||
|
||||
: >"$log"
|
||||
run_helper brightness up 5
|
||||
assert_line 'brightnessctl <-e4> <-n2> <set> <5%+>'
|
||||
assert_line 'brightnessctl <-m> <-c> <backlight>'
|
||||
assert_line 'brightnessctl <-e4> <-n2> <-c> <backlight> <set> <5%+>'
|
||||
assert_line 'qs <ipc> <call> <osd> <progress> <brightness> <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> <monitors>'
|
||||
assert_line 'panama-brightness <list>'
|
||||
assert_line 'panama-brightness <get> <5>'
|
||||
assert_line 'panama-brightness <set> <5> <45>'
|
||||
assert_line 'qs <ipc> <call> <osd> <progress> <brightness> <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 <get> <5>'
|
||||
assert_line 'panama-brightness <set> <5> <40>'
|
||||
assert_line 'qs <ipc> <call> <osd> <progress> <brightness> <40> <100> <40%>'
|
||||
if grep -Fq 'panama-brightness <list>' "$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 <get> <9>'
|
||||
assert_line 'panama-brightness <list>'
|
||||
assert_line 'panama-brightness <get> <5>'
|
||||
assert_line 'panama-brightness <set> <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 <get> <3>'
|
||||
assert_line 'panama-brightness <set> <3> <25>'
|
||||
assert_line 'qs <ipc> <call> <osd> <progress> <brightness> <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 <ipc> <call> <osd> <message> <dialog-warning-symbolic> <Brightness needs permission>'
|
||||
assert_line 'notify-send <--app-name=Panama> <--icon=display-brightness-symbolic> <Brightness unavailable> <Run sudo udevadm control --reload-rules && sudo udevadm trigger --subsystem-match=i2c-dev --subsystem-match=drm>'
|
||||
if grep -Fq 'osd> <progress> <brightness>' "$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
|
||||
|
||||
Reference in New Issue
Block a user