Draw idle as one timeline, and let the power button answer to its owner
Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
This commit is contained in:
1 parent
6f0ce639d9
commit
e1ff25fc66
23 files changed
+2655
-174
No files matched your search
@@ -69,6 +69,53 @@ read_int() {
|
||||
printf '%s\n' "$value"
|
||||
}
|
||||
|
||||
# How much of the pack's design capacity is left, as a whole percent.
|
||||
#
|
||||
# Energy (µWh) and charge (µAh) are the two spellings of the same pair and a
|
||||
# pack has one or the other, never both worth trusting, so each is tried in the
|
||||
# order the kernel prefers. Summed across every system pack for the same reason
|
||||
# the percentage is: on a two-battery machine, one pack's wear is not the
|
||||
# machine's.
|
||||
#
|
||||
# Fails rather than guessing when the firmware exposes no design capacity,
|
||||
# which plenty does not. A health figure computed from a design capacity that
|
||||
# was itself invented is a confident number that is wrong, and this page has
|
||||
# already decided once (no time-to-empty) that it would rather say nothing.
|
||||
battery_health() {
|
||||
local dir full design total_full=0 total_design=0
|
||||
while IFS= read -r dir; do
|
||||
full="$(read_int "$dir/energy_full" || read_int "$dir/charge_full")" || continue
|
||||
design="$(read_int "$dir/energy_full_design" || read_int "$dir/charge_full_design")" || continue
|
||||
(( design > 0 )) || continue
|
||||
total_full=$(( total_full + full ))
|
||||
total_design=$(( total_design + design ))
|
||||
done < <(all_battery_dirs)
|
||||
(( total_design > 0 )) || return 1
|
||||
printf '%s\n' "$(( (total_full * 100 + total_design / 2) / total_design ))"
|
||||
}
|
||||
|
||||
# Full charge cycles, from the primary pack and then from whichever pack
|
||||
# answers.
|
||||
#
|
||||
# A reported zero is treated as "the firmware does not count", not as a battery
|
||||
# that has never been charged: a great many laptops export cycle_count as a
|
||||
# permanent 0, and "0 cycles" beside a four-year-old pack reads as a fact
|
||||
# rather than the absence of one.
|
||||
battery_cycles() {
|
||||
local primary="$1" dir value
|
||||
if [[ -n "$primary" ]] && value="$(read_int "$primary/cycle_count")" && (( value > 0 )); then
|
||||
printf '%s\n' "$value"
|
||||
return 0
|
||||
fi
|
||||
while IFS= read -r dir; do
|
||||
if value="$(read_int "$dir/cycle_count")" && (( value > 0 )); then
|
||||
printf '%s\n' "$value"
|
||||
return 0
|
||||
fi
|
||||
done < <(all_battery_dirs)
|
||||
return 1
|
||||
}
|
||||
|
||||
cmd_paths() {
|
||||
local battery mains threshold=""
|
||||
battery="$(battery_dir)" || battery=""
|
||||
@@ -87,6 +134,7 @@ cmd_paths() {
|
||||
|
||||
cmd_status() {
|
||||
local battery mains capacity="" state="Unknown" online=1 threshold=0
|
||||
local health="" cycles=""
|
||||
battery="$(battery_dir)" || battery=""
|
||||
mains="$(mains_dir)" || mains=""
|
||||
|
||||
@@ -109,16 +157,23 @@ cmd_status() {
|
||||
done
|
||||
(( total_full > 0 )) && capacity=$(( (total_now * 100 + total_full / 2) / total_full ))
|
||||
fi
|
||||
|
||||
health="$(battery_health)" || health=""
|
||||
cycles="$(battery_cycles "$battery")" || cycles=""
|
||||
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' \
|
||||
# health and cycles are JSON null when sysfs does not report them, which is
|
||||
# most desktops and a fair number of laptops. Null rather than 0: a zero
|
||||
# would render as a dead battery that has never been charged.
|
||||
printf '{"available":%s,"percent":%s,"status":"%s","acOnline":%s,"chargeLimit":%s,"healthPercent":%s,"cycleCount":%s}\n' \
|
||||
"$([[ -n "$capacity" ]] && echo true || echo false)" \
|
||||
"${capacity:-0}" "$state" \
|
||||
"$([[ "$online" == "1" ]] && echo true || echo false)" \
|
||||
"$threshold"
|
||||
"$threshold" \
|
||||
"${health:-null}" "${cycles:-null}"
|
||||
}
|
||||
|
||||
cmd_set_threshold() {
|
||||
@@ -156,7 +211,9 @@ case "${1:-status}" in
|
||||
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
|
||||
status JSON: one reading of charge, state, power source, limit,
|
||||
health against design capacity and charge cycles (the last
|
||||
two null where the firmware does not report them)
|
||||
set-threshold cap charging at N percent (asks for a password)
|
||||
USAGE
|
||||
;;
|
||||
|
||||
@@ -9,10 +9,11 @@
|
||||
# `-c`. The repository's hypridle.conf remains the shipped default and is what
|
||||
# runs if this has never been set up.
|
||||
#
|
||||
# panama-idle apply regenerate and restart hypridle
|
||||
# panama-idle status report as JSON what is in effect
|
||||
# panama-idle install write the systemd drop-in (idempotent)
|
||||
# panama-idle remove remove the drop-in and fall back to the shipped config
|
||||
# panama-idle apply regenerate and restart hypridle
|
||||
# panama-idle status report as JSON what is in effect
|
||||
# panama-idle inhibitors what is currently holding sleep or idle off
|
||||
# panama-idle install write the systemd drop-in (idempotent)
|
||||
# panama-idle remove remove the drop-in and fall back to the shipped config
|
||||
#
|
||||
# All values are read from the settings store and clamped here as well as in the
|
||||
# schema, because this script is also reachable from a shell.
|
||||
@@ -166,6 +167,57 @@ generate() {
|
||||
generated_tmp=""
|
||||
}
|
||||
|
||||
# What is currently holding sleep or idle off, as a JSON array of
|
||||
# { who, why, what, mode }.
|
||||
#
|
||||
# The Power page can promise a timeline all it likes; if something in the
|
||||
# session holds a wake lock, the timeline is not what will happen. This is the
|
||||
# honest substitute for the conditional suspend rules hypridle cannot express:
|
||||
# rather than inventing rules about when not to sleep, say who is already
|
||||
# saying it.
|
||||
#
|
||||
# Read from logind over D-Bus rather than by parsing `systemd-inhibit --list`.
|
||||
# That table is a padded, human-facing layout whose `why` column contains
|
||||
# spaces and whose `who` column is a free string the inhibiting program picks,
|
||||
# so there is no column count that reliably splits it -- and systemd documents
|
||||
# it as display output, not an interface. ListInhibitors returns the same rows
|
||||
# as typed data. `--json=short` plus jq is the whole parser.
|
||||
#
|
||||
# `mode` is carried through because it is the difference between a program that
|
||||
# stops the machine sleeping and one that merely asks for a moment on the way
|
||||
# down. A `delay` inhibitor holds sleep for at most InhibitDelayMaxSec and then
|
||||
# the machine sleeps anyway; NetworkManager, UPower and hypridle all hold one
|
||||
# permanently, and listing those as reasons the machine is awake would be a
|
||||
# lie the size of the card they appear on. Only `block` keeps a machine up.
|
||||
#
|
||||
# Panama's own lid inhibitor is included rather than filtered out. It IS one of
|
||||
# the reasons a docked machine stays awake, and a list that quietly omits the
|
||||
# desktop's own hold would be the one entry a person could not act on.
|
||||
#
|
||||
# Prints NOTHING and exits non-zero when logind could not be asked, so the
|
||||
# caller can tell "found nothing" from "could not look". An empty array on
|
||||
# failure would claim that nothing holds the machine awake, which is the wrong
|
||||
# way to be wrong, and it would claim it in a shape the reader cannot question.
|
||||
cmd_inhibitors() {
|
||||
local raw
|
||||
raw="$(busctl --json=short call org.freedesktop.login1 /org/freedesktop/login1 \
|
||||
org.freedesktop.login1.Manager ListInhibitors 2>/dev/null)" || return 1
|
||||
|
||||
# ListInhibitors returns a(ssssuu): what, who, why, mode, uid, pid. Only
|
||||
# the holds that bear on sleeping or idling are kept -- a shutdown or
|
||||
# power-key inhibitor says nothing about whether the screen will blank.
|
||||
# handle-lid-switch earns its place for the same reason Panama's own hold
|
||||
# does: on a docked laptop it is exactly why the machine is still up.
|
||||
jq -c '
|
||||
[ .data[0][]
|
||||
| { what: .[0], who: .[1], why: .[2], mode: .[3] }
|
||||
| select(.what | split(":")
|
||||
| any(. == "sleep" or . == "idle" or . == "handle-lid-switch"))
|
||||
| { who, why, what, mode } ]
|
||||
| sort_by(.mode == "delay", .who)
|
||||
' <<<"$raw" 2>/dev/null || return 1
|
||||
}
|
||||
|
||||
install_dropin() {
|
||||
mkdir -p "$dropin_dir"
|
||||
cat >"$dropin" <<EOF
|
||||
@@ -214,8 +266,11 @@ case "${1:-apply}" in
|
||||
"$(on_battery && printf battery || printf ac)" \
|
||||
"$blank_min" "$lock_min" "$suspend_min" "$lock_on_sleep" "$generated"
|
||||
;;
|
||||
inhibitors)
|
||||
cmd_inhibitors
|
||||
;;
|
||||
*)
|
||||
printf 'usage: panama-idle [apply|install|remove|status]\n' >&2
|
||||
printf 'usage: panama-idle [apply|install|remove|status|inhibitors]\n' >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
@@ -91,23 +91,159 @@ cmd_close() {
|
||||
hyprctl eval "hl.monitor({ output = \"$internal\", disable = true })" >/dev/null
|
||||
}
|
||||
|
||||
# Re-enable on open, preferring what the person chose for this panel in
|
||||
# Settings (the same displays store monitors.lua reads at startup) and
|
||||
# falling back to the panel's preferred mode.
|
||||
# The stored display record for one output, rendered as hl.monitor arguments.
|
||||
#
|
||||
# This exists because the four-key version it replaces was a clobber. Opening
|
||||
# the lid emitted mode, position="auto" and scale and nothing else -- and an
|
||||
# hl.monitor call REPLACES the rule for that output whole, so every other field
|
||||
# the person had chosen for the panel went with it. Transform, VRR, bit depth,
|
||||
# colour profile, SDR trim, mirroring and, worst of all, the position: a panel
|
||||
# arranged to the left of an external display jumped back to automatic
|
||||
# placement, taking its workspaces with it, every time the lid was opened.
|
||||
#
|
||||
# So the rule is rebuilt from the same store config/dot/hypr/monitors.lua reads
|
||||
# at startup, with the same validation, so that opening the lid produces the
|
||||
# rule a reload would have produced. Mirroring that file field for field is the
|
||||
# point; the two must not be able to disagree about what a saved record means.
|
||||
#
|
||||
# The two failure directions are deliberately different, exactly as they are
|
||||
# there:
|
||||
#
|
||||
# * mode, scale or transform unreadable -- or a half-written position, where
|
||||
# a record carries some layout fields but not a valid pair -- refuses the
|
||||
# WHOLE record. Nothing is printed and the caller falls back to the panel's
|
||||
# preferred mode. Guessing a position can strand an output where no cursor
|
||||
# can reach it.
|
||||
# * an unreadable colour, VRR, SDR or mirror value drops only itself. The
|
||||
# geometry survives, and the worst it costs is a wrong shade.
|
||||
#
|
||||
# Every value that reaches the emitted string is validated first -- modes and
|
||||
# positions against a numeric pattern, connector names against the same
|
||||
# `[%w_.-]` class monitors.lua uses, colour profiles against a fixed set -- so
|
||||
# nothing from the settings file can carry a quote into the Lua that is built
|
||||
# from it.
|
||||
monitor_rule() {
|
||||
local name="$1" settings="${XDG_CONFIG_HOME:-$HOME/.config}/panama/settings.json"
|
||||
[[ -r "$settings" ]] || return 0
|
||||
jq -r --arg name "$name" '
|
||||
def numeric: if type == "number" and (isnan | not) and (isinfinite | not)
|
||||
then . else null end;
|
||||
def whole: numeric | if . != null and . == floor then . else null end;
|
||||
|
||||
. as $root
|
||||
| ($root.displays[$name] // null) as $e
|
||||
| if ($e | type) != "object" then "" else
|
||||
|
||||
# ── Geometry: all of it, or none of it ───────────────────────────────
|
||||
($e.mode | if type == "string"
|
||||
and test("^[0-9]+x[0-9]+@[0-9]+(\\.[0-9]+)?$")
|
||||
then . else null end) as $mode
|
||||
| ($mode | if . == null then null
|
||||
else capture("^(?<w>[0-9]+)x(?<h>[0-9]+)@(?<r>[0-9.]+)$")
|
||||
| [(.w | tonumber), (.h | tonumber), (.r | tonumber)]
|
||||
end) as $dim
|
||||
| (if $dim == null or $dim[0] <= 0 or $dim[1] <= 0 or $dim[2] <= 0
|
||||
then null else $mode end) as $mode
|
||||
|
||||
# A scale is only valid if it divides the mode into whole logical
|
||||
# pixels; Hyprland refuses the rest, and monitors.lua refuses them here
|
||||
# first so the two agree about which records are usable.
|
||||
| ($e.scale | numeric | if . != null and . > 0 and . <= 4
|
||||
then . else null end) as $rawScale
|
||||
| (if $mode == null or $rawScale == null then null
|
||||
else ($dim[0] / $rawScale) as $lw
|
||||
| ($dim[1] / $rawScale) as $lh
|
||||
| if ((($lw - ($lw | round)) | fabs) < 0.0001)
|
||||
and ((($lh - ($lh | round)) | fabs) < 0.0001)
|
||||
then $rawScale else null end
|
||||
end) as $scale
|
||||
| ($e.transform | whole
|
||||
| if . != null and . >= 0 and . <= 3 then . else null end) as $transform
|
||||
|
||||
# Legacy records carry no layout fields at all and keep automatic
|
||||
# placement. A record that carries SOME of them and gets one wrong is
|
||||
# refused outright rather than half-honoured.
|
||||
| (($e.x != null) or ($e.y != null) or ($e.primary != null)) as $hasLayout
|
||||
| ($e.x | whole | if . != null and . >= -100000 and . <= 100000
|
||||
then . else null end) as $x
|
||||
| ($e.y | whole | if . != null and . >= -100000 and . <= 100000
|
||||
then . else null end) as $y
|
||||
| ($hasLayout
|
||||
and ($x == null or $y == null or ($e.primary | type) != "boolean")) as $layoutBroken
|
||||
|
||||
# ── Extended fields: each one drops on its own ───────────────────────
|
||||
| ($e.bitdepth | if . == 8 or . == 10 then . else null end) as $bitdepth
|
||||
| ($e.colorProfile
|
||||
| if . == "auto" or . == "srgb" or . == "wide" or . == "hdr"
|
||||
then . else null end) as $cm
|
||||
# -1 means "follow the global VRR policy", which is said by leaving the
|
||||
# key out; 3 is the global policy value and not a per-display choice.
|
||||
| ($e.vrrMode | whole | if . != null and . >= 0 and . <= 2
|
||||
then . else null end) as $vrr
|
||||
# Neutral is 1.0 and is left out rather than written: naming it pins the
|
||||
# display to it, which is not the same as leaving the trim alone.
|
||||
| ($e.sdrBrightness | numeric
|
||||
| if . != null and . >= 0.8 and . <= 2.0 and (((. - 1) | fabs) >= 0.001)
|
||||
then . else null end) as $sdrBrightness
|
||||
| ($e.sdrSaturation | numeric
|
||||
| if . != null and . >= 0.8 and . <= 1.2 and (((. - 1) | fabs) >= 0.001)
|
||||
then . else null end) as $sdrSaturation
|
||||
# A mirror needs a target that is not itself and not another mirror --
|
||||
# Hyprland has no chain to follow -- and the primary may not mirror at
|
||||
# all, since the arrangement is anchored on it.
|
||||
| ($e.mirrorOf
|
||||
| if type == "string" and . != "" and . != $name
|
||||
and test("^[A-Za-z0-9_.-]+$")
|
||||
then . else null end) as $mirrorName
|
||||
| (if $mirrorName == null or $e.primary == true then null
|
||||
else ($root.displays[$mirrorName] // null) as $target
|
||||
| if ($target | type) == "object"
|
||||
and ($target.mirrorOf | type) == "string"
|
||||
and $target.mirrorOf != ""
|
||||
then null else $mirrorName end
|
||||
end) as $mirror
|
||||
|
||||
# A mirror shows its target picture in its target place, so the saved
|
||||
# position is not ours to ask for.
|
||||
| (if $mirror != null then "auto"
|
||||
elif $hasLayout then "\($x)x\($y)"
|
||||
else "auto" end) as $position
|
||||
|
||||
| if $mode == null or $scale == null or $transform == null or $layoutBroken
|
||||
then ""
|
||||
else ([ "mode = \"\($mode)\"",
|
||||
"position = \"\($position)\"",
|
||||
"scale = \($scale)",
|
||||
"transform = \($transform)" ]
|
||||
+ (if $bitdepth == null then [] else ["bitdepth = \($bitdepth)"] end)
|
||||
+ (if $cm == null then [] else ["cm = \"\($cm)\""] end)
|
||||
+ (if $vrr == null then [] else ["vrr = \($vrr)"] end)
|
||||
+ (if $sdrBrightness == null then []
|
||||
else ["sdrbrightness = \($sdrBrightness)"] end)
|
||||
+ (if $sdrSaturation == null then []
|
||||
else ["sdrsaturation = \($sdrSaturation)"] end)
|
||||
+ (if $mirror == null then [] else ["mirror = \"\($mirror)\""] end)
|
||||
) | join(", ")
|
||||
end
|
||||
end
|
||||
' "$settings" 2>/dev/null || true
|
||||
}
|
||||
|
||||
# Re-enable on open, restoring what the person chose for this panel in Settings
|
||||
# -- the whole record, not the three fields that used to survive -- and falling
|
||||
# back to the panel's preferred mode when there is no usable record.
|
||||
cmd_open() {
|
||||
"$HW" laptop || exit 0
|
||||
local internal entry mode scale
|
||||
local internal rule
|
||||
internal="$(internal_connector)"
|
||||
[[ -n "$internal" ]] || exit 0
|
||||
entry="$(jq -c --arg name "$internal" '.displays[$name] // empty' \
|
||||
"${XDG_CONFIG_HOME:-$HOME/.config}/panama/settings.json" 2>/dev/null)"
|
||||
if [[ -n "$entry" ]]; then
|
||||
mode="$(jq -r '.mode' <<<"$entry")"
|
||||
scale="$(jq -r '.scale' <<<"$entry")"
|
||||
hyprctl eval "hl.monitor({ output = \"$internal\", mode = \"$mode\", position = \"auto\", scale = $scale })" >/dev/null
|
||||
else
|
||||
hyprctl eval "hl.monitor({ output = \"$internal\", mode = \"preferred\", position = \"auto\", scale = \"auto\" })" >/dev/null
|
||||
fi
|
||||
# The connector name is interpolated into Lua too, and it comes from
|
||||
# hyprctl rather than from us. Same class monitors.lua accepts.
|
||||
[[ "$internal" =~ ^[A-Za-z0-9_.-]+$ ]] || exit 0
|
||||
|
||||
rule="$(monitor_rule "$internal")"
|
||||
[[ -n "$rule" ]] || rule='mode = "preferred", position = "auto", scale = "auto"'
|
||||
hyprctl eval "hl.monitor({ output = \"$internal\", $rule })" >/dev/null
|
||||
}
|
||||
|
||||
case "${1:-status}" in
|
||||
@@ -124,8 +260,9 @@ usage: panama-lid [status|guard|close|open]
|
||||
connected; exits immediately on a machine that needs none
|
||||
close docked lid closed: turn the internal panel off (bound to the lid
|
||||
switch by keybinds.lua); does nothing undocked
|
||||
open lid opened: turn the internal panel back on, restoring the mode
|
||||
and scale chosen in Settings
|
||||
open lid opened: turn the internal panel back on, restoring the whole
|
||||
display record chosen in Settings -- position, mode, scale,
|
||||
transform, and the colour, VRR and mirror fields when they are set
|
||||
USAGE
|
||||
;;
|
||||
*) printf 'panama-lid: unknown command: %s\n' "$1" >&2; exit 2 ;;
|
||||
|
||||
Reference in new issue
Block a user