Initial release of Predator Control 1.0.0

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>
This commit is contained in:
2026-10-04 18:38:14 +02:00
co-authored by Claude Opus 5.5
commit ea7ce49452
34 changed files with 3774 additions and 0 deletions
View File
+887
View File
@@ -0,0 +1,887 @@
"""Predator Control main window."""
import argparse
import os
import sys
import threading
from PySide6.QtCore import QObject, Qt, QTimer, Signal
from PySide6.QtGui import QColor, QIcon
from PySide6.QtNetwork import QLocalServer, QLocalSocket
from PySide6.QtWidgets import (QApplication, QButtonGroup, QCheckBox, QComboBox, QFrame,
QGridLayout, QHBoxLayout, QInputDialog, QLabel, QListWidget,
QListWidgetItem, QMainWindow, QMessageBox, QPushButton,
QScrollArea, QSlider, QSpinBox, QStackedWidget, QTabWidget,
QVBoxLayout, QWidget)
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
PAGES = [
("home", "Dashboard"),
("performance", "Performance"),
("fans", "Fans"),
("rgb", "RGB Studio"),
("battery", "Battery"),
("system", "System"),
]
PROFILE_INFO = {
"low-power": ("Eco", "Maximum battery life, lowest power limits."),
"quiet": ("Quiet", "Low noise for office work and video."),
"balanced": ("Balanced", "Everyday mix of performance and acoustics."),
"balanced-performance": ("Performance", "Higher power limits for gaming."),
"performance": ("Turbo", "Maximum CPU/GPU power, fans run loud."),
}
# ------------------------------------------------------------------ backend
class Backend(QObject):
"""Runs daemon commands in worker threads so the UI never blocks."""
finished = Signal(str, object, str) # command, result, error
def run(self, cmd, **args):
def work():
c = Client()
try:
res, err = c.call(cmd, **args), ""
except (DaemonError, OSError, ValueError) as e:
res, err = None, str(e)
finally:
c.close()
self.finished.emit(cmd, res, err)
threading.Thread(target=work, daemon=True).start()
def call(self, cmd, **args):
c = Client()
try:
return c.call(cmd, **args)
finally:
c.close()
def _title(text, sub=None):
w = QWidget()
lay = QVBoxLayout(w)
lay.setContentsMargins(0, 0, 0, 8)
lay.setSpacing(2)
t = QLabel(text)
t.setObjectName("pageTitle")
lay.addWidget(t)
if sub:
s = QLabel(sub)
s.setObjectName("pageSub")
s.setWordWrap(True)
lay.addWidget(s)
return w
def _slider(lo, hi, val=0):
s = QSlider(Qt.Horizontal)
s.setRange(lo, hi)
s.setValue(val)
return s
def _muted(text):
l = QLabel(text)
l.setObjectName("muted")
l.setWordWrap(True)
return l
class Page(QScrollArea):
def __init__(self, win):
super().__init__()
self.win = win
self.setWidgetResizable(True)
inner = QWidget()
inner.setObjectName("page")
self.lay = QVBoxLayout(inner)
self.lay.setContentsMargins(32, 28, 32, 28)
self.lay.setSpacing(16)
self.setWidget(inner)
def update_state(self, st):
pass
# ------------------------------------------------------------------ pages
class HomePage(Page):
def __init__(self, win):
super().__init__(win)
self.lay.addWidget(_title("Dashboard", "Live system monitoring"))
grid = QGridLayout()
grid.setSpacing(16)
self.g_cpu = Gauge("CPU", "°C", 105, warn=90)
self.g_gpu = Gauge("GPU", "°C", 100, warn=85)
self.g_cfan = Gauge("CPU fan", "RPM", 7000)
self.g_gfan = Gauge("GPU fan", "RPM", 7000)
for i, g in enumerate((self.g_cpu, self.g_gpu, self.g_cfan, self.g_gfan)):
c = Card()
c.body.addWidget(g)
c.setMinimumHeight(210)
grid.addWidget(c, 0, i)
self.lay.addLayout(grid)
row = QHBoxLayout()
row.setSpacing(16)
load = Card("System")
self.b_load = Bar("CPU load")
self.b_freq = Bar("CPU clock (avg)")
self.b_mem = Bar("Memory")
self.b_sys = Bar("Chassis temperature")
for b in (self.b_load, self.b_freq, self.b_mem, self.b_sys):
load.body.addWidget(b)
row.addWidget(load, 3)
quick = Card("Mode")
self.l_profile = QLabel("–")
self.l_profile.setObjectName("big")
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)
b = QPushButton("Next profile")
b.setObjectName("primary")
b.clicked.connect(self._cycle)
quick.body.addWidget(b)
quick.body.addStretch(1)
row.addWidget(quick, 2)
self.lay.addLayout(row)
self.lay.addStretch(1)
def _cycle(self):
st = self.win.state
if not st:
return
ch = st["profile_choices"]
nxt = ch[(ch.index(st["profile"]) + 1) % len(ch)] if st["profile"] in ch else ch[0]
self.win.backend.run("set_profile", profile=nxt)
def update_state(self, st):
s = st["sensors"]
self.g_cpu.set_value(s.get("cpu_temp"))
self.g_gpu.set_value(s.get("gpu_temp"), "" if s.get("gpu_temp") else "dGPU asleep")
self.g_cfan.set_value(s.get("cpu_fan"))
self.g_gfan.set_value(s.get("gpu_fan"))
load = st.get("cpu_load") or 0
self.b_load.set_value(load / 100, "%.0f %%" % load)
f = st.get("cpu_freq") or 0
self.b_freq.set_value(f / 5500, "%.2f GHz" % (f / 1000))
m = st.get("memory") or {}
self.b_mem.set_value((m.get("percent") or 0) / 100, "%s / %s GB" % (m.get("used_gb"), m.get("total_gb")))
t = s.get("sys_temp") or 0
self.b_sys.set_value(t / 100, "%s °C" % (t or "–"))
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"))
class PerformancePage(Page):
def __init__(self, win):
super().__init__(win)
self.lay.addWidget(_title("Performance", "Choose an operating mode. Uses the firmware's ACPI platform profiles."))
card = Card("Operating mode")
row = QHBoxLayout()
row.setSpacing(12)
self.group = QButtonGroup(self)
self.group.setExclusive(True)
self.buttons = {}
for key, (name, desc) in PROFILE_INFO.items():
b = QPushButton(name)
b.setObjectName("profileTurbo" if key == "performance" else "profile")
b.setCheckable(True)
b.setToolTip(desc)
b.clicked.connect(lambda _=False, k=key: win.backend.run("set_profile", profile=k))
self.group.addButton(b)
self.buttons[key] = b
row.addWidget(b)
card.body.addLayout(row)
self.desc = _muted("")
card.body.addWidget(self.desc)
self.lay.addWidget(card)
pol = Card("Automatic switching")
self.auto = QCheckBox("Switch the profile automatically when plugging in / unplugging the charger")
self.auto.toggled.connect(self._policy)
pol.body.addWidget(self.auto)
g = QGridLayout()
self.ac = QComboBox()
self.bat = QComboBox()
for k, (name, _d) in PROFILE_INFO.items():
self.ac.addItem(name, k)
self.bat.addItem(name, k)
self.ac.activated.connect(self._policy)
self.bat.activated.connect(self._policy)
g.addWidget(QLabel("On AC power"), 0, 0)
g.addWidget(self.ac, 0, 1)
g.addWidget(QLabel("On battery"), 1, 0)
g.addWidget(self.bat, 1, 1)
g.setColumnStretch(2, 1)
pol.body.addLayout(g)
pol.body.addWidget(_muted("Choosing a mode above also stores it for the current power source. "
"Tip: the Predator/Turbo key cycles the firmware profiles as well."))
self.lay.addWidget(pol)
self.lay.addStretch(1)
def _policy(self, *_):
self.win.backend.run("set_profile_policy", auto_switch=self.auto.isChecked(),
ac=self.ac.currentData(), battery=self.bat.currentData())
def update_state(self, st):
for k, b in self.buttons.items():
b.setEnabled(k in st["profile_choices"])
b.setChecked(k == st["profile"])
self.desc.setText(PROFILE_INFO.get(st["profile"], ("", ""))[1])
p = st["config"]["profile"]
for w, v in ((self.auto, p["auto_switch"]),):
w.blockSignals(True)
w.setChecked(v)
w.blockSignals(False)
for combo, key in ((self.ac, "ac"), (self.bat, "battery")):
if not combo.view().isVisible():
combo.blockSignals(True)
combo.setCurrentIndex(max(0, combo.findData(p[key])))
combo.blockSignals(False)
class FansPage(Page):
MODES = [("auto", "Auto", "Firmware controls the fans."),
("max", "Max", "Both fans at full speed (cooler boost)."),
("custom", "Manual", "Fixed speed for each fan."),
("curve", "Curve", "Speed follows your temperature curve.")]
def __init__(self, win):
super().__init__(win)
self.lay.addWidget(_title("Fans", "Fan control via the Acer gaming WMI interface"))
top = Card("Fan mode")
row = QHBoxLayout()
self.group = QButtonGroup(self)
self.mode_btns = {}
for key, name, desc in self.MODES:
b = QPushButton(name)
b.setObjectName("profile")
b.setCheckable(True)
b.setToolTip(desc)
b.clicked.connect(lambda _=False, k=key: self._set_mode(k))
self.group.addButton(b)
self.mode_btns[key] = b
row.addWidget(b)
top.body.addLayout(row)
self.rpm = _muted("")
top.body.addWidget(self.rpm)
self.lay.addWidget(top)
self.manual = Card("Manual speed")
g = QGridLayout()
self.s_cpu = _slider(0, 100, 50)
self.s_gpu = _slider(0, 100, 50)
self.l_cpu = QLabel()
self.l_gpu = QLabel()
for i, (name, s, l) in enumerate((("CPU fan", self.s_cpu, self.l_cpu), ("GPU fan", self.s_gpu, self.l_gpu))):
s.setSingleStep(5)
s.setPageStep(10)
s.valueChanged.connect(lambda v, l=l: l.setText("%d %%" % v if v else "auto"))
s.sliderReleased.connect(self._apply_manual)
g.addWidget(QLabel(name), i, 0)
g.addWidget(s, i, 1)
g.addWidget(l, i, 2)
g.setColumnStretch(1, 1)
self.manual.body.addLayout(g)
self.manual.body.addWidget(_muted("0 % leaves that fan under firmware control."))
self.lay.addWidget(self.manual)
self.curve = Card("Fan curves")
self.c_cpu = CurveEditor("CPU (package temperature)")
self.c_gpu = CurveEditor("GPU (dGPU temperature)")
self.c_cpu.changed.connect(lambda pts: self._apply_curve("cpu", pts))
self.c_gpu.changed.connect(lambda pts: self._apply_curve("gpu", pts))
h = QHBoxLayout()
h.addWidget(self.c_cpu)
h.addWidget(self.c_gpu)
self.curve.body.addLayout(h)
self.curve_info = _muted("")
self.curve.body.addWidget(self.curve_info)
self.curve.body.addWidget(_muted("Drag the points. The daemon evaluates the curve every 1.5 s, "
"ramps up immediately and ramps down with 10 % hysteresis."))
self.lay.addWidget(self.curve)
self.lay.addStretch(1)
def _set_mode(self, mode):
args = {"mode": mode}
if mode == "custom":
args.update(cpu=self.s_cpu.value(), gpu=self.s_gpu.value())
self.win.backend.run("set_fans", **args)
def _apply_manual(self):
self.win.backend.run("set_fans", mode="custom", cpu=self.s_cpu.value(), gpu=self.s_gpu.value())
def _apply_curve(self, which, pts):
curve = dict(self.win.state["config"]["fans"]["curve"]) if self.win.state else {}
curve[which] = pts
self.win.backend.run("set_fans", curve=curve, mode="curve")
def update_state(self, st):
f = st["config"]["fans"]
for k, b in self.mode_btns.items():
b.setChecked(k == f["mode"])
s = st["sensors"]
self.rpm.setText("CPU fan %s RPM · GPU fan %s RPM · CPU %s °C · GPU %s" % (
s.get("cpu_fan"), s.get("gpu_fan"), s.get("cpu_temp"),
("%s °C" % s["gpu_temp"]) if s.get("gpu_temp") else "asleep"))
self.manual.setVisible(f["mode"] == "custom")
self.curve.setVisible(f["mode"] == "curve")
for sl, key in ((self.s_cpu, "cpu"), (self.s_gpu, "gpu")):
if not sl.isSliderDown():
sl.blockSignals(True)
sl.setValue(f[key])
sl.blockSignals(False)
self.l_cpu.setText("%d %%" % self.s_cpu.value() if self.s_cpu.value() else "auto")
self.l_gpu.setText("%d %%" % self.s_gpu.value() if self.s_gpu.value() else "auto")
self.c_cpu.set_points(f["curve"]["cpu"])
self.c_gpu.set_points(f["curve"]["gpu"])
self.c_cpu.set_current(s.get("cpu_temp"))
self.c_gpu.set_current(s.get("gpu_temp"))
ap = st.get("fan_curve_applied")
self.curve_info.setText("Current target: CPU %s %% · GPU %s %%" % tuple(ap) if ap else "")
class RgbPage(Page):
def __init__(self, win):
super().__init__(win)
self.lay.addWidget(_title("RGB Studio", "Keyboard effects, per-key painting and the rear lightbar"))
tabs = QTabWidget()
tabs.addTab(self._effects_tab(), "Keyboard effects")
tabs.addTab(self._perkey_tab(), "Per-key editor")
tabs.addTab(self._lightbar_tab(), "Lightbar")
self.tabs = tabs
self.lay.addWidget(tabs)
self.lay.addStretch(1)
self._preview_timer = QTimer(self)
self._preview_timer.setSingleShot(True)
self._preview_timer.setInterval(120)
self._preview_timer.timeout.connect(self._send_preview)
self._loaded_frame = None
# ---- effects
def _effects_tab(self):
w = QWidget()
lay = QHBoxLayout(w)
lay.setContentsMargins(0, 12, 0, 0)
lay.setSpacing(16)
left = Card("Effect")
self.effects = QListWidget()
self.effects.setMinimumWidth(220)
self.effects.itemClicked.connect(self._effect_clicked)
left.body.addWidget(self.effects)
lay.addWidget(left, 1)
right = Card("Settings")
right.body.addWidget(QLabel("Colour"))
self.color = ColorRow()
self.color.colorChosen.connect(lambda c: self.win.backend.run("set_keyboard", color=c.name()))
right.body.addWidget(self.color)
right.body.addSpacing(8)
right.body.addWidget(QLabel("Brightness"))
self.bright = _slider(0, 255, 200)
self.bright.sliderReleased.connect(lambda: self.win.backend.run("set_keyboard", brightness=self.bright.value()))
right.body.addWidget(self.bright)
right.body.addSpacing(8)
h = QHBoxLayout()
h.addWidget(QLabel("Effect variant (raw ID, 0 = default)"))
self.variant = QSpinBox()
self.variant.setRange(0, 255)
self.variant.setDisplayIntegerBase(16)
self.variant.setPrefix("0x")
self.variant.editingFinished.connect(lambda: self.win.backend.run("set_keyboard", effect_id=self.variant.value()))
h.addWidget(self.variant)
h.addStretch(1)
right.body.addLayout(h)
right.body.addWidget(_muted("Many effects have hidden variants with other speeds or directions "
"(e.g. 0x07 for ripple, 0x14 for explosion)."))
right.body.addSpacing(8)
right.body.addWidget(QLabel("Presets"))
pr = QHBoxLayout()
self.presets = QComboBox()
self.presets.setMinimumWidth(180)
pr.addWidget(self.presets, 1)
for text, fn in (("Load", self._load_preset), ("Save as…", self._save_preset), ("Delete", self._delete_preset)):
b = QPushButton(text)
b.clicked.connect(fn)
pr.addWidget(b)
right.body.addLayout(pr)
right.body.addStretch(1)
lay.addWidget(right, 2)
return w
def _effect_clicked(self, item):
self.win.backend.run("set_keyboard", effect=item.data(Qt.UserRole), effect_id=0)
def _save_preset(self):
name, ok = QInputDialog.getText(self, "Save preset", "Name:")
if ok and name.strip():
self.win.backend.run("save_preset", name=name.strip())
def _load_preset(self):
if self.presets.currentText():
self.win.backend.run("load_preset", name=self.presets.currentText())
def _delete_preset(self):
if self.presets.currentText():
self.win.backend.run("delete_preset", name=self.presets.currentText())
# ---- per key
def _perkey_tab(self):
w = QWidget()
lay = QVBoxLayout(w)
lay.setContentsMargins(0, 12, 0, 0)
lay.setSpacing(12)
card = Card()
tools = QHBoxLayout()
tools.addWidget(QLabel("Brush"))
self.brush = ColorRow()
self.brush.colorChosen.connect(lambda c: setattr(self.painter, "brush", QColor(c)))
tools.addWidget(self.brush, 1)
tools.addWidget(QLabel("Layout"))
self.layout_combo = QComboBox()
for k, v in layout.VARIANTS.items():
self.layout_combo.addItem(v, k)
tools.addWidget(self.layout_combo)
card.body.addLayout(tools)
self.painter = KeyboardPainter()
self.painter.brush = QColor(self.brush.current)
self.layout_combo.setCurrentIndex(max(0, self.layout_combo.findData(self.painter.variant)))
self.layout_combo.currentIndexChanged.connect(lambda: self.painter.set_variant(self.layout_combo.currentData()))
self.painter.painted.connect(self._painted)
self.painter.picked.connect(lambda c: (self.brush.set_color(c), setattr(self.painter, "brush", QColor(c))))
card.body.addWidget(self.painter)
card.body.addWidget(_muted("Left click / drag paints · right click picks a key's colour · "
"changes are previewed live on the keyboard"))
g = QHBoxLayout()
g.addWidget(QLabel("Fill:"))
b = QPushButton("All")
b.clicked.connect(lambda: self.painter.fill(range(128), self.painter.brush))
g.addWidget(b)
for name, cells in layout.GROUPS.items():
b = QPushButton(name)
b.clicked.connect(lambda _=False, c=cells: self.painter.fill(c, self.painter.brush))
g.addWidget(b)
b = QPushButton("Clear")
b.clicked.connect(lambda: self.painter.fill(range(128), QColor(0, 0, 0)))
g.addWidget(b)
g.addStretch(1)
card.body.addLayout(g)
act = QHBoxLayout()
act.addStretch(1)
self.live = QCheckBox("Live preview")
self.live.setChecked(True)
act.addWidget(self.live)
b = QPushButton("Discard")
b.clicked.connect(self._discard)
act.addWidget(b)
b = QPushButton("Apply && save")
b.setObjectName("primary")
b.clicked.connect(self._save_frame)
act.addWidget(b)
card.body.addLayout(act)
lay.addWidget(card)
return w
def _painted(self):
if self.live.isChecked():
self._preview_timer.start()
def _send_preview(self):
self.win.backend.run("preview_keyboard", effect="perkey", frame=self.painter.frame_hex())
def _save_frame(self):
self.win.backend.run("set_keyboard", effect="perkey", frame=self.painter.frame_hex())
def _discard(self):
self._loaded_frame = None
self.win.backend.run("restore")
# ---- lightbar
def _lightbar_tab(self):
w = QWidget()
lay = QVBoxLayout(w)
lay.setContentsMargins(0, 12, 0, 0)
card = Card("Rear lightbar")
self.lb_sync = QCheckBox("Follow the keyboard (colour, brightness and effect)")
self.lb_sync.toggled.connect(lambda v: self.win.backend.run("set_lightbar", sync_keyboard=v))
card.body.addWidget(self.lb_sync)
g = QGridLayout()
g.setVerticalSpacing(12)
self.lb_mode = QComboBox()
self.lb_mode.activated.connect(lambda: self.win.backend.run("set_lightbar", mode=self.lb_mode.currentData()))
g.addWidget(QLabel("Effect"), 0, 0)
g.addWidget(self.lb_mode, 0, 1)
self.lb_color = ColorRow()
self.lb_color.colorChosen.connect(lambda c: self.win.backend.run("set_lightbar", color=c.name()))
g.addWidget(QLabel("Colour"), 1, 0)
g.addWidget(self.lb_color, 1, 1)
self.lb_bright = _slider(0, 255, 200)
self.lb_bright.sliderReleased.connect(lambda: self.win.backend.run("set_lightbar", brightness=self.lb_bright.value()))
g.addWidget(QLabel("Brightness"), 2, 0)
g.addWidget(self.lb_bright, 2, 1)
self.lb_speed = _slider(1, 10, 5)
self.lb_speed.sliderReleased.connect(lambda: self.win.backend.run("set_lightbar", speed=self.lb_speed.value()))
g.addWidget(QLabel("Speed"), 3, 0)
g.addWidget(self.lb_speed, 3, 1)
g.setColumnStretch(1, 1)
self.lb_controls = QWidget()
self.lb_controls.setLayout(g)
card.body.addWidget(self.lb_controls)
lay.addWidget(card)
lay.addStretch(1)
return w
def update_state(self, st):
k = st["config"]["keyboard"]
effects = st.get("keyboard_effects", {})
if self.effects.count() != len(effects):
self.effects.clear()
for key, (label, _uses_color) in effects.items():
it = QListWidgetItem(label)
it.setData(Qt.UserRole, key)
self.effects.addItem(it)
for i in range(self.effects.count()):
it = self.effects.item(i)
if it.data(Qt.UserRole) == k["effect"] and not it.isSelected():
self.effects.setCurrentItem(it)
self.color.set_color(QColor(k["color"]))
if not self.bright.isSliderDown():
self.bright.setValue(k["brightness"])
if not self.variant.hasFocus():
self.variant.setValue(int(k.get("effect_id") or 0))
names = sorted(st["config"].get("keyboard_presets", {}))
if names != [self.presets.itemText(i) for i in range(self.presets.count())]:
self.presets.clear()
self.presets.addItems(names)
if self._loaded_frame != k.get("frame"):
self._loaded_frame = k.get("frame")
self.painter.load_frame(k.get("frame"))
lb = st["config"]["lightbar"]
modes = st.get("lightbar_modes", {})
if self.lb_mode.count() != len(modes):
self.lb_mode.clear()
for key, label in modes.items():
self.lb_mode.addItem(label, key)
if not self.lb_mode.view().isVisible():
self.lb_mode.setCurrentIndex(max(0, self.lb_mode.findData(lb["mode"])))
self.lb_sync.blockSignals(True)
self.lb_sync.setChecked(bool(lb.get("sync_keyboard")))
self.lb_sync.blockSignals(False)
self.lb_controls.setEnabled(not lb.get("sync_keyboard"))
self.lb_color.set_color(QColor(lb["color"]))
if not self.lb_bright.isSliderDown():
self.lb_bright.setValue(lb["brightness"])
if not self.lb_speed.isSliderDown():
self.lb_speed.setValue(lb["speed"])
class BatteryPage(Page):
def __init__(self, win):
super().__init__(win)
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)
self.details = _muted("")
info.body.addWidget(self.details)
info.body.addStretch(1)
row.addWidget(info, 1)
health = Card("Battery health")
self.limit = QCheckBox("Limit charging to 80 %")
self.limit.clicked.connect(lambda v: self._set("set_battery_limit", v))
health.body.addWidget(self.limit)
health.body.addWidget(_muted("Recommended when the laptop is mostly plugged in – greatly extends battery life."))
health.body.addSpacing(10)
self.calib = QCheckBox("Battery calibration running")
self.calib.clicked.connect(self._calibrate)
health.body.addWidget(self.calib)
health.body.addWidget(_muted("Calibration fully charges, discharges and recharges the battery to re-learn its "
"capacity. Keep the charger connected; it can take several hours."))
health.body.addStretch(1)
row.addWidget(health, 2)
self.lay.addLayout(row)
self.lay.addStretch(1)
self._fw_loaded = False
def _set(self, cmd, v):
self.win.backend.run(cmd, enabled=bool(v))
QTimer.singleShot(800, self.refresh)
def _calibrate(self, v):
if v and QMessageBox.question(self, "Battery calibration",
"Start battery calibration now? Keep the charger connected.") != QMessageBox.Yes:
self.calib.setChecked(False)
return
self._set("set_battery_calibration", v)
def refresh(self):
self.win.backend.run("get_firmware")
def firmware(self, fw):
for box, key in ((self.limit, "battery_limit"), (self.calib, "battery_calibration")):
v = fw.get(key, {})
box.setEnabled(v.get("value") is not None)
box.setChecked(bool(v.get("value")))
box.setToolTip(v.get("error", ""))
def showEvent(self, e):
super().showEvent(e)
self.refresh()
def update_state(self, st):
b = st["battery"]
if not b.get("present"):
self.cap.setText("No battery")
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"),
"Health: %s %% of design capacity" % b.get("health"),
"Charge cycles: %s" % b.get("cycles")]
self.details.setText("\n".join(lines))
class SystemPage(Page):
def __init__(self, win):
super().__init__(win)
self.lay.addWidget(_title("System", "Firmware options and service status"))
self.fw_card = Card("Firmware options")
self.fw_boxes = {}
self.fw_area = QVBoxLayout()
self.fw_card.body.addLayout(self.fw_area)
self.fw_card.body.addWidget(_muted("Values are stored in the laptop firmware (EC/BIOS)."))
self.lay.addWidget(self.fw_card)
svc = Card("Status")
self.status = _muted("")
svc.body.addWidget(self.status)
h = QHBoxLayout()
b = QPushButton("Re-apply all settings")
b.clicked.connect(lambda: win.backend.run("restore"))
h.addWidget(b)
h.addStretch(1)
svc.body.addLayout(h)
self.lay.addWidget(svc)
about = Card("About")
about.body.addWidget(QLabel("%s %s" % (APP_NAME, __version__)))
about.body.addWidget(_muted("Open-source PredatorSense alternative for the Acer Predator Helios 16 (PH16-71). "
"Protocols based on the work of the Venator and Linuwu-Sense projects. "
"Not affiliated with Acer."))
self.lay.addWidget(about)
self.lay.addStretch(1)
def showEvent(self, e):
super().showEvent(e)
self.win.backend.run("get_firmware")
def firmware(self, fw):
for name, info in fw.items():
if "label" not in info:
continue
if name == "usb_charging":
w = self.fw_boxes.get(name)
if w is None:
row = QHBoxLayout()
row.addWidget(QLabel(info["label"]))
w = QComboBox()
for val, text in ((0, "Off"), (10, "Until battery 10 %"), (20, "Until battery 20 %"), (30, "Until battery 30 %")):
w.addItem(text, val)
w.activated.connect(lambda _=0, w=w: self.win.backend.run("set_firmware", name="usb_charging", value=w.currentData()))
row.addWidget(w)
row.addStretch(1)
self.fw_area.addLayout(row)
self.fw_boxes[name] = w
w.setEnabled(info.get("value") is not None)
if info.get("value") is not None:
w.setCurrentIndex(max(0, w.findData(info["value"])))
w.setToolTip(info.get("error", ""))
continue
box = self.fw_boxes.get(name)
if box is None:
box = QCheckBox(info["label"])
box.clicked.connect(lambda v, n=name: self.win.backend.run("set_firmware", name=n, value=bool(v)))
self.fw_area.addWidget(box)
self.fw_boxes[name] = box
box.setEnabled(info.get("value") is not None)
box.setChecked(bool(info.get("value")))
box.setToolTip(info.get("error", "") or "")
def update_state(self, st):
a = st["available"]
yes = lambda v: "✔" if v else "✘"
lines = ["%s Service predatord %s" % (yes(True), st.get("version")),
"%s RGB keyboard (hidraw)" % yes(a.get("keyboard")),
"%s WMI bridge (kernel module predator_wmi)" % yes(a.get("wmi"))]
for k, v in st.get("errors", {}).items():
lines.append("⚠ %s: %s" % (k, v))
self.status.setText("\n".join(lines))
# ------------------------------------------------------------------ window
class MainWindow(QMainWindow):
def __init__(self, page="home"):
super().__init__()
self.setWindowTitle(APP_NAME)
self.setWindowIcon(QIcon.fromTheme("predator-control"))
self.resize(1240, 820)
self.setMinimumSize(980, 640)
self.state = None
self.backend = Backend()
self.backend.finished.connect(self._done)
root = QWidget()
root.setObjectName("root")
h = QHBoxLayout(root)
h.setContentsMargins(0, 0, 0, 0)
h.setSpacing(0)
side = QFrame()
side.setObjectName("sidebar")
side.setFixedWidth(220)
sl = QVBoxLayout(side)
sl.setContentsMargins(14, 22, 14, 18)
sl.setSpacing(4)
brand = QLabel("PREDATOR")
brand.setObjectName("brand")
sl.addWidget(brand)
sub = QLabel("CONTROL CENTER")
sub.setObjectName("brandSub")
sl.addWidget(sub)
sl.addSpacing(22)
self.stack = QStackedWidget()
self.nav = QButtonGroup(self)
self.pages = {}
classes = {"home": HomePage, "performance": PerformancePage, "fans": FansPage,
"rgb": RgbPage, "battery": BatteryPage, "system": SystemPage}
for key, label in PAGES:
b = QPushButton(label)
b.setObjectName("nav")
b.setCheckable(True)
b.clicked.connect(lambda _=False, k=key: self.show_page(k))
self.nav.addButton(b)
sl.addWidget(b)
pg = classes[key](self)
self.pages[key] = (pg, b)
self.stack.addWidget(pg)
sl.addStretch(1)
self.footer = QLabel("")
self.footer.setObjectName("muted")
self.footer.setWordWrap(True)
sl.addWidget(self.footer)
h.addWidget(side)
right = QVBoxLayout()
right.setContentsMargins(0, 0, 0, 0)
self.banner = QLabel()
self.banner.setObjectName("error")
self.banner.setWordWrap(True)
self.banner.setStyleSheet("padding:10px 32px;background:#2a0f16;")
self.banner.hide()
right.addWidget(self.banner)
right.addWidget(self.stack)
h.addLayout(right, 1)
self.setCentralWidget(root)
self.statusBar().showMessage("Ready")
self.timer = QTimer(self)
self.timer.timeout.connect(self.refresh)
self.timer.start(1500)
self.show_page(page)
self.refresh()
def show_page(self, key):
if key not in self.pages:
key = "home"
pg, b = self.pages[key]
b.setChecked(True)
self.stack.setCurrentWidget(pg)
if self.state:
pg.update_state(self.state)
def refresh(self):
st = read_state()
if not st:
self.banner.setText("The predatord service is not running. Start it with: "
"sudo systemctl enable --now predatord")
self.banner.show()
return
self.banner.hide()
self.state = st
self.stack.currentWidget().update_state(st)
s = st["sensors"]
self.footer.setText("%s\nCPU %s °C · GPU %s" % (
PROFILE_INFO.get(st["profile"], (st["profile"],))[0], s.get("cpu_temp"),
("%s °C" % s["gpu_temp"]) if s.get("gpu_temp") else "off"))
def _done(self, cmd, result, err):
if err:
self.statusBar().showMessage("Error: %s" % err, 8000)
elif cmd == "get_firmware":
self.pages["system"][0].firmware(result)
self.pages["battery"][0].firmware(result)
return
else:
self.statusBar().showMessage("Applied", 2000)
QTimer.singleShot(250, self.refresh)
def main(argv=None):
ap = argparse.ArgumentParser(prog="predator-control")
ap.add_argument("--page", default="home", choices=[p for p, _ in PAGES])
args, qt_args = ap.parse_known_args(argv)
app = QApplication([sys.argv[0]] + qt_args)
app.setApplicationName("predator-control")
app.setApplicationDisplayName(APP_NAME)
app.setDesktopFileName("predator-control")
app.setStyle("Fusion")
app.setStyleSheet(theme.QSS)
# single instance: forward the requested page to a running window
name = "predator-control-%d" % os.getuid()
sock = QLocalSocket()
sock.connectToServer(name)
if sock.waitForConnected(300):
sock.write(args.page.encode())
sock.waitForBytesWritten(300)
sock.disconnectFromServer()
return 0
QLocalServer.removeServer(name)
server = QLocalServer()
server.listen(name)
win = MainWindow(args.page)
def incoming():
c = server.nextPendingConnection()
if c.waitForReadyRead(500):
win.show_page(bytes(c.readAll()).decode().strip())
win.showNormal()
win.raise_()
win.activateWindow()
server.newConnection.connect(incoming)
win.show()
return app.exec()
if __name__ == "__main__":
sys.exit(main())
+79
View File
@@ -0,0 +1,79 @@
"""Colours and the Qt style sheet (dark, PredatorSense-inspired)."""
BG = "#0a0e13"
SIDEBAR = "#0d1219"
SURFACE = "#121a24"
SURFACE2 = "#182230"
BORDER = "#223043"
TEXT = "#e6edf3"
MUTED = "#8593a6"
ACCENT = "#00d4ff"
ACCENT_DIM = "#0a6f8a"
WARN = "#ffb020"
DANGER = "#ff3b5c"
TRACK = "#1c2735"
GRID = "#1b2532"
KEY = "#151d28"
KEY_BORDER = "#2a3a50"
QSS = f"""
* {{ color: {TEXT}; font-size: 10pt; }}
QMainWindow, QWidget#root {{ background: {BG}; }}
QWidget#page {{ background: {BG}; }}
QScrollArea, QScrollArea > QWidget > QWidget {{ background: transparent; border: none; }}
QFrame#sidebar {{ background: {SIDEBAR}; border-right: 1px solid {BORDER}; }}
QLabel#brand {{ font-size: 15pt; font-weight: 700; color: {ACCENT}; letter-spacing: 2px; }}
QLabel#brandSub {{ color: {MUTED}; font-size: 8pt; letter-spacing: 3px; }}
QPushButton#nav {{
text-align: left; padding: 11px 16px; border: none; border-radius: 8px;
background: transparent; color: {MUTED}; font-size: 10.5pt;
}}
QPushButton#nav:hover {{ background: {SURFACE}; color: {TEXT}; }}
QPushButton#nav:checked {{ background: {SURFACE2}; color: {ACCENT}; border-left: 3px solid {ACCENT}; }}
QLabel#pageTitle {{ font-size: 18pt; font-weight: 700; }}
QLabel#pageSub {{ color: {MUTED}; }}
QFrame#card {{ background: {SURFACE}; border: 1px solid {BORDER}; border-radius: 14px; }}
QLabel#cardTitle {{ color: {MUTED}; font-size: 9pt; font-weight: 600; letter-spacing: 1px; text-transform: uppercase; }}
QLabel#big {{ font-size: 20pt; font-weight: 700; }}
QLabel#muted {{ color: {MUTED}; }}
QLabel#error {{ color: {DANGER}; }}
QPushButton {{
background: {SURFACE2}; border: 1px solid {BORDER}; border-radius: 8px; padding: 8px 14px;
}}
QPushButton:hover {{ border-color: {ACCENT_DIM}; }}
QPushButton:pressed {{ background: {BORDER}; }}
QPushButton:checked {{ background: {ACCENT_DIM}; border-color: {ACCENT}; color: white; }}
QPushButton:disabled {{ color: {MUTED}; }}
QPushButton#primary {{ background: {ACCENT}; color: #00131a; font-weight: 700; border: none; }}
QPushButton#primary:hover {{ background: #4de1ff; }}
QPushButton#profile, QPushButton#profileTurbo {{
padding: 16px 10px; border-radius: 12px; font-size: 11pt; font-weight: 600; min-width: 110px;
}}
QPushButton#profile:checked {{ background: {ACCENT_DIM}; border: 2px solid {ACCENT}; }}
QPushButton#profileTurbo:checked {{ background: #6b1022; border: 2px solid {DANGER}; }}
QPushButton#swatch {{ border-radius: 14px; border: 2px solid {BORDER}; padding: 0; }}
QPushButton#swatch:hover {{ border-color: {TEXT}; }}
QComboBox, QSpinBox {{
background: {SURFACE2}; border: 1px solid {BORDER}; border-radius: 8px; padding: 6px 10px; min-height: 20px;
}}
QComboBox QAbstractItemView {{ background: {SURFACE2}; selection-background-color: {ACCENT_DIM}; border: 1px solid {BORDER}; }}
QSlider::groove:horizontal {{ height: 6px; background: {TRACK}; border-radius: 3px; }}
QSlider::sub-page:horizontal {{ background: {ACCENT}; border-radius: 3px; }}
QSlider::handle:horizontal {{
background: {TEXT}; width: 16px; height: 16px; margin: -6px 0; border-radius: 8px;
}}
QCheckBox {{ spacing: 10px; }}
QCheckBox::indicator {{ width: 36px; height: 20px; border-radius: 10px; background: {TRACK}; border: 1px solid {BORDER}; }}
QCheckBox::indicator:checked {{ background: {ACCENT}; border-color: {ACCENT}; }}
QTabWidget::pane {{ border: none; }}
QTabBar::tab {{
background: transparent; color: {MUTED}; padding: 10px 18px; border: none;
border-bottom: 2px solid transparent; font-weight: 600;
}}
QTabBar::tab:selected {{ color: {ACCENT}; border-bottom: 2px solid {ACCENT}; }}
QListWidget {{ background: {SURFACE2}; border: 1px solid {BORDER}; border-radius: 8px; padding: 4px; }}
QListWidget::item {{ padding: 8px; border-radius: 6px; }}
QListWidget::item:selected {{ background: {ACCENT_DIM}; color: white; }}
QStatusBar {{ background: {SIDEBAR}; color: {MUTED}; border-top: 1px solid {BORDER}; }}
QToolTip {{ background: {SURFACE2}; color: {TEXT}; border: 1px solid {BORDER}; }}
"""
+390
View File
@@ -0,0 +1,390 @@
"""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))