"""Custom widgets: gauges, fan curve editor, per-key keyboard painter, colour swatches.""" from PySide6.QtCore import QPointF, QRectF, Qt, Signal from PySide6.QtGui import QColor, QFont, QPainter, QPainterPath, QPen from PySide6.QtWidgets import (QColorDialog, QFrame, QHBoxLayout, QLabel, QPushButton, QSizePolicy, QVBoxLayout, QWidget) from .. import layout from . import theme class Card(QFrame): """Rounded panel with an optional title.""" def __init__(self, title=None, parent=None): super().__init__(parent) self.setObjectName("card") self.body = QVBoxLayout(self) self.body.setContentsMargins(18, 16, 18, 16) self.body.setSpacing(10) if title: t = QLabel(title) t.setObjectName("cardTitle") self.body.addWidget(t) class Gauge(QWidget): """Circular gauge (270° arc).""" def __init__(self, title, unit, maximum=100, warn=None, parent=None): super().__init__(parent) self.title, self.unit, self.maximum, self.warn = title, unit, maximum, warn self.value = None self.sub = "" self.setMinimumSize(150, 150) self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) def set_value(self, value, sub=""): self.value, self.sub = value, sub self.update() def paintEvent(self, _): p = QPainter(self) p.setRenderHint(QPainter.Antialiasing) side = min(self.width(), self.height()) - 16 r = QRectF((self.width() - side) / 2, (self.height() - side) / 2, side, side) pen = QPen(QColor(theme.TRACK), max(8, side / 14), Qt.SolidLine, Qt.RoundCap) p.setPen(pen) p.drawArc(r, -45 * 16, 270 * 16) if self.value is not None: frac = max(0.0, min(1.0, self.value / self.maximum)) col = QColor(theme.ACCENT) if self.warn and self.value >= self.warn: col = QColor(theme.DANGER) elif self.warn and self.value >= self.warn * 0.85: col = QColor(theme.WARN) pen.setColor(col) p.setPen(pen) p.drawArc(r, 225 * 16, int(-270 * 16 * frac)) p.setPen(QColor(theme.TEXT)) f = QFont(self.font()) f.setPointSizeF(max(10, side / 7)) f.setBold(True) p.setFont(f) txt = "–" if self.value is None else ("%d" % round(self.value)) p.drawText(r.adjusted(0, -side * 0.08, 0, -side * 0.08), Qt.AlignCenter, txt) f.setPointSizeF(max(7, side / 16)) f.setBold(False) p.setFont(f) p.setPen(QColor(theme.MUTED)) p.drawText(r.adjusted(0, side * 0.22, 0, 0), Qt.AlignHCenter | Qt.AlignVCenter, self.unit) p.setPen(QColor(theme.TEXT)) p.drawText(QRectF(r.left(), r.bottom() - side * 0.2, r.width(), side * 0.2), Qt.AlignHCenter | Qt.AlignVCenter, self.title) if self.sub: p.setPen(QColor(theme.MUTED)) p.drawText(QRectF(r.left(), r.bottom() - side * 0.05, r.width(), side * 0.2), Qt.AlignHCenter | Qt.AlignTop, self.sub) class Bar(QWidget): """Thin labelled progress bar.""" def __init__(self, title, parent=None): super().__init__(parent) self.title = title self.value, self.text = 0, "" self.setMinimumHeight(38) def set_value(self, frac, text): self.value, self.text = max(0.0, min(1.0, frac or 0)), text self.update() def paintEvent(self, _): p = QPainter(self) p.setRenderHint(QPainter.Antialiasing) p.setPen(QColor(theme.MUTED)) p.drawText(QRectF(0, 0, self.width(), 18), Qt.AlignLeft | Qt.AlignVCenter, self.title) p.setPen(QColor(theme.TEXT)) p.drawText(QRectF(0, 0, self.width(), 18), Qt.AlignRight | Qt.AlignVCenter, self.text) track = QRectF(0, 24, self.width(), 8) p.setPen(Qt.NoPen) p.setBrush(QColor(theme.TRACK)) p.drawRoundedRect(track, 4, 4) p.setBrush(QColor(theme.ACCENT)) p.drawRoundedRect(QRectF(0, 24, self.width() * self.value, 8), 4, 4) class CurveEditor(QWidget): """Drag-and-drop fan curve: temperature (°C) -> fan speed (%).""" changed = Signal(list) T_MIN, T_MAX = 30, 100 def __init__(self, title, parent=None): super().__init__(parent) self.title = title self.points = [[45, 0], [60, 35], [72, 55], [82, 75], [90, 100]] self.current_temp = None self._drag = None self.setMinimumHeight(220) self.setMouseTracking(True) def set_points(self, pts): if self._drag is None: self.points = sorted([list(p) for p in pts]) self.update() def set_current(self, temp): self.current_temp = temp self.update() def _plot(self): return QRectF(44, 26, self.width() - 60, self.height() - 56) def _to_px(self, t, s): r = self._plot() return QPointF(r.left() + (t - self.T_MIN) / (self.T_MAX - self.T_MIN) * r.width(), r.bottom() - s / 100 * r.height()) def _from_px(self, pos): r = self._plot() t = self.T_MIN + (pos.x() - r.left()) / r.width() * (self.T_MAX - self.T_MIN) s = (r.bottom() - pos.y()) / r.height() * 100 return t, s def paintEvent(self, _): p = QPainter(self) p.setRenderHint(QPainter.Antialiasing) r = self._plot() p.setPen(QColor(theme.TEXT)) p.drawText(QRectF(0, 0, self.width(), 20), Qt.AlignLeft | Qt.AlignVCenter, self.title) grid = QPen(QColor(theme.GRID), 1) p.setPen(grid) for s in range(0, 101, 25): a = self._to_px(self.T_MIN, s) p.drawLine(QPointF(r.left(), a.y()), QPointF(r.right(), a.y())) p.setPen(QColor(theme.MUTED)) p.drawText(QRectF(0, a.y() - 8, 38, 16), Qt.AlignRight | Qt.AlignVCenter, "%d%%" % s) p.setPen(grid) for t in range(self.T_MIN, self.T_MAX + 1, 10): a = self._to_px(t, 0) p.drawLine(QPointF(a.x(), r.top()), QPointF(a.x(), r.bottom())) p.setPen(QColor(theme.MUTED)) p.drawText(QRectF(a.x() - 20, r.bottom() + 4, 40, 16), Qt.AlignCenter, "%d°" % t) p.setPen(grid) if self.current_temp: a = self._to_px(max(self.T_MIN, min(self.T_MAX, self.current_temp)), 0) p.setPen(QPen(QColor(theme.WARN), 1, Qt.DashLine)) p.drawLine(QPointF(a.x(), r.top()), QPointF(a.x(), r.bottom())) pts = sorted(self.points) path = QPainterPath(self._to_px(self.T_MIN, pts[0][1])) for t, s in pts: path.lineTo(self._to_px(t, s)) path.lineTo(self._to_px(self.T_MAX, pts[-1][1])) fill = QPainterPath(path) fill.lineTo(self._to_px(self.T_MAX, 0)) fill.lineTo(self._to_px(self.T_MIN, 0)) c = QColor(theme.ACCENT) c.setAlpha(40) p.fillPath(fill, c) p.setPen(QPen(QColor(theme.ACCENT), 2.5)) p.drawPath(path) p.setBrush(QColor(theme.BG)) for i, (t, s) in enumerate(pts): p.setPen(QPen(QColor(theme.TEXT if i == self._drag else theme.ACCENT), 2.5)) p.drawEllipse(self._to_px(t, s), 6, 6) if i == self._drag: p.drawText(self._to_px(t, s) + QPointF(10, -10), "%d° → %d%%" % (t, s)) def mousePressEvent(self, e): for i, (t, s) in enumerate(sorted(self.points)): d = self._to_px(t, s) - e.position() if abs(d.x()) < 12 and abs(d.y()) < 12: self.points = sorted(self.points) self._drag = i self.update() return def mouseMoveEvent(self, e): if self._drag is None: return t, s = self._from_px(e.position()) i = self._drag lo = self.points[i - 1][0] + 1 if i > 0 else self.T_MIN hi = self.points[i + 1][0] - 1 if i < len(self.points) - 1 else self.T_MAX self.points[i] = [int(max(lo, min(hi, round(t)))), int(max(0, min(100, round(s / 5) * 5)))] self.update() def mouseReleaseEvent(self, _): if self._drag is not None: self._drag = None self.update() self.changed.emit([list(p) for p in self.points]) class KeyboardPainter(QWidget): """Draws the keyboard and lets the user paint keys with the current colour.""" painted = Signal() picked = Signal(QColor) def __init__(self, parent=None): super().__init__(parent) self.variant = layout.default_variant() self.keys = layout.keys(self.variant) self.colors = {} # cell -> QColor self.brush = QColor(theme.ACCENT) self._painting = False self._hover = None self.setMouseTracking(True) self.setMinimumHeight(260) self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred) def set_variant(self, variant): self.variant = variant self.keys = layout.keys(variant) self.update() def heightForWidth(self, w): kw, kh = layout.size(self.keys) return int(w * kh / kw) def hasHeightForWidth(self): return True def _scale(self): kw, kh = layout.size(self.keys) s = min((self.width() - 8) / kw, (self.height() - 8) / kh) ox = (self.width() - kw * s) / 2 oy = (self.height() - kh * s) / 2 return s, ox, oy def _rect(self, k, s, ox, oy): _l, _c, x, y, w, h = k g = s * 0.08 return QRectF(ox + x * s + g / 2, oy + y * s + g / 2, w * s - g, h * s - g) def _key_at(self, pos): s, ox, oy = self._scale() for k in self.keys: if self._rect(k, s, ox, oy).contains(pos): return k return None def paintEvent(self, _): p = QPainter(self) p.setRenderHint(QPainter.Antialiasing) s, ox, oy = self._scale() f = QFont(self.font()) for k in self.keys: label, cell = k[0], k[1] r = self._rect(k, s, ox, oy) col = self.colors.get(cell, QColor(0, 0, 0)) base = QColor(theme.KEY) p.setPen(QPen(QColor(theme.TEXT) if k is self._hover else QColor(theme.KEY_BORDER), 1.5)) p.setBrush(base) p.drawRoundedRect(r, s * 0.12, s * 0.12) # glow of the key colour glow = QColor(col) glow.setAlpha(210 if (col.red() + col.green() + col.blue()) > 0 else 0) p.setPen(Qt.NoPen) p.setBrush(glow) p.drawRoundedRect(r.adjusted(3, 3, -3, -3), s * 0.1, s * 0.1) lum = 0.299 * col.red() + 0.587 * col.green() + 0.114 * col.blue() p.setPen(QColor("#0b0f14") if lum > 150 else QColor(theme.TEXT)) f.setPointSizeF(max(6, s * (0.22 if len(label) <= 2 else 0.16))) p.setFont(f) p.drawText(r, Qt.AlignCenter, label) def _paint_at(self, pos, button): k = self._key_at(pos) if not k: return if button == Qt.RightButton: self.picked.emit(QColor(self.colors.get(k[1], QColor(0, 0, 0)))) return if self.colors.get(k[1]) != self.brush: self.colors[k[1]] = QColor(self.brush) self.update() self.painted.emit() def mousePressEvent(self, e): self._painting = e.button() == Qt.LeftButton self._paint_at(e.position(), e.button()) def mouseMoveEvent(self, e): k = self._key_at(e.position()) if k is not self._hover: self._hover = k self.update() if self._painting: self._paint_at(e.position(), Qt.LeftButton) def mouseReleaseEvent(self, _): self._painting = False def leaveEvent(self, _): self._hover = None self.update() def fill(self, cells, color): for c in cells: self.colors[c] = QColor(color) self.update() self.painted.emit() def frame_hex(self): out = bytearray(128 * 3) for c, col in self.colors.items(): if 0 <= c < 128: out[c * 3:c * 3 + 3] = bytes((col.red(), col.green(), col.blue())) return out.hex() def load_frame(self, hexstr): self.colors = {} try: data = bytes.fromhex(hexstr or "") except ValueError: data = b"" for c in range(min(128, len(data) // 3)): self.colors[c] = QColor(data[c * 3], data[c * 3 + 1], data[c * 3 + 2]) self.update() PRESET_COLORS = ["#00d4ff", "#0057ff", "#7a00ff", "#ff00c8", "#ff0033", "#ff6a00", "#ffd000", "#3cff00", "#00ff9c", "#ffffff"] class ColorRow(QWidget): """Row of colour swatches plus a 'custom colour' button.""" colorChosen = Signal(QColor) def __init__(self, parent=None): super().__init__(parent) lay = QHBoxLayout(self) lay.setContentsMargins(0, 0, 0, 0) lay.setSpacing(6) self.current = QColor(PRESET_COLORS[0]) for c in PRESET_COLORS: b = QPushButton() b.setObjectName("swatch") b.setFixedSize(28, 28) b.setStyleSheet("QPushButton#swatch{background:%s;}" % c) b.setToolTip(c) b.clicked.connect(lambda _=False, c=c: self._choose(QColor(c))) lay.addWidget(b) self.custom = QPushButton("Custom…") self.custom.clicked.connect(self._dialog) lay.addWidget(self.custom) self.preview = QLabel() self.preview.setFixedSize(46, 28) lay.addWidget(self.preview) lay.addStretch(1) self.set_color(self.current) def _dialog(self): c = QColorDialog.getColor(self.current, self, "Choose colour") if c.isValid(): self._choose(c) def _choose(self, c): self.set_color(c) self.colorChosen.emit(c) def set_color(self, c): self.current = QColor(c) self.preview.setStyleSheet("background:%s;border-radius:6px;border:1px solid %s;" % (self.current.name(), theme.BORDER)) def format_minutes(m): if m is None: return "" return "%d h %02d min" % divmod(m, 60) if m >= 60 else "%d min" % m class ChargeIndicator(QWidget): """Battery graphic with fill level, charge bolt, wattage and a power history sparkline.""" HISTORY = 80 # samples (~2 min at 1.5 s) def __init__(self, compact=False, parent=None): super().__init__(parent) self.compact = compact self.bat = {} self.history = [] self.setMinimumHeight(54 if compact else 190) self.setMinimumWidth(300 if compact else 420) self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred) def set_battery(self, bat): self.bat = bat or {} w = self.bat.get("power_w") if w is not None: self.history = (self.history + [w])[-self.HISTORY:] self.update() def _state_text(self): b = self.bat st = b.get("status") or "" if st == "Charging": return "Charging" if st == "Discharging": return "On battery" if b.get("ac_online"): return "Plugged in · not charging" return st or "Unknown" def paintEvent(self, _): b = self.bat p = QPainter(self) p.setRenderHint(QPainter.Antialiasing) h = self.height() - 8 if self.compact else min(self.height() * 0.42, 86) bw, bh = h * 2.0, h r = QRectF(4, 4, bw, bh) cap = b.get("capacity") or 0 charging = b.get("charging") accent = QColor(theme.ACCENT if charging else (theme.DANGER if cap <= 15 else theme.TEXT)) # battery body + tip p.setPen(QPen(QColor(theme.MUTED), 2.5)) p.setBrush(Qt.NoBrush) p.drawRoundedRect(r, h * 0.14, h * 0.14) p.setPen(Qt.NoPen) p.setBrush(QColor(theme.MUTED)) p.drawRoundedRect(QRectF(r.right() + 2, r.top() + bh * 0.3, bh * 0.1, bh * 0.4), 2, 2) inner = r.adjusted(5, 5, -5, -5) fill = QColor(accent) fill.setAlpha(170) p.setBrush(fill) p.drawRoundedRect(QRectF(inner.left(), inner.top(), inner.width() * cap / 100, inner.height()), h * 0.08, h * 0.08) if charging: c = inner.center() s = inner.height() * 0.42 bolt = QPainterPath(QPointF(c.x() + s * 0.15, c.y() - s)) bolt.lineTo(c.x() - s * 0.45, c.y() + s * 0.12) bolt.lineTo(c.x() - s * 0.02, c.y() + s * 0.12) bolt.lineTo(c.x() - s * 0.15, c.y() + s) bolt.lineTo(c.x() + s * 0.45, c.y() - s * 0.12) bolt.lineTo(c.x() + s * 0.02, c.y() - s * 0.12) bolt.closeSubpath() p.setBrush(QColor(theme.TEXT)) p.setPen(QPen(QColor(theme.BG), 1.5)) p.drawPath(bolt) else: f = QFont(self.font()) f.setBold(True) f.setPointSizeF(max(8, h * 0.22)) p.setFont(f) p.setPen(QColor(theme.TEXT)) p.drawText(inner, Qt.AlignCenter, "%d %%" % cap) # text block tx = r.right() + bh * 0.1 + 16 f = QFont(self.font()) w = b.get("power_w") f.setBold(True) f.setPointSizeF(max(10, h * (0.36 if self.compact else 0.34))) p.setFont(f) p.setPen(accent if charging else QColor(theme.TEXT)) big = ("%.1f W" % w) if w else ("%d %%" % cap) p.drawText(QRectF(tx, r.top(), self.width() - tx, bh * 0.55), Qt.AlignLeft | Qt.AlignVCenter, big) f.setBold(False) f.setPointSizeF(max(8, h * (0.17 if self.compact else 0.14))) p.setFont(f) p.setPen(QColor(theme.MUTED)) sub = self._state_text() if w: sub = "%d %% · %s" % (cap, sub) t = format_minutes(b.get("minutes_left")) if t: sub += " · %s %s" % (t, "until full" if charging else "left") p.drawText(QRectF(tx, r.top() + bh * 0.5, self.width() - tx, bh * 0.5), Qt.AlignLeft | Qt.AlignVCenter, sub) if self.compact or len(self.history) < 2: return # power history sparkline g = QRectF(4, r.bottom() + 22, self.width() - 8, self.height() - r.bottom() - 30) if g.height() < 30: return p.setPen(QColor(theme.MUTED)) f.setPointSizeF(8) p.setFont(f) top = max(10.0, max(self.history) * 1.15) p.drawText(QRectF(g.left(), g.top() - 18, g.width(), 16), Qt.AlignLeft, "Power over the last %d min" % max(1, round(len(self.history) * 1.5 / 60))) p.drawText(QRectF(g.left(), g.top() - 18, g.width(), 16), Qt.AlignRight, "max %.0f W" % top) p.setPen(QPen(QColor(theme.GRID), 1)) p.drawLine(g.bottomLeft(), g.bottomRight()) n = self.HISTORY - 1 pts = [QPointF(g.left() + g.width() * (n - (len(self.history) - 1 - i)) / n, g.bottom() - g.height() * v / top) for i, v in enumerate(self.history)] path = QPainterPath(pts[0]) for pt in pts[1:]: path.lineTo(pt) area = QPainterPath(path) area.lineTo(pts[-1].x(), g.bottom()) area.lineTo(pts[0].x(), g.bottom()) c = QColor(theme.ACCENT) c.setAlpha(45) p.fillPath(area, c) p.setPen(QPen(QColor(theme.ACCENT), 2)) p.drawPath(path)