Add charging indicator and pacman package, bump to 1.1.0

- Show charge/discharge power in watts, a battery graphic with charge
  bolt, a power history graph and the estimated time until full or
  remaining (respecting the 80 % charge limit) in the app, the panel
  widget, its tooltip and `predatorctl status`
- Add packaging/arch/PKGBUILD that builds a pacman package; the kernel
  module ships as DKMS source and is rebuilt for every kernel
- Bump version to 1.1.0

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-07 17:37:03 +02:00
co-authored by Claude Opus 5.5
parent ea7ce49452
commit 2ef62e85cd
17 changed files with 315 additions and 25 deletions
+20 -14
View File
@@ -17,7 +17,7 @@ from PySide6.QtWidgets import (QApplication, QButtonGroup, QCheckBox, QComboBox,
from .. import APP_NAME, __version__, layout
from ..client import Client, DaemonError, read_state
from . import theme
from .widgets import Bar, Card, ColorRow, CurveEditor, Gauge, KeyboardPainter
from .widgets import Bar, Card, ChargeIndicator, ColorRow, CurveEditor, Gauge, KeyboardPainter, format_minutes
PAGES = [
("home", "Dashboard"),
@@ -146,8 +146,9 @@ class HomePage(Page):
quick.body.addWidget(self.l_profile)
self.l_fans = _muted("")
quick.body.addWidget(self.l_fans)
self.l_bat = _muted("")
quick.body.addWidget(self.l_bat)
self.charge = ChargeIndicator(compact=True)
self.charge.setFixedHeight(54)
quick.body.addWidget(self.charge)
b = QPushButton("Next profile")
b.setObjectName("primary")
b.clicked.connect(self._cycle)
@@ -182,7 +183,7 @@ class HomePage(Page):
self.l_profile.setText(PROFILE_INFO.get(st["profile"], (st["profile"],))[0])
self.l_fans.setText("Fans: %s" % st["config"]["fans"]["mode"].capitalize())
b = st["battery"]
self.l_bat.setText("Battery %s %% · %s" % (b.get("capacity"), "AC" if b.get("ac_online") else "on battery"))
self.charge.set_battery(b)
class PerformancePage(Page):
@@ -595,14 +596,13 @@ class BatteryPage(Page):
self.lay.addWidget(_title("Battery", "Battery status and health features"))
row = QHBoxLayout()
row.setSpacing(16)
info = Card("Status")
self.cap = QLabel("–")
self.cap.setObjectName("big")
info.body.addWidget(self.cap)
info = Card("Charging")
self.indicator = ChargeIndicator()
info.body.addWidget(self.indicator)
self.details = _muted("")
info.body.addWidget(self.details)
info.body.addStretch(1)
row.addWidget(info, 1)
row.addWidget(info, 3)
health = Card("Battery health")
self.limit = QCheckBox("Limit charging to 80 %")
self.limit.clicked.connect(lambda v: self._set("set_battery_limit", v))
@@ -648,14 +648,17 @@ class BatteryPage(Page):
def update_state(self, st):
b = st["battery"]
if not b.get("present"):
self.cap.setText("No battery")
self.details.setText("No battery found")
return
self.cap.setText("%s %%" % b.get("capacity"))
lines = ["Status: %s" % b.get("status"),
"Power source: %s" % ("AC adapter" if b.get("ac_online") else "battery"),
"Power draw: %s W" % b.get("power_w"),
self.indicator.set_battery(b)
rate = "Charge rate" if b.get("charging") else "Power draw"
lines = ["Power source: %s" % ("AC adapter" if b.get("ac_online") else "battery"),
"%s: %s W" % (rate, b.get("power_w") if b.get("power_w") is not None else "–"),
"Health: %s %% of design capacity" % b.get("health"),
"Charge cycles: %s" % b.get("cycles")]
if b.get("minutes_left") is not None:
lines.insert(2, "%s: %s" % ("Time until full" + (" (80 % limit)" if st.get("battery_limit") else "")
if b.get("charging") else "Time remaining", format_minutes(b["minutes_left"])))
self.details.setText("\n".join(lines))
@@ -826,6 +829,9 @@ class MainWindow(QMainWindow):
self.banner.hide()
self.state = st
self.stack.currentWidget().update_state(st)
bat_page = self.pages["battery"][0]
if self.stack.currentWidget() is not bat_page:
bat_page.indicator.set_battery(st["battery"]) # keep the power history going
s = st["sensors"]
self.footer.setText("%s\nCPU %s °C · GPU %s" % (
PROFILE_INFO.get(st["profile"], (st["profile"],))[0], s.get("cpu_temp"),
+136
View File
@@ -388,3 +388,139 @@ class ColorRow(QWidget):
self.current = QColor(c)
self.preview.setStyleSheet("background:%s;border-radius:6px;border:1px solid %s;"
% (self.current.name(), theme.BORDER))
def format_minutes(m):
if m is None:
return ""
return "%d h %02d min" % divmod(m, 60) if m >= 60 else "%d min" % m
class ChargeIndicator(QWidget):
"""Battery graphic with fill level, charge bolt, wattage and a power history sparkline."""
HISTORY = 80 # samples (~2 min at 1.5 s)
def __init__(self, compact=False, parent=None):
super().__init__(parent)
self.compact = compact
self.bat = {}
self.history = []
self.setMinimumHeight(54 if compact else 190)
self.setMinimumWidth(300 if compact else 420)
self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
def set_battery(self, bat):
self.bat = bat or {}
w = self.bat.get("power_w")
if w is not None:
self.history = (self.history + [w])[-self.HISTORY:]
self.update()
def _state_text(self):
b = self.bat
st = b.get("status") or ""
if st == "Charging":
return "Charging"
if st == "Discharging":
return "On battery"
if b.get("ac_online"):
return "Plugged in · not charging"
return st or "Unknown"
def paintEvent(self, _):
b = self.bat
p = QPainter(self)
p.setRenderHint(QPainter.Antialiasing)
h = self.height() - 8 if self.compact else min(self.height() * 0.42, 86)
bw, bh = h * 2.0, h
r = QRectF(4, 4, bw, bh)
cap = b.get("capacity") or 0
charging = b.get("charging")
accent = QColor(theme.ACCENT if charging else (theme.DANGER if cap <= 15 else theme.TEXT))
# battery body + tip
p.setPen(QPen(QColor(theme.MUTED), 2.5))
p.setBrush(Qt.NoBrush)
p.drawRoundedRect(r, h * 0.14, h * 0.14)
p.setPen(Qt.NoPen)
p.setBrush(QColor(theme.MUTED))
p.drawRoundedRect(QRectF(r.right() + 2, r.top() + bh * 0.3, bh * 0.1, bh * 0.4), 2, 2)
inner = r.adjusted(5, 5, -5, -5)
fill = QColor(accent)
fill.setAlpha(170)
p.setBrush(fill)
p.drawRoundedRect(QRectF(inner.left(), inner.top(), inner.width() * cap / 100, inner.height()),
h * 0.08, h * 0.08)
if charging:
c = inner.center()
s = inner.height() * 0.42
bolt = QPainterPath(QPointF(c.x() + s * 0.15, c.y() - s))
bolt.lineTo(c.x() - s * 0.45, c.y() + s * 0.12)
bolt.lineTo(c.x() - s * 0.02, c.y() + s * 0.12)
bolt.lineTo(c.x() - s * 0.15, c.y() + s)
bolt.lineTo(c.x() + s * 0.45, c.y() - s * 0.12)
bolt.lineTo(c.x() + s * 0.02, c.y() - s * 0.12)
bolt.closeSubpath()
p.setBrush(QColor(theme.TEXT))
p.setPen(QPen(QColor(theme.BG), 1.5))
p.drawPath(bolt)
else:
f = QFont(self.font())
f.setBold(True)
f.setPointSizeF(max(8, h * 0.22))
p.setFont(f)
p.setPen(QColor(theme.TEXT))
p.drawText(inner, Qt.AlignCenter, "%d %%" % cap)
# text block
tx = r.right() + bh * 0.1 + 16
f = QFont(self.font())
w = b.get("power_w")
f.setBold(True)
f.setPointSizeF(max(10, h * (0.36 if self.compact else 0.34)))
p.setFont(f)
p.setPen(accent if charging else QColor(theme.TEXT))
big = ("%.1f W" % w) if w else ("%d %%" % cap)
p.drawText(QRectF(tx, r.top(), self.width() - tx, bh * 0.55), Qt.AlignLeft | Qt.AlignVCenter, big)
f.setBold(False)
f.setPointSizeF(max(8, h * (0.17 if self.compact else 0.14)))
p.setFont(f)
p.setPen(QColor(theme.MUTED))
sub = self._state_text()
if w:
sub = "%d %% · %s" % (cap, sub)
t = format_minutes(b.get("minutes_left"))
if t:
sub += " · %s %s" % (t, "until full" if charging else "left")
p.drawText(QRectF(tx, r.top() + bh * 0.5, self.width() - tx, bh * 0.5), Qt.AlignLeft | Qt.AlignVCenter, sub)
if self.compact or len(self.history) < 2:
return
# power history sparkline
g = QRectF(4, r.bottom() + 22, self.width() - 8, self.height() - r.bottom() - 30)
if g.height() < 30:
return
p.setPen(QColor(theme.MUTED))
f.setPointSizeF(8)
p.setFont(f)
top = max(10.0, max(self.history) * 1.15)
p.drawText(QRectF(g.left(), g.top() - 18, g.width(), 16), Qt.AlignLeft,
"Power over the last %d min" % max(1, round(len(self.history) * 1.5 / 60)))
p.drawText(QRectF(g.left(), g.top() - 18, g.width(), 16), Qt.AlignRight, "max %.0f W" % top)
p.setPen(QPen(QColor(theme.GRID), 1))
p.drawLine(g.bottomLeft(), g.bottomRight())
n = self.HISTORY - 1
pts = [QPointF(g.left() + g.width() * (n - (len(self.history) - 1 - i)) / n,
g.bottom() - g.height() * v / top) for i, v in enumerate(self.history)]
path = QPainterPath(pts[0])
for pt in pts[1:]:
path.lineTo(pt)
area = QPainterPath(path)
area.lineTo(pts[-1].x(), g.bottom())
area.lineTo(pts[0].x(), g.bottom())
c = QColor(theme.ACCENT)
c.setAlpha(45)
p.fillPath(area, c)
p.setPen(QPen(QColor(theme.ACCENT), 2))
p.drawPath(path)