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:
@@ -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())
|
||||
@@ -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}; }}
|
||||
"""
|
||||
@@ -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))
|
||||
Reference in New Issue
Block a user