Files
MrBlakeandClaude Opus 5.5 2ef62e85cd 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>
2026-10-07 17:37:03 +02:00

527 lines
19 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Custom widgets: gauges, fan curve editor, per-key keyboard painter, colour swatches."""
from PySide6.QtCore import QPointF, QRectF, Qt, Signal
from PySide6.QtGui import QColor, QFont, QPainter, QPainterPath, QPen
from PySide6.QtWidgets import (QColorDialog, QFrame, QHBoxLayout, QLabel, QPushButton,
QSizePolicy, QVBoxLayout, QWidget)
from .. import layout
from . import theme
class Card(QFrame):
"""Rounded panel with an optional title."""
def __init__(self, title=None, parent=None):
super().__init__(parent)
self.setObjectName("card")
self.body = QVBoxLayout(self)
self.body.setContentsMargins(18, 16, 18, 16)
self.body.setSpacing(10)
if title:
t = QLabel(title)
t.setObjectName("cardTitle")
self.body.addWidget(t)
class Gauge(QWidget):
"""Circular gauge (270° arc)."""
def __init__(self, title, unit, maximum=100, warn=None, parent=None):
super().__init__(parent)
self.title, self.unit, self.maximum, self.warn = title, unit, maximum, warn
self.value = None
self.sub = ""
self.setMinimumSize(150, 150)
self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
def set_value(self, value, sub=""):
self.value, self.sub = value, sub
self.update()
def paintEvent(self, _):
p = QPainter(self)
p.setRenderHint(QPainter.Antialiasing)
side = min(self.width(), self.height()) - 16
r = QRectF((self.width() - side) / 2, (self.height() - side) / 2, side, side)
pen = QPen(QColor(theme.TRACK), max(8, side / 14), Qt.SolidLine, Qt.RoundCap)
p.setPen(pen)
p.drawArc(r, -45 * 16, 270 * 16)
if self.value is not None:
frac = max(0.0, min(1.0, self.value / self.maximum))
col = QColor(theme.ACCENT)
if self.warn and self.value >= self.warn:
col = QColor(theme.DANGER)
elif self.warn and self.value >= self.warn * 0.85:
col = QColor(theme.WARN)
pen.setColor(col)
p.setPen(pen)
p.drawArc(r, 225 * 16, int(-270 * 16 * frac))
p.setPen(QColor(theme.TEXT))
f = QFont(self.font())
f.setPointSizeF(max(10, side / 7))
f.setBold(True)
p.setFont(f)
txt = "–" if self.value is None else ("%d" % round(self.value))
p.drawText(r.adjusted(0, -side * 0.08, 0, -side * 0.08), Qt.AlignCenter, txt)
f.setPointSizeF(max(7, side / 16))
f.setBold(False)
p.setFont(f)
p.setPen(QColor(theme.MUTED))
p.drawText(r.adjusted(0, side * 0.22, 0, 0), Qt.AlignHCenter | Qt.AlignVCenter, self.unit)
p.setPen(QColor(theme.TEXT))
p.drawText(QRectF(r.left(), r.bottom() - side * 0.2, r.width(), side * 0.2),
Qt.AlignHCenter | Qt.AlignVCenter, self.title)
if self.sub:
p.setPen(QColor(theme.MUTED))
p.drawText(QRectF(r.left(), r.bottom() - side * 0.05, r.width(), side * 0.2),
Qt.AlignHCenter | Qt.AlignTop, self.sub)
class Bar(QWidget):
"""Thin labelled progress bar."""
def __init__(self, title, parent=None):
super().__init__(parent)
self.title = title
self.value, self.text = 0, ""
self.setMinimumHeight(38)
def set_value(self, frac, text):
self.value, self.text = max(0.0, min(1.0, frac or 0)), text
self.update()
def paintEvent(self, _):
p = QPainter(self)
p.setRenderHint(QPainter.Antialiasing)
p.setPen(QColor(theme.MUTED))
p.drawText(QRectF(0, 0, self.width(), 18), Qt.AlignLeft | Qt.AlignVCenter, self.title)
p.setPen(QColor(theme.TEXT))
p.drawText(QRectF(0, 0, self.width(), 18), Qt.AlignRight | Qt.AlignVCenter, self.text)
track = QRectF(0, 24, self.width(), 8)
p.setPen(Qt.NoPen)
p.setBrush(QColor(theme.TRACK))
p.drawRoundedRect(track, 4, 4)
p.setBrush(QColor(theme.ACCENT))
p.drawRoundedRect(QRectF(0, 24, self.width() * self.value, 8), 4, 4)
class CurveEditor(QWidget):
"""Drag-and-drop fan curve: temperature (°C) -> fan speed (%)."""
changed = Signal(list)
T_MIN, T_MAX = 30, 100
def __init__(self, title, parent=None):
super().__init__(parent)
self.title = title
self.points = [[45, 0], [60, 35], [72, 55], [82, 75], [90, 100]]
self.current_temp = None
self._drag = None
self.setMinimumHeight(220)
self.setMouseTracking(True)
def set_points(self, pts):
if self._drag is None:
self.points = sorted([list(p) for p in pts])
self.update()
def set_current(self, temp):
self.current_temp = temp
self.update()
def _plot(self):
return QRectF(44, 26, self.width() - 60, self.height() - 56)
def _to_px(self, t, s):
r = self._plot()
return QPointF(r.left() + (t - self.T_MIN) / (self.T_MAX - self.T_MIN) * r.width(),
r.bottom() - s / 100 * r.height())
def _from_px(self, pos):
r = self._plot()
t = self.T_MIN + (pos.x() - r.left()) / r.width() * (self.T_MAX - self.T_MIN)
s = (r.bottom() - pos.y()) / r.height() * 100
return t, s
def paintEvent(self, _):
p = QPainter(self)
p.setRenderHint(QPainter.Antialiasing)
r = self._plot()
p.setPen(QColor(theme.TEXT))
p.drawText(QRectF(0, 0, self.width(), 20), Qt.AlignLeft | Qt.AlignVCenter, self.title)
grid = QPen(QColor(theme.GRID), 1)
p.setPen(grid)
for s in range(0, 101, 25):
a = self._to_px(self.T_MIN, s)
p.drawLine(QPointF(r.left(), a.y()), QPointF(r.right(), a.y()))
p.setPen(QColor(theme.MUTED))
p.drawText(QRectF(0, a.y() - 8, 38, 16), Qt.AlignRight | Qt.AlignVCenter, "%d%%" % s)
p.setPen(grid)
for t in range(self.T_MIN, self.T_MAX + 1, 10):
a = self._to_px(t, 0)
p.drawLine(QPointF(a.x(), r.top()), QPointF(a.x(), r.bottom()))
p.setPen(QColor(theme.MUTED))
p.drawText(QRectF(a.x() - 20, r.bottom() + 4, 40, 16), Qt.AlignCenter, "%d°" % t)
p.setPen(grid)
if self.current_temp:
a = self._to_px(max(self.T_MIN, min(self.T_MAX, self.current_temp)), 0)
p.setPen(QPen(QColor(theme.WARN), 1, Qt.DashLine))
p.drawLine(QPointF(a.x(), r.top()), QPointF(a.x(), r.bottom()))
pts = sorted(self.points)
path = QPainterPath(self._to_px(self.T_MIN, pts[0][1]))
for t, s in pts:
path.lineTo(self._to_px(t, s))
path.lineTo(self._to_px(self.T_MAX, pts[-1][1]))
fill = QPainterPath(path)
fill.lineTo(self._to_px(self.T_MAX, 0))
fill.lineTo(self._to_px(self.T_MIN, 0))
c = QColor(theme.ACCENT)
c.setAlpha(40)
p.fillPath(fill, c)
p.setPen(QPen(QColor(theme.ACCENT), 2.5))
p.drawPath(path)
p.setBrush(QColor(theme.BG))
for i, (t, s) in enumerate(pts):
p.setPen(QPen(QColor(theme.TEXT if i == self._drag else theme.ACCENT), 2.5))
p.drawEllipse(self._to_px(t, s), 6, 6)
if i == self._drag:
p.drawText(self._to_px(t, s) + QPointF(10, -10), "%d° → %d%%" % (t, s))
def mousePressEvent(self, e):
for i, (t, s) in enumerate(sorted(self.points)):
d = self._to_px(t, s) - e.position()
if abs(d.x()) < 12 and abs(d.y()) < 12:
self.points = sorted(self.points)
self._drag = i
self.update()
return
def mouseMoveEvent(self, e):
if self._drag is None:
return
t, s = self._from_px(e.position())
i = self._drag
lo = self.points[i - 1][0] + 1 if i > 0 else self.T_MIN
hi = self.points[i + 1][0] - 1 if i < len(self.points) - 1 else self.T_MAX
self.points[i] = [int(max(lo, min(hi, round(t)))), int(max(0, min(100, round(s / 5) * 5)))]
self.update()
def mouseReleaseEvent(self, _):
if self._drag is not None:
self._drag = None
self.update()
self.changed.emit([list(p) for p in self.points])
class KeyboardPainter(QWidget):
"""Draws the keyboard and lets the user paint keys with the current colour."""
painted = Signal()
picked = Signal(QColor)
def __init__(self, parent=None):
super().__init__(parent)
self.variant = layout.default_variant()
self.keys = layout.keys(self.variant)
self.colors = {} # cell -> QColor
self.brush = QColor(theme.ACCENT)
self._painting = False
self._hover = None
self.setMouseTracking(True)
self.setMinimumHeight(260)
self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
def set_variant(self, variant):
self.variant = variant
self.keys = layout.keys(variant)
self.update()
def heightForWidth(self, w):
kw, kh = layout.size(self.keys)
return int(w * kh / kw)
def hasHeightForWidth(self):
return True
def _scale(self):
kw, kh = layout.size(self.keys)
s = min((self.width() - 8) / kw, (self.height() - 8) / kh)
ox = (self.width() - kw * s) / 2
oy = (self.height() - kh * s) / 2
return s, ox, oy
def _rect(self, k, s, ox, oy):
_l, _c, x, y, w, h = k
g = s * 0.08
return QRectF(ox + x * s + g / 2, oy + y * s + g / 2, w * s - g, h * s - g)
def _key_at(self, pos):
s, ox, oy = self._scale()
for k in self.keys:
if self._rect(k, s, ox, oy).contains(pos):
return k
return None
def paintEvent(self, _):
p = QPainter(self)
p.setRenderHint(QPainter.Antialiasing)
s, ox, oy = self._scale()
f = QFont(self.font())
for k in self.keys:
label, cell = k[0], k[1]
r = self._rect(k, s, ox, oy)
col = self.colors.get(cell, QColor(0, 0, 0))
base = QColor(theme.KEY)
p.setPen(QPen(QColor(theme.TEXT) if k is self._hover else QColor(theme.KEY_BORDER), 1.5))
p.setBrush(base)
p.drawRoundedRect(r, s * 0.12, s * 0.12)
# glow of the key colour
glow = QColor(col)
glow.setAlpha(210 if (col.red() + col.green() + col.blue()) > 0 else 0)
p.setPen(Qt.NoPen)
p.setBrush(glow)
p.drawRoundedRect(r.adjusted(3, 3, -3, -3), s * 0.1, s * 0.1)
lum = 0.299 * col.red() + 0.587 * col.green() + 0.114 * col.blue()
p.setPen(QColor("#0b0f14") if lum > 150 else QColor(theme.TEXT))
f.setPointSizeF(max(6, s * (0.22 if len(label) <= 2 else 0.16)))
p.setFont(f)
p.drawText(r, Qt.AlignCenter, label)
def _paint_at(self, pos, button):
k = self._key_at(pos)
if not k:
return
if button == Qt.RightButton:
self.picked.emit(QColor(self.colors.get(k[1], QColor(0, 0, 0))))
return
if self.colors.get(k[1]) != self.brush:
self.colors[k[1]] = QColor(self.brush)
self.update()
self.painted.emit()
def mousePressEvent(self, e):
self._painting = e.button() == Qt.LeftButton
self._paint_at(e.position(), e.button())
def mouseMoveEvent(self, e):
k = self._key_at(e.position())
if k is not self._hover:
self._hover = k
self.update()
if self._painting:
self._paint_at(e.position(), Qt.LeftButton)
def mouseReleaseEvent(self, _):
self._painting = False
def leaveEvent(self, _):
self._hover = None
self.update()
def fill(self, cells, color):
for c in cells:
self.colors[c] = QColor(color)
self.update()
self.painted.emit()
def frame_hex(self):
out = bytearray(128 * 3)
for c, col in self.colors.items():
if 0 <= c < 128:
out[c * 3:c * 3 + 3] = bytes((col.red(), col.green(), col.blue()))
return out.hex()
def load_frame(self, hexstr):
self.colors = {}
try:
data = bytes.fromhex(hexstr or "")
except ValueError:
data = b""
for c in range(min(128, len(data) // 3)):
self.colors[c] = QColor(data[c * 3], data[c * 3 + 1], data[c * 3 + 2])
self.update()
PRESET_COLORS = ["#00d4ff", "#0057ff", "#7a00ff", "#ff00c8", "#ff0033", "#ff6a00",
"#ffd000", "#3cff00", "#00ff9c", "#ffffff"]
class ColorRow(QWidget):
"""Row of colour swatches plus a 'custom colour' button."""
colorChosen = Signal(QColor)
def __init__(self, parent=None):
super().__init__(parent)
lay = QHBoxLayout(self)
lay.setContentsMargins(0, 0, 0, 0)
lay.setSpacing(6)
self.current = QColor(PRESET_COLORS[0])
for c in PRESET_COLORS:
b = QPushButton()
b.setObjectName("swatch")
b.setFixedSize(28, 28)
b.setStyleSheet("QPushButton#swatch{background:%s;}" % c)
b.setToolTip(c)
b.clicked.connect(lambda _=False, c=c: self._choose(QColor(c)))
lay.addWidget(b)
self.custom = QPushButton("Custom…")
self.custom.clicked.connect(self._dialog)
lay.addWidget(self.custom)
self.preview = QLabel()
self.preview.setFixedSize(46, 28)
lay.addWidget(self.preview)
lay.addStretch(1)
self.set_color(self.current)
def _dialog(self):
c = QColorDialog.getColor(self.current, self, "Choose colour")
if c.isValid():
self._choose(c)
def _choose(self, c):
self.set_color(c)
self.colorChosen.emit(c)
def set_color(self, c):
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)