PredatorSense replacement for the Acer Predator Helios 16 (PH16-71) on Linux: - predatord: root service for profiles, fans (incl. temperature curves), keyboard RGB, lightbar and battery health; restores settings at boot and after resume, switches profiles on AC/battery changes - Qt desktop app with dashboard, fan curve editor and per-key RGB studio - Plasma 6 panel widget with quick controls - predatorctl command line tool - predator_wmi kernel module (DKMS) as a minimal WMI method bridge Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
391 lines
14 KiB
Python
391 lines
14 KiB
Python
"""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))
|