diff --git a/config/dot/quickshell/config/PreferenceSchema.qml b/config/dot/quickshell/config/PreferenceSchema.qml index c7a3034..495397d 100644 --- a/config/dot/quickshell/config/PreferenceSchema.qml +++ b/config/dot/quickshell/config/PreferenceSchema.qml @@ -419,6 +419,47 @@ Singleton { 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 ────────────────────────────────────────────────── { key: "vitalsIntervalMs", type: "int", def: 2000, min: 500, max: 10000, step: 500, diff --git a/config/dot/quickshell/config/Settings.qml b/config/dot/quickshell/config/Settings.qml index a5cfe0b..fbfccc2 100644 --- a/config/dot/quickshell/config/Settings.qml +++ b/config/dot/quickshell/config/Settings.qml @@ -22,12 +22,12 @@ Singleton { // ── Weather ───────────────────────────────────────────────────────────── // Coordinates taken from the GNOME night-light setting, which had already // resolved the location. Uses Open-Meteo, which needs no API key. - readonly property real latitude: 27.7375 - readonly property real longitude: -82.6861 + readonly property real latitude: DesktopPreferences.get("weatherLatitude") + readonly property real longitude: DesktopPreferences.get("weatherLongitude") // Open-Meteo returns coordinates but no friendly place name. Keep the // label deliberately general rather than exposing precise coordinates in // 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 int weatherRefreshMinutes: DesktopPreferences.get("weatherRefreshMinutes") @@ -41,7 +41,7 @@ Singleton { // amdgpu exposes utilisation here. Verified present on this machine; the // 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 ───────────────────────────────────────────────────────── // Matches the (disabled) GNOME schedule: 3500K from 17:00 to 10:00. diff --git a/config/dot/quickshell/modules/osd/OsdModel.js b/config/dot/quickshell/modules/osd/OsdModel.js new file mode 100644 index 0000000..28d84d8 --- /dev/null +++ b/config/dot/quickshell/modules/osd/OsdModel.js @@ -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 + }; +} diff --git a/config/dot/quickshell/modules/settings/AppearancePage.qml b/config/dot/quickshell/modules/settings/AppearancePage.qml index 1488f2f..02fae73 100644 --- a/config/dot/quickshell/modules/settings/AppearancePage.qml +++ b/config/dot/quickshell/modules/settings/AppearancePage.qml @@ -103,7 +103,24 @@ SettingsPage { ToggleRow { setting: "showCpu" } 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 { diff --git a/config/dot/quickshell/modules/settings/HomePage.qml b/config/dot/quickshell/modules/settings/HomePage.qml index ef9777e..c5ec8d4 100644 --- a/config/dot/quickshell/modules/settings/HomePage.qml +++ b/config/dot/quickshell/modules/settings/HomePage.qml @@ -103,6 +103,16 @@ SettingsPage { SettingsCard { title: "Weather" 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" } SliderRow { setting: "weatherRefreshMinutes"; divider: false } } diff --git a/config/dot/quickshell/modules/settings/LocationPicker.qml b/config/dot/quickshell/modules/settings/LocationPicker.qml new file mode 100644 index 0000000..5884ca5 --- /dev/null +++ b/config/dot/quickshell/modules/settings/LocationPicker.qml @@ -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 + } +} diff --git a/config/dot/quickshell/modules/settings/qmldir b/config/dot/quickshell/modules/settings/qmldir index d47e899..0cca97b 100644 --- a/config/dot/quickshell/modules/settings/qmldir +++ b/config/dot/quickshell/modules/settings/qmldir @@ -44,3 +44,4 @@ AudioBalance 1.0 AudioBalance.qml SoundDeviceList 1.0 SoundDeviceList.qml SoundDeviceRow 1.0 SoundDeviceRow.qml TimeOfDayRow 1.0 TimeOfDayRow.qml +LocationPicker 1.0 LocationPicker.qml diff --git a/config/dot/quickshell/scripts/panama-gpus b/config/dot/quickshell/scripts/panama-gpus new file mode 100755 index 0000000..5a6bc1f --- /dev/null +++ b/config/dot/quickshell/scripts/panama-gpus @@ -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' diff --git a/config/dot/quickshell/scripts/panama-osd b/config/dot/quickshell/scripts/panama-osd new file mode 100755 index 0000000..675ed74 --- /dev/null +++ b/config/dot/quickshell/scripts/panama-osd @@ -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 diff --git a/config/dot/quickshell/services/Geocoding.qml b/config/dot/quickshell/services/Geocoding.qml new file mode 100644 index 0000000..5f2d229 --- /dev/null +++ b/config/dot/quickshell/services/Geocoding.qml @@ -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; + } +} diff --git a/config/dot/quickshell/services/GraphicsDevices.qml b/config/dot/quickshell/services/GraphicsDevices.qml new file mode 100644 index 0000000..f6eca82 --- /dev/null +++ b/config/dot/quickshell/services/GraphicsDevices.qml @@ -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*$/, ""); + } +} diff --git a/config/dot/quickshell/weather-gpu-harness.qml b/config/dot/quickshell/weather-gpu-harness.qml new file mode 100644 index 0000000..d0a915f --- /dev/null +++ b/config/dot/quickshell/weather-gpu-harness.qml @@ -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") + }); + } + } +} diff --git a/tests/quickshell/osd-helper-contract.sh b/tests/quickshell/osd-helper-contract.sh new file mode 100755 index 0000000..7d1f329 --- /dev/null +++ b/tests/quickshell/osd-helper-contract.sh @@ -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 <-l> <1> <@DEFAULT_AUDIO_SINK@> <6%+>' +assert_line 'wpctl <@DEFAULT_AUDIO_SINK@>' +assert_line 'qs <58> <100> <58%>' + +: >"$log" +WPCTL_OUTPUT='Volume: 0.58 [MUTED]' run_helper volume toggle +assert_line 'wpctl <@DEFAULT_AUDIO_SINK@> ' +assert_line 'qs <58> <100> ' + +: >"$log" +WPCTL_OUTPUT='Volume: 0.72 [MUTED]' run_helper microphone toggle +assert_line 'wpctl <@DEFAULT_AUDIO_SOURCE@> ' +assert_line 'qs <72> <100> ' + +: >"$log" +run_helper brightness up 5 +assert_line 'brightnessctl <-e4> <-n2> <5%+>' +assert_line 'brightnessctl <-m> <-c> ' +assert_line 'qs <50> <100> <50%>' + +: >"$log" +run_helper media next +assert_line 'playerctl ' +assert_line 'qs ' + +printf 'osd helper contract: PASS\n' diff --git a/tests/quickshell/osd-model-contract.sh b/tests/quickshell/osd-model-contract.sh new file mode 100755 index 0000000..276b462 --- /dev/null +++ b/tests/quickshell/osd-model-contract.sh @@ -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 diff --git a/tests/quickshell/osd-ui-contract.sh b/tests/quickshell/osd-ui-contract.sh new file mode 100644 index 0000000..a5f0b87 --- /dev/null +++ b/tests/quickshell/osd-ui-contract.sh @@ -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'