From bd109dceee591d88c1e75199bd432bc69317bfa8 Mon Sep 17 00:00:00 2001 From: soraefir Date: Fri, 11 Sep 2026 15:07:27 +0200 Subject: [PATCH] eww better battery --- .../home/wayland/apps/eww/bar/css/_sys.scss | 23 ++++-- .../wayland/apps/eww/bar/modules/sys.yuck | 5 +- .../wayland/apps/eww/bar/scripts/sys/battery | 79 +++++++++++++++---- .../wayland/apps/eww/bar/windows/sys.yuck | 50 +++++++++--- 4 files changed, 124 insertions(+), 33 deletions(-) diff --git a/modules/home/wayland/apps/eww/bar/css/_sys.scss b/modules/home/wayland/apps/eww/bar/css/_sys.scss index efc91eb..aa05693 100644 --- a/modules/home/wayland/apps/eww/bar/css/_sys.scss +++ b/modules/home/wayland/apps/eww/bar/css/_sys.scss @@ -3,7 +3,7 @@ .cpubar { color: $base0C; } .gpubar { color: $base0E; } .membar { color: $base08; } -.batbar { color: $base0B; } // overridden by .bat- below +.batbar { color: $base0A; } // overridden by .bat- below .cpubar, .gpubar, .membar, .batbar { @include background-base2; margin: $gaps-window 0; } // Window chrome @@ -66,7 +66,9 @@ .gpu-ring-label, .vram-usage-label, .swap-section-label { margin-top: 2pt; } .cpu-core-label { font-size: 0.7em; @include color-active; } -.bat-ring-label { font-size: 0.7em; @include color-body; } +.bat-ring-label { font-size: 0.85em; font-weight: bold; @include color-body; } +.bat-stat-value { font-size: 1.05em; font-weight: bold; @include color-body; } +.bat-stat-label { font-size: 0.62em; margin-top: 2pt; @include color-active; } .ram-total-label { font-size: 0.72em; @include color-active; } // GPU stats row @@ -92,7 +94,18 @@ margin: $popup-scale * 4pt; } -// Battery colour by state (scripts/sys/battery → .bat-) -.batbar, .bat-ring { color: $base0B; } -.bat-charging { color: $base0C; } +// Battery colour by state (scripts/sys/battery → .bat-): +// light blue on mains (charging / full / idle), on battery green, orange below +// 25 %, red at ≤15 % +.batbar, .bat-ring { color: $base0A; } +.bat-charging, .bat-full, .bat-idle { color: $base0B; } .bat-low { color: $base08; } +.bat-critical { color: $base0F; } + +// Battery history plot: one vertical bar per bucket, same colours as the ring +.bat-hist { min-height: 44px; margin-top: $popup-scale * 8pt; } +.bat-hist-bar { @include background-base2; margin: 0 2px; } +.bat-hist-bar trough * { background-color: $base0A; } +.hist-charging trough *, .hist-idle trough * { background-color: $base0B; } +.hist-low trough * { background-color: $base08; } +.hist-critical trough * { background-color: $base0F; } diff --git a/modules/home/wayland/apps/eww/bar/modules/sys.yuck b/modules/home/wayland/apps/eww/bar/modules/sys.yuck index de591ee..05aa5a4 100644 --- a/modules/home/wayland/apps/eww/bar/modules/sys.yuck +++ b/modules/home/wayland/apps/eww/bar/modules/sys.yuck @@ -1,8 +1,9 @@ (deflisten gpu :initial '{"gfx_pct":0,"mem_pct":0,"media_pct":0,"sclk":0,"mclk":0,"sclk_pct":0,"mclk_pct":0,"vclk":0,"vclk_pct":0,"temp":0,"power":0,"vram_used":0,"vram_total":1}' "scripts/sys/gpu") (deflisten memory :initial '{"total":1,"used":0,"cached":0,"human":{"total":"0G","used":"0G","cached":"0G"},"swap":{"total":0,"used":0,"human":{"total":"0G","used":"0G"}}}' "scripts/sys/memory") -; state: charging | discharging | low | full | idle | none — styled via .bat- -(deflisten battery :initial '{"visible":false,"percentage":0,"wattage":"","status":"","state":"none"}' "scripts/sys/battery") +; state: charging | discharging | low (<25%) | critical (≤15%) | full | idle | none — styled via .bat- +; history: 6 h of {t, p, s} buckets (p = -1 when empty) +(deflisten battery :initial '{"visible":false,"percentage":0,"wattage":"","status":"","time":"","state":"none","rate":"","history":[]}' "scripts/sys/battery") (defwidget sys-mod [] (module diff --git a/modules/home/wayland/apps/eww/bar/scripts/sys/battery b/modules/home/wayland/apps/eww/bar/scripts/sys/battery index 59cf256..8bf1da8 100755 --- a/modules/home/wayland/apps/eww/bar/scripts/sys/battery +++ b/modules/home/wayland/apps/eww/bar/scripts/sys/battery @@ -1,17 +1,24 @@ #!/usr/bin/env bash # Battery state from sysfs; refreshes on power_supply udev events and every -# 30 s. Emits a `state` (charging / discharging / low / full / idle) that the -# CSS maps to a colour, instead of hard-coding one here. +# 30 s. Emits a `state` (charging / discharging / low / critical / full / +# idle) that the CSS maps to a colour, instead of hard-coding one here. +# +# Every refresh also appends a sample to $XDG_STATE_HOME/eww/battery.log, +# which feeds the drain rate (%/h) and the history plot. bat="" for d in /sys/class/power_supply/*; do [[ -r $d/type && $(<"$d/type") == Battery ]] && { bat=$d; break; } done if [[ -z $bat ]]; then - echo '{"visible":false,"percentage":0,"wattage":"","status":"","state":"none"}' + echo '{"visible":false,"percentage":0,"wattage":"","status":"","time":"","state":"none","rate":"","history":[]}' exit 0 fi +log=${XDG_STATE_HOME:-$HOME/.local/state}/eww/battery.log +hist_hours=6 buckets=24 +mkdir -p "${log%/*}" && touch "$log" + attr() { # first readable, non-empty attribute of the list, else 0 local a v for a in "$@"; do @@ -22,39 +29,83 @@ attr() { # first readable, non-empty attribute of the list, else 0 hours_to_clock() { awk -v h="$1" 'BEGIN { printf "%d:%02d", int(h), int((h - int(h)) * 60 + 0.5) }'; } +# Append "epoch pct state" and, once the head is an hour past the plot window, +# drop everything older than the window. +log_sample() { + local now=$1 first + printf '%s %s %s\n' "$now" "$2" "$3" >> "$log" + read -r first _ < "$log" + ((first < now - (hist_hours + 1) * 3600)) || return 0 + awk -v min=$((now - hist_hours * 3600)) '$1 >= min' "$log" > "$log.tmp" && mv "$log.tmp" "$log" +} + +# %/h over the last 30 min of samples in the current state, signed; empty +# until there are 5 min of them. +drain_rate() { + awk -v now="$1" -v st="$2" ' + $1 >= now - 1800 && $3 == st { if (!n++) { t0 = $1; p0 = $2 } t1 = $1; p1 = $2 } + END { if (n > 1 && t1 - t0 >= 300) printf "%.1f", (p1 - p0) * 3600 / (t1 - t0) }' "$log" +} + +# The plot window split into buckets: mean % of the bucket's samples (-1 when +# it has none), the state of its last sample, and its start time as label. +plot() { + awk -v now="$1" -v hours="$hist_hours" -v n="$buckets" ' + BEGIN { w = hours * 3600 / n; t0 = now - hours * 3600 } + $1 >= t0 { b = int(($1 - t0) / w); if (b >= n) b = n - 1; sum[b] += $2; cnt[b]++; st[b] = $3 } + END { + printf "[" + for (b = 0; b < n; b++) { + p = -1; s = "none" + if (cnt[b]) { + p = sprintf("%.1f", sum[b] / cnt[b]) + s = st[b] == "c" ? "charging" : st[b] == "d" ? "discharging" : "idle" + if (st[b] == "d" && p < 25) s = "low" + if (st[b] == "d" && p <= 15) s = "critical" + } + printf "%s{\"t\":\"%s\",\"p\":%s,\"s\":\"%s\"}", (b ? "," : ""), strftime("%H:%M", t0 + b * w), p, s + } + printf "]" + }' "$log" +} + state() { - local cap status rate now full power time="" wattage state + local now cap status rate now_e full power pct st time="" wattage state + now=$(printf '%(%s)T' -1) cap=$(attr capacity) status=$(attr status) rate=$(attr power_now current_now) # µW or µA - now=$(attr energy_now charge_now) # µWh or µAh — same unit as rate, so now/rate = hours + now_e=$(attr energy_now charge_now) # µWh or µAh — same unit as rate, so now_e/rate = hours full=$(attr energy_full charge_full) if [[ -r $bat/power_now ]]; then power=$rate; else power=$((rate * $(attr voltage_now) / 1000000)); fi wattage=$(awk -v p="$power" 'BEGIN { printf "%.1f W", p / 1000000 }') + pct=$(awk -v n="$now_e" -v f="$full" -v c="$cap" 'BEGIN { printf "%.1f", (f > 0 ? 100 * n / f : c) }') if ((rate > 0)); then case $status in - Discharging) time=$(hours_to_clock "$(awk -v n="$now" -v r="$rate" 'BEGIN { print n / r }')") ;; - Charging) time=$(hours_to_clock "$(awk -v n="$now" -v f="$full" -v r="$rate" 'BEGIN { print (f - n) / r }')") ;; + Discharging) time=$(hours_to_clock "$(awk -v n="$now_e" -v r="$rate" 'BEGIN { print n / r }')") ;; + Charging) time=$(hours_to_clock "$(awk -v n="$now_e" -v f="$full" -v r="$rate" 'BEGIN { print (f - n) / r }')") ;; esac fi case $status in Charging) - state=charging + state=charging st=c status="Charging${time:+, $time left}" ;; Discharging) - state=discharging - ((cap <= 20)) && state=low + state=discharging st=d + ((cap < 25)) && state=low + ((cap <= 15)) && state=critical status=${time:+$time left} ;; Full) - state=full + state=full st=i status="Fully charged" ;; - *) state=idle ;; + *) state=idle st=i ;; esac - printf '{"visible":true,"percentage":%s,"wattage":"%s","status":"%s","state":"%s"}' \ - "$cap" "$wattage" "$(jstr "$status")" "$state" + log_sample "$now" "$pct" "$st" + printf '{"visible":true,"percentage":%s,"wattage":"%s","status":"%s","time":"%s","state":"%s","rate":"%s","history":%s}' \ + "$cap" "$wattage" "$(jstr "$status")" "$time" "$state" "$(drain_rate "$now" "$st")" "$(plot "$now")" } refresh() { emit "$(state)"; } diff --git a/modules/home/wayland/apps/eww/bar/windows/sys.yuck b/modules/home/wayland/apps/eww/bar/windows/sys.yuck index 67350bd..ff27c50 100644 --- a/modules/home/wayland/apps/eww/bar/windows/sys.yuck +++ b/modules/home/wayland/apps/eww/bar/windows/sys.yuck @@ -173,20 +173,46 @@ ; --- Battery --- -(defwidget bat-sys-win [] +(defwidget bat-stat [value label] + (box :orientation "v" :halign "center" :valign "center" :space-evenly false + (label :class "bat-stat-value" :text value) + (label :class "bat-stat-label" :text label))) + +; left: draw while on battery, charge rate while charging; right: time left / to full +(defwidget bat-sys-win [] (box :orientation "v" :space-evenly false :class "sys-section" :visible {battery.visible} (section-header :title "Battery" :accent "bat-accent") - (overlay - (circular-progress - :width 60 :height 60 - :value {battery.percentage} - :start-at 0 - :clockwise true - :thickness 6 - :class "bat-ring bat-${battery.state}" - :tooltip "Battery ${round(battery.percentage, 0)}%\n${battery.status} @ ${battery.wattage}") - (label :class "bat-ring-label" - :text "${round(battery.percentage, 0)}%")) + (box :orientation "h" :space-evenly true :valign "center" + (bat-stat + :value {battery.state == "charging" + ? (battery.rate == "" ? "—" : "${battery.rate} %/h") + : (battery.state == "discharging" || battery.state == "low" || battery.state == "critical" + ? battery.wattage : "—")} + :label {battery.state == "charging" ? "charging" : "draw"}) + (overlay + (circular-progress + :width 72 :height 72 + :value {battery.percentage} + :start-at 0 + :clockwise true + :thickness 7 + :class "bat-ring bat-${battery.state}" + :tooltip "Battery ${round(battery.percentage, 0)}%\n${battery.status} @ ${battery.wattage}") + (label :class "bat-ring-label" + :text "${round(battery.percentage, 0)}%")) + (bat-stat + :value {battery.time == "" ? "—" : battery.time} + :label {battery.state == "charging" ? "to full" : "left"})) + ; 6 h history, one bar per 15 min bucket, coloured like the ring + (box :orientation "h" :space-evenly true :class "bat-hist" + (for b in {battery.history} + (progress :orientation "v" :flipped true + :value {b.p < 0 ? 0 : b.p} + :class "bat-hist-bar hist-${b.s}" + :tooltip {b.p < 0 ? b.t : "${b.t} ${round(b.p, 0)}%"}))) + (box :orientation "h" :space-evenly false + (label :class "gpu-stat-label" :halign "start" :hexpand true :text "-6h") + (label :class "gpu-stat-label" :halign "end" :text "now")) ) )