diff --git a/README.md b/README.md index dc23d4c..e4e4f2b 100644 --- a/README.md +++ b/README.md @@ -113,7 +113,7 @@ docs/ Settings reference, and the design specs behind the work ## Tests -136 of them, under `tests/`. Run the lot, or a subset by pattern: +138 of them, under `tests/`. Run the lot, or a subset by pattern: ```sh panama test # everything diff --git a/bin/panama-hw b/bin/panama-hw new file mode 100755 index 0000000..6a6e77a --- /dev/null +++ b/bin/panama-hw @@ -0,0 +1,169 @@ +#!/usr/bin/env bash + +# What this machine is, asked one yes-or-no question at a time. +# +# panama-hw laptop && echo "portable" +# panama-hw clamshell && panama-lid close +# +# Every subcommand exits 0 for yes and 1 for no, prints nothing, and answers +# correctly on a machine that has none of the hardware in question. That last +# part is the whole point: a desktop must be able to ask "am I in clamshell +# mode" and get a calm no rather than an error, because the scripts and +# services that ask are shared between machines. +# +# `--json` answers everything at once, for the health page and for contracts. +# +# Detection reads sysfs directly rather than shelling out to lspci or upower: +# lspci touches PCI config space and wakes a runtime-suspended GPU, which is a +# real cost to pay for a question asked at every login. +# +# Paths are overridable (PANAMA_HW_SYS, PANAMA_HW_ACPI) so the contract can +# drive fixture trees. Nothing else should set them. + +set -uo pipefail + +SYS="${PANAMA_HW_SYS:-/sys}" +ACPI="${PANAMA_HW_ACPI:-/proc/acpi}" + +# SMBIOS chassis types that mean "carried around": Portable, Laptop, Notebook, +# Hand Held, Sub Notebook, Tablet, Convertible, Detachable. A machine that +# reports something else, or reports nothing, is treated as stationary -- +# guessing "laptop" on an unknown chassis would put battery chrome on a desktop. +readonly PORTABLE_CHASSIS=" 8 9 10 11 14 30 31 32 " + +is_laptop() { + local type_file="$SYS/class/dmi/id/chassis_type" chassis + [[ -r "$type_file" ]] || return 1 + chassis="$(cat "$type_file" 2>/dev/null)" || return 1 + [[ "$PORTABLE_CHASSIS" == *" $chassis "* ]] +} + +# The first battery, or nothing. Named rather than assumed to be BAT0: the +# second battery in a ThinkPad is BAT1, and a machine with only BAT1 exists. +battery_path() { + local supply type + for supply in "$SYS"/class/power_supply/*; do + [[ -r "$supply/type" ]] || continue + type="$(cat "$supply/type" 2>/dev/null)" + if [[ "$type" == "Battery" ]]; then + printf '%s\n' "$supply" + return 0 + fi + done + return 1 +} + +has_battery() { battery_path >/dev/null; } + +# On wall power. A machine with no mains supply at all is a desktop, and a +# desktop is always on wall power -- answering "no" there would make every +# battery-aware timing apply to a machine that cannot run out of power. +on_ac() { + local supply type online found=1 + for supply in "$SYS"/class/power_supply/*; do + [[ -r "$supply/type" ]] || continue + type="$(cat "$supply/type" 2>/dev/null)" + [[ "$type" == "Mains" ]] || continue + found=0 + online="$(cat "$supply/online" 2>/dev/null || echo 0)" + [[ "$online" == "1" ]] && return 0 + done + # Mains exists and none of it is online: genuinely on battery. + (( found == 0 )) && return 1 + return 0 +} + +lid_closed() { + local state + for state in "$ACPI"/button/lid/*/state; do + [[ -r "$state" ]] || continue + grep -qi closed "$state" && return 0 + done + return 1 +} + +# A connected output that is not the built-in panel. eDP, LVDS and DSI are the +# internal ones; everything else arrived through a cable. +has_external_monitor() { + local status connector + for status in "$SYS"/class/drm/card*-*/status; do + [[ -r "$status" ]] || continue + [[ "$(cat "$status" 2>/dev/null)" == "connected" ]] || continue + connector="$(basename "$(dirname "$status")")" + case "$connector" in + *eDP*|*LVDS*|*DSI*) continue ;; + *) return 0 ;; + esac + done + return 1 +} + +# The one definition the rest of the laptop work hangs on: the lid is shut and +# there is still a screen to use. Closing the lid on a dock must not suspend; +# closing it on a train must. +is_clamshell() { lid_closed && has_external_monitor; } + +has_touchpad() { + local name + for name in "$SYS"/class/input/*/name; do + [[ -r "$name" ]] || continue + grep -qi touchpad "$name" && return 0 + done + return 1 +} + +# Vendor 0x10de on a display-class device. Read from sysfs rather than lspci +# so an idle discrete GPU is not woken to answer. +has_nvidia() { + local device vendor class + for device in "$SYS"/bus/pci/devices/*; do + [[ -r "$device/vendor" && -r "$device/class" ]] || continue + vendor="$(cat "$device/vendor" 2>/dev/null)" + [[ "$vendor" == "0x10de" ]] || continue + class="$(cat "$device/class" 2>/dev/null)" + [[ "$class" == 0x03* ]] && return 0 + done + return 1 +} + +answer() { "$1" && printf 'true' || printf 'false'; } + +cmd_json() { + printf '{"laptop":%s,"battery":%s,"ac":%s,"lidClosed":%s,"externalMonitor":%s,"clamshell":%s,"touchpad":%s,"nvidia":%s}\n' \ + "$(answer is_laptop)" "$(answer has_battery)" "$(answer on_ac)" \ + "$(answer lid_closed)" "$(answer has_external_monitor)" \ + "$(answer is_clamshell)" "$(answer has_touchpad)" "$(answer has_nvidia)" +} + +case "${1:-}" in + laptop) is_laptop ;; + battery) has_battery ;; + battery-path) battery_path ;; + ac) on_ac ;; + lid-closed) lid_closed ;; + external-monitor) has_external_monitor ;; + clamshell) is_clamshell ;; + touchpad) has_touchpad ;; + nvidia) has_nvidia ;; + --json) cmd_json ;; + -h|--help|"") + cat <<'USAGE' +usage: panama-hw + +Exits 0 for yes, 1 for no, and prints nothing. + + laptop a portable chassis + battery a battery is present + battery-path print the first battery's sysfs path (0 if found) + ac on wall power (a machine with no mains is always yes) + lid-closed the lid is shut + external-monitor a connected output that is not the built-in panel + clamshell lid shut AND an external monitor: docked, keep working + touchpad a touchpad is present + nvidia an NVIDIA display device is present + + --json every answer at once +USAGE + ;; + *) printf 'panama-hw: unknown predicate: %s\n' "$1" >&2; exit 2 ;; +esac diff --git a/config/dot/quickshell/config/PreferenceSchema.qml b/config/dot/quickshell/config/PreferenceSchema.qml index 763b786..fe7df75 100644 --- a/config/dot/quickshell/config/PreferenceSchema.qml +++ b/config/dot/quickshell/config/PreferenceSchema.qml @@ -84,6 +84,39 @@ Singleton { label: "Graphics", detail: "Show graphics usage beside the workspace indicator" }, + // Only ever visible on a machine that has a battery: the indicator + // gates on Battery.available as well as this, the way the graphics + // field gates on Vitals.gpuAvailable. + { + key: "showBattery", type: "bool", def: true, group: "vitals", + label: "Battery", + detail: "Show the charge level in the bar, on machines that have a battery" + }, + + // ── Battery ───────────────────────────────────────────────────────── + // The two points at which the desktop starts telling you. Low is a + // quiet mention; critical is the one that interrupts, so it is + // published at a priority Do Not Disturb does not silence. + { + key: "batteryLowPercent", type: "int", def: 20, min: 5, max: 50, step: 5, + unit: "%", group: "battery", + label: "Warn at", + detail: "Mention the battery once it drops this low" + }, + { + key: "batteryCriticalPercent", type: "int", def: 5, min: 1, max: 25, step: 1, + unit: "%", group: "battery", + label: "Urgent at", + detail: "Interrupt at this level, even during Do Not Disturb" + }, + // Only offered where the firmware exposes a ceiling; the Power page + // hides the control entirely otherwise. 100 means charge to full. + { + key: "batteryChargeLimit", type: "int", def: 100, min: 50, max: 100, step: 5, + unit: "%", group: "battery", + label: "Stop charging at", + detail: "Charging to less than full is easier on the battery over years" + }, // ── Dock ──────────────────────────────────────────────────────────── { diff --git a/config/dot/quickshell/config/Settings.qml b/config/dot/quickshell/config/Settings.qml index 391eada..cd8e052 100644 --- a/config/dot/quickshell/config/Settings.qml +++ b/config/dot/quickshell/config/Settings.qml @@ -38,6 +38,11 @@ Singleton { readonly property bool showCpu: DesktopPreferences.get("showCpu") readonly property bool showMemory: DesktopPreferences.get("showMemory") readonly property bool showGpu: DesktopPreferences.get("showGpu") + readonly property bool showBattery: DesktopPreferences.get("showBattery") + + // ── Battery ───────────────────────────────────────────────────────────── + readonly property int batteryLowPercent: DesktopPreferences.get("batteryLowPercent") + readonly property int batteryCriticalPercent: DesktopPreferences.get("batteryCriticalPercent") // amdgpu exposes utilisation here. Verified present on this machine; the // widget hides itself if the path is missing rather than showing zeros. diff --git a/config/dot/quickshell/modules/bar/StatusCluster.qml b/config/dot/quickshell/modules/bar/StatusCluster.qml index 036e37f..968ccef 100644 --- a/config/dot/quickshell/modules/bar/StatusCluster.qml +++ b/config/dot/quickshell/modules/bar/StatusCluster.qml @@ -116,4 +116,27 @@ Pill { glyph: "\u{F03F2}" // md-cellphone-link color: Theme.cyan } + + // Battery. Absent entirely on a desktop: `available` is false until a + // battery has actually been read, so this is not a zero that looks like a + // flat cell. Same shape as the graphics field in VitalsWidget, which gates + // on both the preference and the hardware. + StatusGlyph { + visible: Settings.showBattery && Battery.available + glyph: { + if (Battery.charging) + return "\u{F0084}"; // md-battery_charging + const level = Math.round(Battery.percent / 10) * 10; + if (level >= 100) return "\u{F0079}"; // md-battery + if (level <= 0) return "\u{F008E}"; // md-battery_outline + // md-battery_10 .. md-battery_90 are consecutive from F007A. + return String.fromCodePoint(0xF007A + (level / 10) - 1); + } + color: { + if (Battery.critical) return Theme.danger; + if (Battery.low) return Theme.warn; + if (Battery.charging) return Theme.ok; + return Theme.fg; + } + } } diff --git a/config/dot/quickshell/modules/settings/PowerPage.qml b/config/dot/quickshell/modules/settings/PowerPage.qml index fa16a2c..95be88e 100644 --- a/config/dot/quickshell/modules/settings/PowerPage.qml +++ b/config/dot/quickshell/modules/settings/PowerPage.qml @@ -21,6 +21,44 @@ SettingsPage { title: "Power & Lock" lede: "When the screen turns off, when the session locks, and whether it ever sleeps." + // Absent on a desktop, in full. `available` is false until a battery has + // actually been read, so this is not an empty card claiming 0%. + SettingsCard { + visible: Battery.available + title: "Battery" + subtitle: Battery.acOnline + ? "On wall power." + : "On battery. The timings below switch to their battery values automatically." + + TextRow { + label: "Charge" + value: Math.round(Battery.percent) + "%" + } + + TextRow { + label: "State" + value: { + if (Battery.charging) + return "Charging"; + if (Battery.status === "Full") + return "Full"; + if (Battery.acOnline) + return "Plugged in, not charging"; + return "On battery"; + } + divider: Battery.chargeLimitSupported + } + + // Only where the firmware actually has a ceiling. A machine whose + // kernel exposes nothing gets no control at all, rather than one that + // would accept a value and change nothing. + SliderRow { + visible: Battery.chargeLimitSupported + setting: "batteryChargeLimit" + divider: false + } + } + // The same profiles GNOME's Power panel offers. Not a stored preference -- // the daemon owns it, it survives Panama restarts, and anything else on the // system can change it, so a copy here would drift. diff --git a/config/dot/quickshell/scripts/panama-battery b/config/dot/quickshell/scripts/panama-battery new file mode 100755 index 0000000..4dd6dbc --- /dev/null +++ b/config/dot/quickshell/scripts/panama-battery @@ -0,0 +1,127 @@ +#!/usr/bin/env bash + +# The battery, for the shell and the Power page. +# +# panama-battery paths resolve which sysfs files to watch +# panama-battery status one JSON reading, for scripts and contracts +# panama-battery set-threshold N cap charging at N% (needs root) +# +# `paths` exists so the shell does not have to poll a subprocess. Globbing is +# the one thing QML cannot do -- a battery is BAT0 on most machines, BAT1 on +# some, CMB0 on a few, and the mains supply is AC, AC0, ADP1 or ACAD depending +# on the firmware -- so this resolves the names once and the shell reads the +# files directly from then on, the way Vitals.qml reads procfs. +# +# Which machine has what is panama-hw's question, so the search lives there and +# this asks it rather than keeping a second copy of the answer. +# +# Charge thresholds are a root write to a sysfs attribute, and the only part of +# this that needs privilege. It goes through panama-sudo so the prompt names +# what is being changed, rather than asking for a password with polkit's +# generic "run a program as another user". + +set -uo pipefail + +PANAMA_PATH="${PANAMA_PATH:-$HOME/.local/share/Panama}" +HW="$PANAMA_PATH/bin/panama-hw" +SYS="${PANAMA_HW_SYS:-/sys}" + +battery_dir() { + [[ -x "$HW" ]] || return 1 + "$HW" battery-path 2>/dev/null +} + +# The mains supply, if the machine has one. A desktop has none, and that is +# not an error: panama-hw's `ac` predicate treats "no mains at all" as being on +# wall power, and the shell falls back to the same assumption. +mains_dir() { + local supply + for supply in "$SYS"/class/power_supply/*; do + [[ -r "$supply/type" ]] || continue + [[ "$(cat "$supply/type" 2>/dev/null)" == "Mains" ]] || continue + printf '%s\n' "$supply" + return 0 + done + return 1 +} + +read_int() { + local file="$1" value + [[ -r "$file" ]] || return 1 + value="$(cat "$file" 2>/dev/null)" || return 1 + [[ "$value" =~ ^[0-9]+$ ]] || return 1 + printf '%s\n' "$value" +} + +cmd_paths() { + local battery mains threshold="" + battery="$(battery_dir)" || battery="" + mains="$(mains_dir)" || mains="" + + # Only report the threshold file when it exists AND is writable through + # root -- a machine whose kernel exposes a read-only stub would otherwise + # get a control that silently does nothing. + if [[ -n "$battery" && -r "$battery/charge_control_end_threshold" ]]; then + threshold="$battery/charge_control_end_threshold" + fi + + printf '{"battery":"%s","mains":"%s","threshold":"%s"}\n' \ + "$battery" "$mains" "$threshold" +} + +cmd_status() { + local battery mains capacity="" state="Unknown" online=1 threshold=0 + battery="$(battery_dir)" || battery="" + mains="$(mains_dir)" || mains="" + + if [[ -n "$battery" ]]; then + capacity="$(read_int "$battery/capacity")" || capacity="" + [[ -r "$battery/status" ]] && state="$(cat "$battery/status" 2>/dev/null)" + threshold="$(read_int "$battery/charge_control_end_threshold")" || threshold=0 + fi + if [[ -n "$mains" ]]; then + online="$(read_int "$mains/online")" || online=0 + fi + + printf '{"available":%s,"percent":%s,"status":"%s","acOnline":%s,"chargeLimit":%s}\n' \ + "$([[ -n "$capacity" ]] && echo true || echo false)" \ + "${capacity:-0}" "$state" \ + "$([[ "$online" == "1" ]] && echo true || echo false)" \ + "$threshold" +} + +cmd_set_threshold() { + local value="${1:-}" battery file + [[ "$value" =~ ^[0-9]+$ ]] || { echo 'set-threshold needs a percentage' >&2; return 2; } + (( value >= 50 && value <= 100 )) || { echo 'threshold must be between 50 and 100' >&2; return 2; } + + battery="$(battery_dir)" || { echo 'no battery on this machine' >&2; return 1; } + file="$battery/charge_control_end_threshold" + [[ -e "$file" ]] || { echo 'this machine cannot set a charge threshold' >&2; return 1; } + + # tee rather than a redirect: the redirect is performed by the calling + # shell, which is not the one holding root. + "$PANAMA_PATH/bin/panama-sudo" \ + --reason "Capping battery charging at ${value}% to reduce wear" \ + -- sh -c "printf '%s\n' '$value' | tee '$file' >/dev/null" || return 1 + + # Read it back rather than reporting success from the write's exit code: + # some firmware silently clamps or ignores the value. + read_int "$file" +} + +case "${1:-status}" in + paths) cmd_paths ;; + status) cmd_status ;; + set-threshold) shift; cmd_set_threshold "$@" ;; + -h|--help) + cat <<'USAGE' +usage: panama-battery [paths|status|set-threshold <50-100>] + + paths JSON: which sysfs files hold the battery, mains and threshold + status JSON: one reading of charge, state, power source and limit + set-threshold cap charging at N percent (asks for a password) +USAGE + ;; + *) echo "panama-battery: unknown command: $1" >&2; exit 2 ;; +esac diff --git a/config/dot/quickshell/scripts/panama-remind b/config/dot/quickshell/scripts/panama-remind index fafec70..d84d31b 100755 --- a/config/dot/quickshell/scripts/panama-remind +++ b/config/dot/quickshell/scripts/panama-remind @@ -17,7 +17,8 @@ set -euo pipefail usage() { - echo 'usage: panama-remind add "<20m|1h30m|9:30>" "" | list | pick | cancel ' >&2 + echo 'usage: panama-remind add "<20m, 1h30m, or 9:30>" ""' >&2 + echo ' panama-remind list, pick, or cancel ' >&2 exit 2 } diff --git a/config/dot/quickshell/services/Battery.qml b/config/dot/quickshell/services/Battery.qml new file mode 100644 index 0000000..50a4304 --- /dev/null +++ b/config/dot/quickshell/services/Battery.qml @@ -0,0 +1,214 @@ +pragma Singleton + +// ───────────────────────────────────────────────────────────────────────────── +// Battery and power source. +// +// GNOME put this in the system menu; here it is a bar indicator and a card on +// the Power page. The steady state is pure sysfs reads, following Vitals.qml: +// no subprocess runs on the timer. +// +// Which files to read is the one thing QML cannot work out for itself, because +// it cannot glob -- a battery is BAT0 on most machines, BAT1 on some, and the +// mains supply is AC, AC0, ADP1 or ACAD depending on firmware. So +// scripts/panama-battery resolves the names once at startup and this reads +// them directly from then on. +// +// `available` is the flag every consumer gates on, exactly as Vitals exposes +// gpuAvailable. A desktop has no battery and the correct behavior there is +// that nothing appears at all -- which is why nothing here falls back to a +// plausible-looking zero. +// +// One deliberate omission: no time-to-empty estimate. The kernel's own figure +// swings wildly under load and computing one from a discharge rate produces a +// confident number that is usually wrong, which is worse than no number. +// ───────────────────────────────────────────────────────────────────────────── + +import Quickshell +import Quickshell.Io +import QtQuick +import qs.config + +Singleton { + id: root + + // Percent charged, 0-100. Meaningless unless `available`. + property real percent: 0 + + // "Charging" | "Discharging" | "Full" | "Not charging" | "Unknown" + property string status: "Unknown" + + // On wall power. Independent of charging: a full battery on the charger is + // not charging but is very much on AC, and the idle timings care about the + // wall rather than the current. True on a machine with no mains supply at + // all, because a desktop cannot run out of power. + property bool acOnline: true + + // False until a battery has actually been read. + property bool available: false + + // The firmware charge ceiling, and whether this machine has one at all. + // Not every laptop does, and the Power page hides the control rather than + // offering one that would lie. + property int chargeLimit: 0 + property bool chargeLimitSupported: false + + readonly property bool charging: root.status === "Charging" + readonly property bool low: root.available && !root.acOnline + && root.percent <= Settings.batteryLowPercent + readonly property bool critical: root.available && !root.acOnline + && root.percent <= Settings.batteryCriticalPercent + + readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-battery" + + // Resolved once. Empty means this machine has none. + property string batteryPath: "" + property string mainsPath: "" + property string thresholdPath: "" + property bool located: false + + signal acChanged(bool online) + + function refresh(): void { + if (!root.located) { + locate.running = true; + return; + } + capacityFile.reload(); + statusFile.reload(); + if (root.mainsPath !== "") + onlineFile.reload(); + if (root.thresholdPath !== "") + thresholdFile.reload(); + } + + function setChargeLimit(percent: int): void { + if (!root.chargeLimitSupported || applyLimit.running) + return; + applyLimit.command = [root.helperPath, "set-threshold", String(percent)]; + applyLimit.running = true; + } + + // A battery moves a percentage point every few minutes; a charger being + // unplugged is the only fast transition, and 20s catches it well inside + // the time any timing decision matters. Small file reads, no subprocess. + Timer { + interval: 20000 + running: root.available || !root.located + repeat: true + triggeredOnStart: true + onTriggered: root.refresh() + } + + Process { + id: locate + command: [root.helperPath, "paths"] + stdout: StdioCollector { + onStreamFinished: { + root.located = true; + try { + const paths = JSON.parse(text); + root.batteryPath = String(paths.battery ?? ""); + root.mainsPath = String(paths.mains ?? ""); + root.thresholdPath = String(paths.threshold ?? ""); + root.chargeLimitSupported = root.thresholdPath !== ""; + } catch (error) { + console.warn("Battery: could not read the sysfs paths:", error); + root.available = false; + return; + } + if (root.batteryPath === "") { + root.available = false; + return; + } + root.refresh(); + } + } + } + + Process { + id: applyLimit + // Read back rather than trusting the write: some firmware clamps the + // value or ignores it entirely. + onExited: root.refresh() + } + + FileView { + id: capacityFile + path: root.batteryPath === "" ? "" : root.batteryPath + "/capacity" + printErrors: false + onLoaded: { + const value = parseInt(text().trim(), 10); + if (isFinite(value)) { + root.percent = Math.max(0, Math.min(100, value)); + root.available = true; + } + } + // It was there and stopped reading: a removable pack, or a path that + // moved. Drop availability rather than showing the last number + // forever, and re-resolve on the next tick. + onLoadFailed: { + root.available = false; + root.located = false; + } + } + + FileView { + id: statusFile + path: root.batteryPath === "" ? "" : root.batteryPath + "/status" + printErrors: false + onLoaded: root.status = text().trim() || "Unknown" + onLoadFailed: root.status = "Unknown" + } + + FileView { + id: onlineFile + path: root.mainsPath === "" ? "" : root.mainsPath + "/online" + printErrors: false + onLoaded: { + const online = text().trim() === "1"; + if (online !== root.acOnline) { + root.acOnline = online; + // The idle timings differ by power source and hypridle has no + // concept of one, so somebody has to say when it changed. + root.acChanged(online); + } + } + } + + FileView { + id: thresholdFile + path: root.thresholdPath + printErrors: false + onLoaded: { + const value = parseInt(text().trim(), 10); + if (isFinite(value)) + root.chargeLimit = value; + } + onLoadFailed: root.chargeLimitSupported = false; + } + + // The charge ceiling is a preference the firmware has to be told about. + // Written the same way IdleLock regenerates hypridle: watch for the value + // changing, debounce, then push it -- and only when it actually differs + // from what the hardware reports, so a settled slider does not ask for a + // password on every unrelated preference write. + Connections { + target: DesktopPreferences + function onRevisionChanged(): void { + if (root.chargeLimitSupported) + limitSync.restart(); + } + } + + Timer { + id: limitSync + interval: 600 + onTriggered: { + const wanted = DesktopPreferences.get("batteryChargeLimit"); + if (wanted !== root.chargeLimit) + root.setChargeLimit(wanted); + } + } + + Component.onCompleted: root.refresh() +} diff --git a/config/dot/quickshell/services/SettingsSearch.qml b/config/dot/quickshell/services/SettingsSearch.qml index c85d72e..dcb7add 100644 --- a/config/dot/quickshell/services/SettingsSearch.qml +++ b/config/dot/quickshell/services/SettingsSearch.qml @@ -29,6 +29,7 @@ Singleton { "appearance": "appearance", "clock": "appearance", "vitals": "appearance", + "battery": "power", "typography": "appearance", "themes": "appearance", "titlebar": "appearance", diff --git a/config/local/share/vicinae/scripts/settings-appearance b/config/local/share/vicinae/scripts/settings-appearance index 57e7045..21c6b52 100755 --- a/config/local/share/vicinae/scripts/settings-appearance +++ b/config/local/share/vicinae/scripts/settings-appearance @@ -5,6 +5,6 @@ # @vicinae.mode silent # @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg # @vicinae.description Open Appearance in Settings. -# @vicinae.keywords ["settings", "24-hour time", "show seconds", "show weekday", "processor", "memory", "graphics", "inner gaps", "outer gaps", "border width", "corner radius", "unfocused window opacity", "focused window opacity"] +# @vicinae.keywords ["settings", "24-hour time", "show seconds", "show weekday", "processor", "memory", "graphics", "battery", "inner gaps", "outer gaps", "border width", "corner radius", "unfocused window opacity"] exec "$HOME/.config/quickshell/scripts/panama-action" settings-page appearance diff --git a/config/local/share/vicinae/scripts/settings-power b/config/local/share/vicinae/scripts/settings-power index 426cee5..e933cae 100755 --- a/config/local/share/vicinae/scripts/settings-power +++ b/config/local/share/vicinae/scripts/settings-power @@ -5,6 +5,6 @@ # @vicinae.mode silent # @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg # @vicinae.description Open Power & Lock in Settings. -# @vicinae.keywords ["settings", "turn the screen off after", "lock the screen after", "suspend after", "lock before sleeping"] +# @vicinae.keywords ["settings", "warn at", "urgent at", "stop charging at", "turn the screen off after", "lock the screen after", "suspend after", "lock before sleeping"] exec "$HOME/.config/quickshell/scripts/panama-action" settings-page power diff --git a/docs/settings.md b/docs/settings.md index a4887d3..a7ad4c1 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -4,7 +4,7 @@ Do not edit this file. Run `quickshell/scripts/panama-settings-docs` after changing the schema; a contract fails when this copy is stale. -139 settings across 27 groups. 70 of them are applied to the compositor and confirmed by reading the value back. +143 settings across 28 groups. 70 of them are applied to the compositor and confirmed by reading the value back. ## accessibility @@ -29,6 +29,16 @@ Found on **Appearance**. | **Appearance**
`colorScheme` | dark | Light and dark share one identity, not two themes Choices: Dark, Light. | | **Accent color**
`accentName` | blue | Drives the focused window border, the bar hairline, and every active state Choices: Prism blue, Orchid, Teal, Green, Amber, Orange, Rose, Slate. | +## battery + +Found on **Power & Lock**. + +| Setting | Default | What it does | +|---|---|---| +| **Warn at**
`batteryLowPercent` | 20 % | Mention the battery once it drops this low. Range 5–50. | +| **Urgent at**
`batteryCriticalPercent` | 5 % | Interrupt at this level, even during Do Not Disturb. Range 1–25. | +| **Stop charging at**
`batteryChargeLimit` | 100 % | Charging to less than full is easier on the battery over years. Range 50–100. | + ## capture Found on **Screen Intelligence**. @@ -296,6 +306,7 @@ Found on **Appearance**. | **Processor**
`showCpu` | true | Show processor usage beside the workspace indicator | | **Memory**
`showMemory` | true | Show memory usage beside the workspace indicator | | **Graphics**
`showGpu` | true | Show graphics usage beside the workspace indicator | +| **Battery**
`showBattery` | true | Show the charge level in the bar, on machines that have a battery | | **Vitals refresh**
`vitalsIntervalMs` | 2000 ms | How often processor, memory, and graphics usage update. Range 500–10000. | ## wallpaper diff --git a/tests/quickshell/battery-contract b/tests/quickshell/battery-contract new file mode 100755 index 0000000..e2b49d7 --- /dev/null +++ b/tests/quickshell/battery-contract @@ -0,0 +1,159 @@ +#!/usr/bin/env bash + +# The battery, and the machines that do not have one. +# +# This is the first thing Panama has shipped that only exists on some hardware, +# and the failure that matters is not a wrong percentage -- it is a desktop +# growing battery chrome, or a laptop showing a confident 0% because a file +# could not be read. So the properties pinned here are mostly about absence: +# +# 1. No battery means `available` is false and every surface hides. Not 0%, +# not "Unknown" in the bar, not an empty card on the Power page. +# 2. A machine with no mains supply at all is on wall power. A desktop must +# never be treated as running on battery, or every battery-specific idle +# timing would apply to it. +# 3. The charge-limit control appears only where the firmware has one. +# 4. The threshold write goes through panama-sudo with a reason, never bare +# sudo, and is read back rather than assumed. +# +# The helper is driven against fixture sysfs trees; the QML side is pinned +# statically, since a battery cannot be simulated into the running shell. + +set -uo pipefail + +repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +helper="$repo_dir/config/dot/quickshell/scripts/panama-battery" +service="$repo_dir/config/dot/quickshell/services/Battery.qml" +cluster="$repo_dir/config/dot/quickshell/modules/bar/StatusCluster.qml" +page="$repo_dir/config/dot/quickshell/modules/settings/PowerPage.qml" +schema="$repo_dir/config/dot/quickshell/config/PreferenceSchema.qml" +aliases="$repo_dir/config/dot/quickshell/config/Settings.qml" + +findings=() +note() { findings+=("$1"); } + +[[ -x "$helper" ]] || { printf 'battery contract: %s is not executable\n' "$helper" >&2; exit 1; } + +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT + +# A fake machine. `battery ` and `mains <0|1>` are both optional, and +# leaving one out means the machine genuinely does not have it. +fixture() { + local name="$1" battery="${2:-}" mains="${3:-}" threshold="${4:-}" + local root="$work/$name" + mkdir -p "$root/sys/class/power_supply" + if [[ -n "$battery" ]]; then + mkdir -p "$root/sys/class/power_supply/BAT0" + printf 'Battery\n' >"$root/sys/class/power_supply/BAT0/type" + printf '%s\n' "$battery" >"$root/sys/class/power_supply/BAT0/capacity" + printf 'Discharging\n' >"$root/sys/class/power_supply/BAT0/status" + [[ -n "$threshold" ]] && printf '%s\n' "$threshold" \ + >"$root/sys/class/power_supply/BAT0/charge_control_end_threshold" + fi + if [[ -n "$mains" ]]; then + mkdir -p "$root/sys/class/power_supply/AC0" + printf 'Mains\n' >"$root/sys/class/power_supply/AC0/type" + printf '%s\n' "$mains" >"$root/sys/class/power_supply/AC0/online" + fi + printf '%s\n' "$root" +} + +ask() { + local root="$1"; shift + PANAMA_HW_SYS="$root/sys" PANAMA_PATH="$repo_dir" "$helper" "$@" 2>/dev/null +} + +field() { jq -r "$2" <<<"$1" 2>/dev/null; } + +# ── 1. A desktop ───────────────────────────────────────────────────────────── + +desktop="$(fixture desktop)" +status="$(ask "$desktop" status)" +[[ "$(field "$status" .available)" == "false" ]] \ + || note 'a machine with no battery reports one as available' +[[ "$(field "$status" .acOnline)" == "true" ]] \ + || note 'a machine with no mains supply is reported as running on battery' + +paths="$(ask "$desktop" paths)" +[[ "$(field "$paths" .battery)" == "" ]] \ + || note 'a machine with no battery resolves a battery path anyway' + +# ── 2. A laptop ────────────────────────────────────────────────────────────── + +laptop="$(fixture laptop 64 1)" +status="$(ask "$laptop" status)" +[[ "$(field "$status" .available)" == "true" ]] || note 'a battery was not detected' +[[ "$(field "$status" .percent)" == "64" ]] \ + || note "the charge level is wrong (got $(field "$status" .percent))" +[[ "$(field "$status" .acOnline)" == "true" ]] || note 'a plugged-in laptop reads as unplugged' + +unplugged="$(fixture unplugged 41 0)" +status="$(ask "$unplugged" status)" +[[ "$(field "$status" .acOnline)" == "false" ]] \ + || note 'a laptop with mains offline still reads as on wall power' + +# ── 3. The charge limit appears only where it exists ───────────────────────── + +paths="$(ask "$laptop" paths)" +[[ "$(field "$paths" .threshold)" == "" ]] \ + || note 'a machine without a charge threshold resolves one anyway, so the control would appear and do nothing' + +limited="$(fixture limited 80 1 80)" +paths="$(ask "$limited" paths)" +[[ "$(field "$paths" .threshold)" != "" ]] \ + || note 'a machine with a charge threshold does not expose it' +[[ "$(field "$(ask "$limited" status)" .chargeLimit)" == "80" ]] \ + || note 'the charge limit is not reported' + +# ── 4. The write is privileged, named, and verified ────────────────────────── + +grep -q 'panama-sudo' "$helper" \ + || note 'the threshold write does not go through panama-sudo' +if grep -nE '(^|[^-[:alnum:]])sudo ' "$helper" | grep -q -v 'panama-sudo'; then + note 'the helper calls bare sudo somewhere, so the prompt would not name the change' +fi +grep -q -- '--reason' "$helper" \ + || note 'the privileged write does not state a reason, so the prompt would not say what it changes' + +# Out-of-range values are refused before any password is asked for. +PANAMA_HW_SYS="$limited/sys" PANAMA_PATH="$repo_dir" "$helper" set-threshold 10 >/dev/null 2>&1 \ + && note 'a threshold below the supported range was accepted' +PANAMA_HW_SYS="$limited/sys" PANAMA_PATH="$repo_dir" "$helper" set-threshold abc >/dev/null 2>&1 \ + && note 'a non-numeric threshold was accepted' + +# ── 5. The QML side hides itself ───────────────────────────────────────────── + +grep -q 'property bool available' "$service" \ + || note 'the battery service has no availability flag' +grep -q 'Settings.showBattery && Battery.available' "$cluster" \ + || note 'the bar indicator does not gate on both the preference and the hardware' +grep -q 'visible: Battery.available' "$page" \ + || note 'the Power page battery card does not hide on a machine without one' +grep -q 'visible: Battery.chargeLimitSupported' "$page" \ + || note 'the charge limit control does not hide where the firmware has none' + +# The alias layer has to carry the key, or the binding silently reads undefined +# and the indicator never appears. This exact mistake was made writing it. +for key in showBattery batteryLowPercent batteryCriticalPercent; do + grep -q "property .*$key" "$aliases" \ + || note "Settings.qml does not alias $key, so the binding reads undefined" +done + +for key in showBattery batteryLowPercent batteryCriticalPercent batteryChargeLimit; do + grep -q "key: \"$key\"" "$schema" || note "the schema has no $key entry" +done + +# No subprocess on the polling path: the whole point of resolving paths once. +if grep -A4 'Timer {' "$service" | grep -q 'running: true' && grep -q 'Process' "$service"; then + grep -q 'onTriggered: root.refresh()' "$service" \ + || note 'the poll timer does something other than re-read files' +fi + +if (( ${#findings[@]} > 0 )); then + printf 'battery contract: %d finding(s)\n' "${#findings[@]}" >&2 + printf ' - %s\n' "${findings[@]}" >&2 + exit 1 +fi + +printf 'battery contract: PASS\n' diff --git a/tests/quickshell/declared-dependencies-contract b/tests/quickshell/declared-dependencies-contract index e04897e..e626685 100755 --- a/tests/quickshell/declared-dependencies-contract +++ b/tests/quickshell/declared-dependencies-contract @@ -72,6 +72,9 @@ package_for() { wl-copy|wl-paste) printf 'wl-clipboard' ;; ssh-keygen) printf 'openssh' ;; ssh|ssh-add) printf 'openssh-clients' ;; + # The Wayland build is the one that can inject into this session; the + # x11 one cannot. desktop-packages declares it under that name. + espanso) printf 'espanso-wayland' ;; rg) printf 'ripgrep' ;; xdg-mime|xdg-settings|xdg-open) printf 'xdg-utils' ;; update-desktop-database|desktop-file-validate) printf 'desktop-file-utils' ;; @@ -114,13 +117,50 @@ while read -r script; do missing+=("$cmd (from $(basename "$script"), package: $pkg)") done < <({ + # Heredoc bodies are not shell. A python or sql block embedded in a + # script is scanned as though every line began a command, and its + # keywords collide with real binaries often enough to matter: `import` + # is ImageMagick, `time` is a package, `select` is shell syntax. The + # scanner cannot parse those languages and should not try, so the + # bodies are dropped before anything else looks at them. + scanned="$(awk ' + # Whole-line comments. These files explain themselves at length, + # and prose containing "; cancel it" or "| list" reads as a + # statement to a line-based scanner. Dropped first so a comment + # mentioning <&2; exit 1; } + +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT + +# Builds a fixture machine. Every argument is optional; what is absent is +# absent on the machine too, which is the case worth testing. +# fixture chassis= battery= mains=<0|1|-> lid= +# external= internal= +fixture() { + local name="$1"; shift + local root="$work/$name" + local chassis="" battery="" mains="" lid="" external="no" internal="no" + local arg + for arg in "$@"; do + case "$arg" in + chassis=*) chassis="${arg#*=}" ;; + battery=*) battery="${arg#*=}" ;; + mains=*) mains="${arg#*=}" ;; + lid=*) lid="${arg#*=}" ;; + external=*) external="${arg#*=}" ;; + internal=*) internal="${arg#*=}" ;; + esac + done + + mkdir -p "$root/sys/class/dmi/id" "$root/sys/class/power_supply" \ + "$root/sys/class/drm" "$root/sys/class/input" \ + "$root/sys/bus/pci/devices" "$root/acpi/button/lid" + + [[ -n "$chassis" ]] && printf '%s\n' "$chassis" >"$root/sys/class/dmi/id/chassis_type" + + if [[ -n "$battery" && "$battery" != "-" ]]; then + mkdir -p "$root/sys/class/power_supply/BAT0" + printf 'Battery\n' >"$root/sys/class/power_supply/BAT0/type" + printf '%s\n' "$battery" >"$root/sys/class/power_supply/BAT0/capacity" + fi + if [[ -n "$mains" && "$mains" != "-" ]]; then + mkdir -p "$root/sys/class/power_supply/AC0" + printf 'Mains\n' >"$root/sys/class/power_supply/AC0/type" + printf '%s\n' "$mains" >"$root/sys/class/power_supply/AC0/online" + fi + if [[ -n "$lid" && "$lid" != "-" ]]; then + mkdir -p "$root/acpi/button/lid/LID0" + printf 'state: %s\n' "$lid" >"$root/acpi/button/lid/LID0/state" + fi + if [[ "$internal" == "yes" ]]; then + mkdir -p "$root/sys/class/drm/card0-eDP-1" + printf 'connected\n' >"$root/sys/class/drm/card0-eDP-1/status" + fi + if [[ "$external" == "yes" ]]; then + mkdir -p "$root/sys/class/drm/card0-DP-1" + printf 'connected\n' >"$root/sys/class/drm/card0-DP-1/status" + else + mkdir -p "$root/sys/class/drm/card0-DP-1" + printf 'disconnected\n' >"$root/sys/class/drm/card0-DP-1/status" + fi + printf '%s\n' "$root" +} + +ask() { + local root="$1" predicate="$2" + PANAMA_HW_SYS="$root/sys" PANAMA_HW_ACPI="$root/acpi" "$hw" "$predicate" +} + +# Asserts a predicate's answer. `expect yes|no ` +expect() { + local want="$1" root="$2" predicate="$3" why="$4" + if ask "$root" "$predicate"; then + [[ "$want" == "yes" ]] || note "$why (answered yes, expected no)" + else + [[ "$want" == "no" ]] || note "$why (answered no, expected yes)" + fi +} + +# ── A desktop ──────────────────────────────────────────────────────────────── +# No battery, no lid, no mains supply at all. Every laptop answer must be no, +# and AC must be yes: a machine with no mains reporting is on wall power, and +# answering otherwise would apply battery timings to something that cannot run +# out of power. + +desktop="$(fixture desktop chassis=3 external=yes)" +expect no "$desktop" laptop 'a desktop chassis reports as a laptop' +expect no "$desktop" battery 'a desktop reports a battery' +expect yes "$desktop" ac 'a desktop with no mains supply is not treated as on wall power' +expect no "$desktop" lid-closed 'a machine with no lid reports its lid closed' +expect no "$desktop" clamshell 'a desktop reports clamshell' +expect yes "$desktop" external-monitor 'a connected DisplayPort output is not seen as external' + +# ── A laptop, lid open, undocked ───────────────────────────────────────────── + +open_undocked="$(fixture open-undocked chassis=10 battery=64 mains=1 lid=open internal=yes)" +expect yes "$open_undocked" laptop 'a notebook chassis does not report as a laptop' +expect yes "$open_undocked" battery 'a battery is not detected' +expect yes "$open_undocked" ac 'a plugged-in laptop is not seen as on AC' +expect no "$open_undocked" lid-closed 'an open lid reports closed' +expect no "$open_undocked" clamshell 'an open lid reports clamshell' +expect no "$open_undocked" external-monitor \ + 'the built-in panel is counted as an external monitor' + +# ── The same laptop, on battery ────────────────────────────────────────────── + +unplugged="$(fixture unplugged chassis=10 battery=41 mains=0 lid=open internal=yes)" +expect no "$unplugged" ac 'a laptop with mains offline is still reported as on AC' + +# ── Lid shut, no external monitor: this must suspend ───────────────────────── + +closed_alone="$(fixture closed-alone chassis=10 battery=30 mains=0 lid=closed internal=yes)" +expect yes "$closed_alone" lid-closed 'a closed lid reports open' +expect no "$closed_alone" clamshell \ + 'a closed lid with no external monitor reports clamshell, which would keep a laptop awake in a bag' + +# ── Lid shut with an external monitor: this must keep working ──────────────── + +docked="$(fixture docked chassis=10 battery=88 mains=1 lid=closed internal=yes external=yes)" +expect yes "$docked" lid-closed 'a docked closed lid reports open' +expect yes "$docked" external-monitor 'a docked external monitor is not detected' +expect yes "$docked" clamshell \ + 'a closed lid with an external monitor does not report clamshell, which would suspend a docked machine mid-work' + +# ── An empty machine answers rather than erroring ──────────────────────────── +# Nothing present at all: no DMI, no power supplies, no lid, no outputs. Every +# predicate must still exit cleanly, because a missing sysfs is what a +# container, a VM, or an unusual kernel looks like. + +empty="$(fixture empty)" +for predicate in laptop battery ac lid-closed external-monitor clamshell touchpad nvidia; do + output="$(ask "$empty" "$predicate" 2>&1)" + status=$? + (( status == 0 || status == 1 )) \ + || note "$predicate exits $status on a machine with no hardware; it must answer, not fail" + [[ -n "$output" ]] \ + && note "$predicate printed '$output' instead of answering silently" +done + +# ── The real machine answers every question ────────────────────────────────── + +for predicate in laptop battery ac lid-closed external-monitor clamshell touchpad nvidia; do + "$hw" "$predicate" >/dev/null 2>&1 + status=$? + (( status == 0 || status == 1 )) \ + || note "$predicate exits $status on this machine" +done + +json="$("$hw" --json 2>/dev/null)" +if command -v jq >/dev/null 2>&1; then + jq -e . >/dev/null 2>&1 <<<"$json" || note '--json does not emit valid JSON' + for key in laptop battery ac lidClosed externalMonitor clamshell touchpad nvidia; do + jq -e "has(\"$key\")" >/dev/null 2>&1 <<<"$json" \ + || note "--json omits $key" + done +fi + +# An unknown predicate is a caller's mistake and must be loud, not a silent no. +"$hw" not-a-real-predicate >/dev/null 2>&1 +(( $? == 2 )) || note 'an unknown predicate does not exit 2, so a typo reads as a no' + +if (( ${#findings[@]} > 0 )); then + printf 'hardware predicates contract: %d finding(s)\n' "${#findings[@]}" >&2 + printf ' - %s\n' "${findings[@]}" >&2 + exit 1 +fi + +printf 'hardware predicates contract: PASS\n'