"""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))