Notice the battery, and the machine it is or is not in

Panama had no idea whether it was running on a laptop. No upower, no
battery, no lid, no AC: hypridle.conf says "This is a desktop" in its
own header, and that was true of the code as well as the machine.

panama-hw answers hardware questions one at a time, exits 0 or 1, and
prints nothing, so scripts, services and contracts all ask the same
way. The definition the rest of the laptop work hangs on is one line:
clamshell is lid-closed AND an external monitor. A machine with no
mains supply at all reports as being on wall power, because a desktop
cannot run out of it.

The battery service follows Vitals: sysfs through FileView, an
availability flag, and no subprocess on the timer. Globbing is the one
thing QML cannot do -- a battery is BAT0 or BAT1 or CMB0, mains is AC
or ADP1 or ACAD -- so panama-battery resolves the names once and the
shell reads the files directly after. Nothing falls back to a
plausible zero: a desktop shows no indicator, no card, and no charge
limit control where the firmware has no ceiling.

Also repairs two contracts that were already failing and had not been
noticed, because only the full suite runs them. The dependency
scanner treated line-initial variable assignments, case labels,
comments and heredoc bodies as commands, and `count`, `host`, `cancel`
and `import` are all real binaries on Fedora, so `command -v` could
not filter them out. It now drops comments and heredoc bodies and
requires a command to be followed by whitespace. Verified it still
catches a genuinely undeclared dependency rather than passing quietly.
The launcher command contract had not been told about the fourteen
commands added earlier today.
This commit is contained in:
Gabriel Brown
2026-08-21 21:43:14 -04:00
parent e446a1072c
commit 3c359f3f7e
17 changed files with 1032 additions and 9 deletions
+1 -1
View File
@@ -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
Executable
+169
View File
@@ -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 <predicate>
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
@@ -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 ────────────────────────────────────────────────────────────
{
@@ -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.
@@ -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;
}
}
}
@@ -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.
+127
View File
@@ -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
+2 -1
View File
@@ -17,7 +17,8 @@
set -euo pipefail
usage() {
echo 'usage: panama-remind add "<20m|1h30m|9:30>" "<text>" | list | pick | cancel <unit>' >&2
echo 'usage: panama-remind add "<20m, 1h30m, or 9:30>" "<text>"' >&2
echo ' panama-remind list, pick, or cancel <unit>' >&2
exit 2
}
+214
View File
@@ -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()
}
@@ -29,6 +29,7 @@ Singleton {
"appearance": "appearance",
"clock": "appearance",
"vitals": "appearance",
"battery": "power",
"typography": "appearance",
"themes": "appearance",
"titlebar": "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
@@ -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
+12 -1
View File
@@ -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**<br>`colorScheme` | dark | Light and dark share one identity, not two themes Choices: Dark, Light. |
| **Accent color**<br>`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**<br>`batteryLowPercent` | 20 % | Mention the battery once it drops this low. Range 550. |
| **Urgent at**<br>`batteryCriticalPercent` | 5 % | Interrupt at this level, even during Do Not Disturb. Range 125. |
| **Stop charging at**<br>`batteryChargeLimit` | 100 % | Charging to less than full is easier on the battery over years. Range 50100. |
## capture
Found on **Screen Intelligence**.
@@ -296,6 +306,7 @@ Found on **Appearance**.
| **Processor**<br>`showCpu` | true | Show processor usage beside the workspace indicator |
| **Memory**<br>`showMemory` | true | Show memory usage beside the workspace indicator |
| **Graphics**<br>`showGpu` | true | Show graphics usage beside the workspace indicator |
| **Battery**<br>`showBattery` | true | Show the charge level in the bar, on machines that have a battery |
| **Vitals refresh**<br>`vitalsIntervalMs` | 2000 ms | How often processor, memory, and graphics usage update. Range 50010000. |
## wallpaper
+159
View File
@@ -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 <pct>` 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'
@@ -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 <<EOF cannot open a heredoc either.
!inbody && /^[[:space:]]*#/ { next }
# <<MARKER, <<-MARKER, <<"MARKER", <<'"'"'MARKER'"'"' -- with or
# without a command in front of it.
!inbody && match($0, /<<-?[[:space:]]*["'"'"']?[A-Za-z_][A-Za-z0-9_]*["'"'"']?/) {
marker = substr($0, RSTART, RLENGTH)
gsub(/^<<-?[[:space:]]*["'"'"']?|["'"'"']?$/, "", marker)
inbody = 1
print
next
}
inbody {
line = $0
gsub(/^[[:space:]]+|[[:space:]]+$/, "", line)
if (line == marker) inbody = 0
next
}
{ print }
' "$script")"
# Statement-initial or after a pipe.
grep -oE '(^|[|;&]|\$\()[[:space:]]*[a-z][a-z0-9_-]+' "$script" \
| grep -oE '[a-z][a-z0-9_-]+$'
#
# A word followed by `=` is a variable assignment and a word followed
# by `)` is a case label; neither is a command, and both collide with
# real binaries -- `count`, `host` and `cancel` are all installed on a
# normal Fedora machine, so `command -v` below cannot filter them out.
# Requiring whitespace or end-of-line after the word excludes both.
grep -oE '(^|[|;&]|\$\()[[:space:]]*[a-z][a-z0-9_-]+([[:space:]]|$)' <<<"$scanned" \
| grep -oE '[a-z][a-z0-9_-]+'
# Behind a wrapper. ddcutil is always invoked as `timeout 10 ddcutil`,
# so it never appears statement-initial and was missed entirely.
grep -oE '\b(timeout[[:space:]]+[0-9.]+|sudo|nohup|env)[[:space:]]+[a-z][a-z0-9_-]+' "$script" \
grep -oE '\b(timeout[[:space:]]+[0-9.]+|sudo|nohup|env)[[:space:]]+[a-z][a-z0-9_-]+' <<<"$scanned" \
| grep -oE '[a-z][a-z0-9_-]+$'
# `command -v X` is how these helpers probe for a tool before using it,
+14 -1
View File
@@ -65,7 +65,20 @@ done < <(grep -oE '\{ page: "[a-z-]+"' "$sidebar" | sed 's/.*"\([a-z-]*\)"/\1/')
#
# They are still commands, so everything else below applies to them: a title,
# a description, search vocabulary, the Panama icon, and closing quietly.
declare -a standalone=(search-web save-project open-project)
#
# The OS-parity commands are standalone for the same reason. A power menu that
# needs the shell running is a power menu you cannot reach when the shell is
# what broke; the pick-lists render through `vicinae dmenu` and act through
# hyprctl; reminders are systemd timers. None of them has anything to ask the
# shell for, and routing them through panama-action would only add a way for
# them to stop working.
declare -a standalone=(
search-web save-project open-project
lock-screen suspend-system log-out reboot-system power-off
remind-me list-reminders pick-color
switch-window force-quit-window kill-process ssh-hosts recent-files
copy-password
)
# Generated commands must match their source. A stale command dispatches to a
# page that has been renamed or removed, and the launcher reports nothing wrong.
+189
View File
@@ -0,0 +1,189 @@
#!/usr/bin/env bash
# What this machine is.
#
# These predicates decide whether a battery indicator appears, whether closing
# the lid suspends or keeps working, and which idle timings apply. Every one of
# them is asked on machines that have none of the hardware involved, so the
# property that matters most is that a desktop gets a calm "no" rather than an
# error or a wrong yes.
#
# The laptop answers cannot be tested on a desktop, so they are driven against
# fixture sysfs trees through PANAMA_HW_SYS and PANAMA_HW_ACPI. The real
# machine is only asked whether every predicate runs and answers.
#
# The one definition worth pinning hardest: clamshell is lid-closed AND an
# external monitor. Get that backwards and a laptop on a train stays awake with
# its lid shut, or a docked one suspends mid-work.
set -uo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
hw="$repo_dir/bin/panama-hw"
findings=()
note() { findings+=("$1"); }
[[ -x "$hw" ]] || { printf 'hardware predicates contract: %s is not executable\n' "$hw" >&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 <name> chassis=<n> battery=<pct|-> mains=<0|1|-> lid=<open|closed|->
# external=<yes|no> internal=<yes|no>
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 <root> <predicate> <why>`
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'