Merge remaining settings work

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
This commit is contained in:
Gabriel Brown
2026-08-18 06:38:46 -04:00
15 changed files with 850 additions and 5 deletions
@@ -419,6 +419,47 @@ Singleton {
detail: "How often Panama updates the current conditions" detail: "How often Panama updates the current conditions"
}, },
// ── Which GPU the vitals readout tracks ─────────────────────────────
// A sysfs path rather than a card number, because the number is neither
// stable across machines nor meaningful. Constrained to the one shape
// that can be read for utilisation; VitalsWidget hides itself when the
// path is unreadable, so a stale value degrades to no readout rather
// than a wrong one.
{
key: "gpuBusyPath", type: "string",
def: "/sys/class/drm/card1/device/gpu_busy_percent",
group: "vitals", internal: true,
pattern: "^/sys/class/drm/card[0-9]+/device/gpu_busy_percent$",
label: "Graphics device",
detail: "Which GPU the graphics readout in the bar measures"
},
// ── Weather location ────────────────────────────────────────────────
// Coordinates rather than a place name, because that is what Open-Meteo
// takes and it needs no API key. weatherLocation is only the label shown
// in the UI; it is never sent anywhere, so it can say whatever makes the
// reading recognisable.
{
key: "weatherLatitude", type: "real", def: 27.7375, min: -90, max: 90, step: 0.0001,
group: "weather", internal: true,
label: "Latitude",
detail: "Set by choosing a location"
},
{
key: "weatherLongitude", type: "real", def: -82.6861, min: -180, max: 180, step: 0.0001,
group: "weather", internal: true,
label: "Longitude",
detail: "Set by choosing a location"
},
{
key: "weatherLocation", type: "string", def: "Local weather", group: "weather",
internal: true,
// Display only -- never sent to the weather service.
pattern: "^[^\\n]{1,64}$",
label: "Weather location",
detail: "The place the weather reading is for"
},
// ── Vitals refresh ────────────────────────────────────────────────── // ── Vitals refresh ──────────────────────────────────────────────────
{ {
key: "vitalsIntervalMs", type: "int", def: 2000, min: 500, max: 10000, step: 500, key: "vitalsIntervalMs", type: "int", def: 2000, min: 500, max: 10000, step: 500,
+4 -4
View File
@@ -22,12 +22,12 @@ Singleton {
// ── Weather ───────────────────────────────────────────────────────────── // ── Weather ─────────────────────────────────────────────────────────────
// Coordinates taken from the GNOME night-light setting, which had already // Coordinates taken from the GNOME night-light setting, which had already
// resolved the location. Uses Open-Meteo, which needs no API key. // resolved the location. Uses Open-Meteo, which needs no API key.
readonly property real latitude: 27.7375 readonly property real latitude: DesktopPreferences.get("weatherLatitude")
readonly property real longitude: -82.6861 readonly property real longitude: DesktopPreferences.get("weatherLongitude")
// Open-Meteo returns coordinates but no friendly place name. Keep the // Open-Meteo returns coordinates but no friendly place name. Keep the
// label deliberately general rather than exposing precise coordinates in // label deliberately general rather than exposing precise coordinates in
// the UI or guessing at a city from them. // the UI or guessing at a city from them.
readonly property string weatherLocation: "Local weather" readonly property string weatherLocation: DesktopPreferences.get("weatherLocation")
readonly property string temperatureUnit: DesktopPreferences.get("temperatureUnit") readonly property string temperatureUnit: DesktopPreferences.get("temperatureUnit")
readonly property int weatherRefreshMinutes: DesktopPreferences.get("weatherRefreshMinutes") readonly property int weatherRefreshMinutes: DesktopPreferences.get("weatherRefreshMinutes")
@@ -41,7 +41,7 @@ Singleton {
// amdgpu exposes utilisation here. Verified present on this machine; the // amdgpu exposes utilisation here. Verified present on this machine; the
// widget hides itself if the path is missing rather than showing zeros. // widget hides itself if the path is missing rather than showing zeros.
readonly property string gpuBusyPath: "/sys/class/drm/card1/device/gpu_busy_percent" readonly property string gpuBusyPath: DesktopPreferences.get("gpuBusyPath")
// ── Night light ───────────────────────────────────────────────────────── // ── Night light ─────────────────────────────────────────────────────────
// Matches the (disabled) GNOME schedule: 3500K from 17:00 to 10:00. // Matches the (disabled) GNOME schedule: 3500K from 17:00 to 10:00.
@@ -0,0 +1,87 @@
function clamp(value, minimum, maximum) {
return Math.max(minimum, Math.min(maximum, value));
}
function finiteNumber(value, fallback) {
var parsed = Number(value);
return isFinite(parsed) ? parsed : fallback;
}
function iconFor(kind, ratio) {
var name = String(kind || "").toLowerCase();
if (name === "volume-muted")
return "audio-volume-muted-symbolic";
if (name === "volume") {
if (ratio <= 0)
return "audio-volume-muted-symbolic";
if (ratio < 0.34)
return "audio-volume-low-symbolic";
if (ratio < 0.67)
return "audio-volume-medium-symbolic";
return "audio-volume-high-symbolic";
}
if (name === "microphone-muted")
return "microphone-sensitivity-muted-symbolic";
if (name === "microphone")
return "audio-input-microphone-symbolic";
if (name === "brightness")
return "display-brightness-symbolic";
if (name === "media-play" || name === "media-playing")
return "media-playback-start-symbolic";
if (name === "media-pause" || name === "media-paused")
return "media-playback-pause-symbolic";
if (name === "media-next")
return "media-skip-forward-symbolic";
if (name === "media-previous")
return "media-skip-backward-symbolic";
if (name === "media-stop")
return "media-playback-stop-symbolic";
return name || "dialog-information-symbolic";
}
function normalizedDuration(value) {
var parsed = finiteNumber(value, 1400);
return Math.max(0, Math.round(parsed));
}
function progressState(kind, rawValue, rawMaximum, rawLabel, rawDuration) {
var maximum = Math.max(1, finiteNumber(rawMaximum, 100));
var value = clamp(finiteNumber(rawValue, 0), 0, maximum);
var ratio = value / maximum;
var label = String(rawLabel || "");
if (!label)
label = Math.round(ratio * 100) + "%";
return {
kind: String(kind || ""),
value: value,
maximum: maximum,
ratio: ratio,
label: label,
icon: iconFor(kind, ratio),
duration: normalizedDuration(rawDuration),
progress: true
};
}
function messageState(kind, rawLabel, rawDuration) {
return {
kind: String(kind || ""),
value: 0,
maximum: 100,
ratio: 0,
label: String(rawLabel || ""),
icon: iconFor(kind, 0),
duration: normalizedDuration(rawDuration),
progress: false
};
}
if (typeof module !== "undefined") {
module.exports = {
clamp: clamp,
iconFor: iconFor,
progressState: progressState,
messageState: messageState
};
}
@@ -103,7 +103,24 @@ SettingsPage {
ToggleRow { setting: "showCpu" } ToggleRow { setting: "showCpu" }
ToggleRow { setting: "showMemory" } ToggleRow { setting: "showMemory" }
ToggleRow { setting: "showGpu"; divider: false } ToggleRow { setting: "showGpu"; divider: GraphicsDevices.devices.length > 1 || GraphicsDevices.selectionMissing }
// Only worth asking when there is a choice to make.
ChoiceGrid {
visible: GraphicsDevices.devices.length > 1 || GraphicsDevices.selectionMissing
width: parent.width
label: "Graphics device"
detail: GraphicsDevices.selectionMissing
? "The stored device is not present on this machine, so the graphics readout is hidden. Choose one below."
: "Which GPU the graphics readout measures."
options: GraphicsDevices.devices.map(device => ({
value: device.path,
label: GraphicsDevices.shortName(device.name)
}))
current: GraphicsDevices.selectedPath
divider: false
onPicked: value => GraphicsDevices.select(value)
}
} }
SettingsCard { SettingsCard {
@@ -103,6 +103,16 @@ SettingsPage {
SettingsCard { SettingsCard {
title: "Weather" title: "Weather"
subtitle: "Local conditions in the date menu" subtitle: "Local conditions in the date menu"
TextRow {
label: "Location"
detail: "Only the search term is sent; the name below is a label kept on this machine"
value: Settings.weatherLocation
}
LocationPicker {
width: parent.width
}
ChoiceRow { setting: "temperatureUnit" } ChoiceRow { setting: "temperatureUnit" }
SliderRow { setting: "weatherRefreshMinutes"; divider: false } SliderRow { setting: "weatherRefreshMinutes"; divider: false }
} }
@@ -0,0 +1,53 @@
// Choosing where the weather reading is for.
//
// A search box rather than latitude and longitude fields: nobody knows their
// own coordinates, and a control that demands them is one nobody ever uses. The
// coordinates are what actually get stored -- the name is only a label.
import QtQuick
import qs.config
import qs.services
import qs.modules.clipboard
Column {
id: root
spacing: 0
SearchField {
id: query
width: parent.width
placeholder: "Search for a town or city"
onTextChanged: Geocoding.search(query.text)
}
Repeater {
model: Geocoding.results
SettingRow {
id: place
required property var modelData
required property int index
label: place.modelData.name
detail: [place.modelData.admin, place.modelData.country].filter(part => !!part).join(", ")
value: place.modelData.latitude.toFixed(2) + ", " + place.modelData.longitude.toFixed(2)
controlWidth: 150
divider: place.index < Geocoding.results.length - 1
activatable: true
onActivated: {
if (Geocoding.choose(place.modelData))
query.text = "";
}
}
}
SettingRow {
width: parent.width
visible: Geocoding.searching || Geocoding.lastError !== ""
label: Geocoding.searching ? "Searching…" : "No result"
detail: Geocoding.searching ? "" : Geocoding.lastError
divider: false
}
}
@@ -44,3 +44,4 @@ AudioBalance 1.0 AudioBalance.qml
SoundDeviceList 1.0 SoundDeviceList.qml SoundDeviceList 1.0 SoundDeviceList.qml
SoundDeviceRow 1.0 SoundDeviceRow.qml SoundDeviceRow 1.0 SoundDeviceRow.qml
TimeOfDayRow 1.0 TimeOfDayRow.qml TimeOfDayRow 1.0 TimeOfDayRow.qml
LocationPicker 1.0 LocationPicker.qml
+47
View File
@@ -0,0 +1,47 @@
#!/usr/bin/env bash
# Enumerates GPUs that can report utilisation, with a readable name for each.
#
# Panama's vitals readout needs one specific sysfs file, and the card numbering
# is neither stable across machines nor meaningful to a person: this box has
# card1 and card2, both amdgpu, one discrete and one integrated. Picking a
# number blindly shows whichever the kernel happened to enumerate first.
#
# Names come from lspci where available, because the sysfs device directory
# exposes only numeric vendor/device ids.
set -euo pipefail
first=true
printf '['
for busy in /sys/class/drm/card*/device/gpu_busy_percent; do
[[ -r "$busy" ]] || continue
device_dir="$(dirname "$busy")"
card="$(basename "$(dirname "$device_dir")")"
# The device directory is a symlink into the PCI tree; its target's basename
# is the PCI address lspci wants.
pci="$(basename "$(readlink -f "$device_dir")" 2>/dev/null || true)"
name=""
if [[ -n "$pci" ]] && command -v lspci >/dev/null 2>&1; then
# Strip the leading domain: lspci -s wants 00:02.0, sysfs gives 0000:00:02.0
short="${pci#*:}"
name="$(lspci -s "$short" 2>/dev/null | sed -E 's/^[^ ]+ [^:]+: //' | head -1)"
fi
if [[ -z "$name" ]]; then
driver="$(sed -n 's/^DRIVER=//p' "$device_dir/uevent" 2>/dev/null | head -1)"
name="${driver:-Graphics} ($card)"
fi
reading="$(cat "$busy" 2>/dev/null || printf '')"
[[ "$reading" =~ ^[0-9]+$ ]] || reading=-1
[[ "$first" == true ]] || printf ','
first=false
printf '{"card":"%s","path":"%s","name":%s,"busy":%s}' \
"$card" "$busy" "$(printf '%s' "$name" | jq -Rs .)" "$reading"
done
printf ']\n'
+107
View File
@@ -0,0 +1,107 @@
#!/bin/bash
set -u
show_progress() {
qs ipc call osd progress "$1" "$2" 100 "$3" >/dev/null 2>&1 || true
}
show_message() {
qs ipc call osd message "$1" "$2" >/dev/null 2>&1 || true
}
volume_state() {
local target="$1" output level percent muted=false
output="$(wpctl get-volume "$target" 2>/dev/null)" || return 1
if [[ $output =~ Volume:[[:space:]]*([0-9]+([.][0-9]+)?) ]]; then
level="${BASH_REMATCH[1]}"
else
return 1
fi
[[ $output == *"[MUTED]"* ]] && muted=true
percent="$(awk -v value="$level" 'BEGIN { printf "%d", value * 100 + 0.5 }')"
printf '%s %s\n' "$percent" "$muted"
}
show_volume() {
local target="$1" kind="$2" state percent muted label
state="$(volume_state "$target")" || return 0
read -r percent muted <<<"$state"
if [[ $muted == true ]]; then
show_progress "${kind}-muted" "$percent" "Muted"
else
label="${percent}%"
show_progress "$kind" "$percent" "$label"
fi
}
adjust_volume() {
local action="${1:-}" step="${2:-6}" target="@DEFAULT_AUDIO_SINK@"
case "$action" in
up) wpctl set-volume -l 1 "$target" "${step}%+" || return ;;
down) wpctl set-volume "$target" "${step}%-" || return ;;
toggle) wpctl set-mute "$target" toggle || return ;;
*) printf 'Usage: panama-osd volume up|down|toggle [step]\n' >&2; return 2 ;;
esac
show_volume "$target" volume
}
adjust_microphone() {
local action="${1:-}" step="${2:-6}" target="@DEFAULT_AUDIO_SOURCE@"
case "$action" in
up) wpctl set-volume -l 1 "$target" "${step}%+" || return ;;
down) wpctl set-volume "$target" "${step}%-" || return ;;
toggle) wpctl set-mute "$target" toggle || return ;;
*) printf 'Usage: panama-osd microphone up|down|toggle [step]\n' >&2; return 2 ;;
esac
show_volume "$target" microphone
}
adjust_brightness() {
local action="${1:-}" step="${2:-5}" output percent
case "$action" in
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
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}%"
}
media_action() {
local action="${1:-}" kind label fallback
case "$action" in
play-pause)
playerctl play-pause || return
if [[ $(playerctl status 2>/dev/null) == "Playing" ]]; then
kind="media-play"
fallback="Playing"
else
kind="media-pause"
fallback="Paused"
fi
;;
next) playerctl next || return; kind="media-next"; fallback="Next track" ;;
previous) playerctl previous || return; kind="media-previous"; fallback="Previous track" ;;
stop) playerctl stop || return; kind="media-stop"; fallback="Stopped" ;;
*) printf 'Usage: panama-osd media play-pause|next|previous|stop\n' >&2; return 2 ;;
esac
label="$(playerctl metadata --format '{{ title }} — {{ artist }}' 2>/dev/null)"
[[ -n $label ]] || label="$fallback"
show_message "$kind" "$label"
}
case "${1:-}" in
volume) shift; adjust_volume "$@" ;;
microphone) shift; adjust_microphone "$@" ;;
brightness) shift; adjust_brightness "$@" ;;
media) shift; media_action "$@" ;;
*)
printf 'Usage: panama-osd volume|microphone|brightness|media ACTION [step]\n' >&2
exit 2
;;
esac
@@ -0,0 +1,129 @@
pragma Singleton
// Turning a place name into coordinates.
//
// The weather card needs latitude and longitude, but nobody knows their own
// coordinates, and a settings page that demands them is a settings page nobody
// changes. Open-Meteo publishes a geocoding endpoint that needs no API key and
// no account, which is the same reason the forecast itself uses them.
//
// Fetched with curl rather than XMLHttpRequest for the same reason as
// services/Weather.qml: curl is guaranteed present, and a search that fails
// must leave the page usable rather than producing an error popup.
//
// Only the query is sent. The stored location label never leaves the machine.
import Quickshell
import Quickshell.Io
import QtQuick
import qs.config
Singleton {
id: root
// [{ name, admin, country, latitude, longitude, label }]
property var results: []
property bool searching: false
property string lastError: ""
property string lastQuery: ""
readonly property string endpoint: "https://geocoding-api.open-meteo.com/v1/search"
Process {
id: fetch
stdout: StdioCollector {
onStreamFinished: root.parse(this.text)
}
onExited: (exitCode, exitStatus) => {
root.searching = false;
if (exitCode !== 0)
root.lastError = "Could not reach the location service.";
}
}
// Debounced: typing "Denver" should not fire six searches.
Timer {
id: debounce
interval: 350
onTriggered: root.run()
}
property string pending: ""
function search(query: string): void {
const trimmed = String(query).trim();
root.pending = trimmed;
if (trimmed.length < 2) {
root.results = [];
root.lastError = "";
debounce.stop();
return;
}
debounce.restart();
}
function run(): void {
if (fetch.running || root.pending.length < 2)
return;
root.searching = true;
root.lastError = "";
root.lastQuery = root.pending;
// --get with --data-urlencode makes curl do the escaping, so a place
// name with spaces or an ampersand cannot alter the request.
fetch.exec(["curl", "-s", "--max-time", "10", "--get",
"--data-urlencode", `name=${root.pending}`,
"--data-urlencode", "count=8",
"--data-urlencode", "format=json",
root.endpoint]);
}
function parse(text: string): void {
try {
const parsed = JSON.parse(text);
const out = [];
for (const item of (parsed.results ?? [])) {
if (typeof item.latitude !== "number" || typeof item.longitude !== "number")
continue;
const admin = item.admin1 ?? "";
const country = item.country ?? "";
out.push({
name: item.name ?? "",
admin: admin,
country: country,
latitude: item.latitude,
longitude: item.longitude,
// What the user will see stored as their location label.
label: [item.name, admin, country].filter(part => !!part).join(", ")
});
}
root.results = out;
root.lastError = out.length === 0 ? "No places match that name." : "";
} catch (error) {
root.results = [];
root.lastError = "The location service returned something unreadable.";
}
}
// Stores a chosen place. Coordinates are rounded to four decimals -- roughly
// ten metres, far finer than a weather reading resolves, and it keeps a
// precise home location out of the settings file.
function choose(place: var): bool {
const latitude = Math.round(place.latitude * 10000) / 10000;
const longitude = Math.round(place.longitude * 10000) / 10000;
const label = String(place.label).slice(0, 64);
const ok = DesktopPreferences.set("weatherLatitude", latitude)
&& DesktopPreferences.set("weatherLongitude", longitude)
&& DesktopPreferences.set("weatherLocation", label);
if (!ok) {
root.lastError = "That location could not be saved.";
return false;
}
root.results = [];
root.lastError = "";
return true;
}
}
@@ -0,0 +1,89 @@
pragma Singleton
// The GPUs that can report utilisation.
//
// The vitals readout needs one specific sysfs file, and card numbering is
// neither stable across machines nor meaningful to a person -- this machine has
// two amdgpu cards, one discrete and one integrated, and picking a number
// blindly measures whichever the kernel enumerated first.
//
// Enumerated on demand rather than polled: hardware does not appear while you
// are looking at a settings page.
import Quickshell
import Quickshell.Io
import QtQuick
import qs.config
Singleton {
id: root
readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-gpus"
// [{ card, path, name, busy }]
property var devices: []
property bool scanning: false
property string lastError: ""
readonly property string selectedPath: DesktopPreferences.get("gpuBusyPath")
readonly property var selected: root.devices.find(device => device.path === root.selectedPath) ?? null
// True when a GPU is stored that this machine does not have -- after moving
// the settings file between machines, say.
readonly property bool selectionMissing: root.devices.length > 0 && root.selected === null
Process {
id: scan
command: [root.helperPath]
stdout: StdioCollector {
onStreamFinished: {
try {
const parsed = JSON.parse(this.text);
root.devices = Array.isArray(parsed) ? parsed : [];
root.lastError = "";
} catch (error) {
root.devices = [];
root.lastError = "The graphics devices could not be read.";
}
root.scanning = false;
}
}
onExited: (exitCode, exitStatus) => {
root.scanning = false;
if (exitCode !== 0)
root.lastError = "The graphics devices could not be read.";
}
}
Component.onCompleted: root.refresh()
function refresh(): void {
if (scan.running)
return;
root.scanning = true;
scan.running = true;
}
// Only a path this machine actually reported is accepted, so a hand-edited
// settings file cannot point the readout at an arbitrary file.
function select(path: string): bool {
if (!root.devices.some(device => device.path === path)) {
root.lastError = "That graphics device is not present.";
return false;
}
if (!DesktopPreferences.set("gpuBusyPath", path)) {
root.lastError = "That graphics device could not be saved.";
return false;
}
root.lastError = "";
return true;
}
// "AMD ... [Radeon RX 7700 XT / 7800 XT] (rev c8)" is what lspci gives; the
// bracketed marketing name is the part anyone recognises.
function shortName(name: string): string {
const bracketed = String(name).match(/\[([^\]]+)\]\s*(?:\(rev[^)]*\))?\s*$/);
return bracketed ? bracketed[1] : String(name).replace(/\s*\(rev[^)]*\)\s*$/, "");
}
}
@@ -0,0 +1,50 @@
import Quickshell
import Quickshell.Io
import QtQuick
import qs.config
import qs.services
ShellRoot {
IpcHandler {
target: "weather-gpu-test"
function gpuStatus(): string {
return JSON.stringify({
count: GraphicsDevices.devices.length,
names: GraphicsDevices.devices.map(d => GraphicsDevices.shortName(d.name)),
paths: GraphicsDevices.devices.map(d => d.path),
selected: GraphicsDevices.selectedPath,
resolved: GraphicsDevices.selected !== null,
missing: GraphicsDevices.selectionMissing,
error: GraphicsDevices.lastError
});
}
function selectGpu(path: string): bool { return GraphicsDevices.select(path); }
function geoSearch(query: string): void { Geocoding.search(query); }
function geoStatus(): string {
return JSON.stringify({
searching: Geocoding.searching,
count: Geocoding.results.length,
top: Geocoding.results.length > 0 ? Geocoding.results[0].label : "",
error: Geocoding.lastError
});
}
function geoChooseTop(): bool {
if (Geocoding.results.length === 0) return false;
return Geocoding.choose(Geocoding.results[0]);
}
function storedLocation(): string {
return JSON.stringify({
label: DesktopPreferences.get("weatherLocation"),
lat: DesktopPreferences.get("weatherLatitude"),
lon: DesktopPreferences.get("weatherLongitude")
});
}
}
}
+94
View File
@@ -0,0 +1,94 @@
#!/bin/bash
set -euo pipefail
repo_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)"
helper="$repo_dir/config/dot/quickshell/scripts/panama-osd"
scratch="$(mktemp -d)"
trap 'rm -rf "$scratch"' EXIT
mkdir -p "$scratch/bin"
log="$scratch/calls"
cat >"$scratch/bin/wpctl" <<'SH'
#!/bin/bash
printf 'wpctl' >>"$OSD_TEST_LOG"
printf ' <%s>' "$@" >>"$OSD_TEST_LOG"
printf '\n' >>"$OSD_TEST_LOG"
if [[ $1 == "get-volume" ]]; then
printf '%s\n' "${WPCTL_OUTPUT:-Volume: 0.58}"
fi
SH
cat >"$scratch/bin/brightnessctl" <<'SH'
#!/bin/bash
printf 'brightnessctl' >>"$OSD_TEST_LOG"
printf ' <%s>' "$@" >>"$OSD_TEST_LOG"
printf '\n' >>"$OSD_TEST_LOG"
if [[ " $* " == *" -m "* && " $* " != *" set "* ]]; then
printf '%s\n' "${BRIGHTNESS_OUTPUT:-intel_backlight,backlight,500,1000,50%}"
fi
SH
cat >"$scratch/bin/playerctl" <<'SH'
#!/bin/bash
printf 'playerctl' >>"$OSD_TEST_LOG"
printf ' <%s>' "$@" >>"$OSD_TEST_LOG"
printf '\n' >>"$OSD_TEST_LOG"
if [[ $1 == "metadata" ]]; then
printf '%s\n' "${PLAYER_OUTPUT:-Horizon — Tycho}"
elif [[ $1 == "status" ]]; then
printf '%s\n' "${PLAYER_STATUS:-Playing}"
fi
SH
cat >"$scratch/bin/qs" <<'SH'
#!/bin/bash
printf 'qs' >>"$OSD_TEST_LOG"
printf ' <%s>' "$@" >>"$OSD_TEST_LOG"
printf '\n' >>"$OSD_TEST_LOG"
SH
chmod +x "$scratch/bin/"*
run_helper() {
PATH="$scratch/bin:$PATH" OSD_TEST_LOG="$log" "$helper" "$@"
}
assert_line() {
local expected="$1"
grep -Fqx -- "$expected" "$log" || {
printf 'osd helper contract: missing call\n%s\nactual:\n' "$expected" >&2
cat "$log" >&2
exit 1
}
}
: >"$log"
run_helper volume up 6
assert_line 'wpctl <set-volume> <-l> <1> <@DEFAULT_AUDIO_SINK@> <6%+>'
assert_line 'wpctl <get-volume> <@DEFAULT_AUDIO_SINK@>'
assert_line 'qs <ipc> <call> <osd> <progress> <volume> <58> <100> <58%>'
: >"$log"
WPCTL_OUTPUT='Volume: 0.58 [MUTED]' run_helper volume toggle
assert_line 'wpctl <set-mute> <@DEFAULT_AUDIO_SINK@> <toggle>'
assert_line 'qs <ipc> <call> <osd> <progress> <volume-muted> <58> <100> <Muted>'
: >"$log"
WPCTL_OUTPUT='Volume: 0.72 [MUTED]' run_helper microphone toggle
assert_line 'wpctl <set-mute> <@DEFAULT_AUDIO_SOURCE@> <toggle>'
assert_line 'qs <ipc> <call> <osd> <progress> <microphone-muted> <72> <100> <Muted>'
: >"$log"
run_helper brightness up 5
assert_line 'brightnessctl <-e4> <-n2> <set> <5%+>'
assert_line 'brightnessctl <-m> <-c> <backlight>'
assert_line 'qs <ipc> <call> <osd> <progress> <brightness> <50> <100> <50%>'
: >"$log"
run_helper media next
assert_line 'playerctl <next>'
assert_line 'qs <ipc> <call> <osd> <message> <media-next> <Horizon — Tycho>'
printf 'osd helper contract: PASS\n'
+62
View File
@@ -0,0 +1,62 @@
#!/bin/bash
set -euo pipefail
repo_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)"
model="$repo_dir/config/dot/quickshell/modules/osd/OsdModel.js"
node - "$model" <<'JS'
const assert = require('node:assert/strict')
const model = require(process.argv[2])
assert.equal(model.iconFor('volume', 0), 'audio-volume-muted-symbolic')
assert.equal(model.iconFor('volume', 0.2), 'audio-volume-low-symbolic')
assert.equal(model.iconFor('volume', 0.5), 'audio-volume-medium-symbolic')
assert.equal(model.iconFor('volume', 0.9), 'audio-volume-high-symbolic')
assert.equal(model.iconFor('microphone-muted', 0.7), 'microphone-sensitivity-muted-symbolic')
assert.equal(model.iconFor('brightness', 0.4), 'display-brightness-symbolic')
assert.deepEqual(
model.progressState('volume', 140, 100, '', 900),
{
kind: 'volume',
value: 100,
maximum: 100,
ratio: 1,
label: '100%',
icon: 'audio-volume-high-symbolic',
duration: 900,
progress: true
}
)
assert.deepEqual(
model.progressState('volume-muted', 43, 100, 'Muted', -50),
{
kind: 'volume-muted',
value: 43,
maximum: 100,
ratio: 0.43,
label: 'Muted',
icon: 'audio-volume-muted-symbolic',
duration: 0,
progress: true
}
)
assert.deepEqual(
model.messageState('media-next', 'Glass Beams', 'invalid'),
{
kind: 'media-next',
value: 0,
maximum: 100,
ratio: 0,
label: 'Glass Beams',
icon: 'media-skip-forward-symbolic',
duration: 1400,
progress: false
}
)
console.log('osd model contract: PASS')
JS
+58
View File
@@ -0,0 +1,58 @@
#!/bin/bash
set -euo pipefail
repo_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)"
qs_dir="$repo_dir/config/dot/quickshell"
osd="$qs_dir/modules/osd/Osd.qml"
state="$qs_dir/services/OsdState.qml"
shell_file="$qs_dir/shell.qml"
keybinds="$repo_dir/config/dot/hypr/keybinds.lua"
fail() {
printf 'osd ui contract: %s\n' "$1" >&2
exit 1
}
[[ -f "$osd" ]] || fail 'Prism OSD surface is missing'
[[ -f "$state" ]] || fail 'OSD presentation state is missing'
[[ -f "$qs_dir/modules/osd/qmldir" ]] || fail 'OSD module manifest is missing'
rg -Fq 'import qs.modules.osd' "$shell_file" || fail 'shell does not import the OSD module'
[[ "$(rg -c '^[[:space:]]*Osd \{\}' "$shell_file")" -eq 1 ]] \
|| fail 'shell does not create exactly one OSD per screen'
rg -Fq 'target: "osd"' "$shell_file" || fail 'OSD IPC target is missing'
rg -Fq 'OsdState.progress(kind, value, maximum, label)' "$shell_file" \
|| fail 'progress IPC is not wired to OSD state'
rg -Fq 'OsdState.message(kind, label)' "$shell_file" \
|| fail 'message IPC is not wired to OSD state'
rg -Fq 'WlrLayershell.namespace: "qs-popover-osd"' "$osd" \
|| fail 'OSD does not use the existing Prism blur namespace'
rg -Fq 'WlrLayershell.keyboardFocus: WlrKeyboardFocus.None' "$osd" \
|| fail 'OSD may steal keyboard focus'
rg -Fq 'mask: Region {}' "$osd" || fail 'OSD may intercept pointer input'
rg -Fq 'PrismEdge {' "$osd" || fail 'OSD is missing the Prism signature edge'
rg -Fq 'font.features: Theme.tabularFigures' "$osd" \
|| fail 'changing percentages do not use tabular figures'
rg -Fq '$HOME/.config/quickshell/scripts/panama-osd' "$keybinds" \
|| fail 'keybinds do not use the deployed Panama OSD helper'
for action in \
'volume up 6' \
'volume down 6' \
'volume toggle' \
'microphone toggle' \
'volume up 1' \
'volume down 1' \
'media play-pause' \
'media next' \
'media previous' \
'media stop' \
'brightness up 5' \
'brightness down 5'; do
rg -Fq "osd(\"$action\")" "$keybinds" \
|| fail "keybind is not routed through panama-osd $action"
done
printf 'osd ui contract: PASS\n'