8d695227bc
Replace the four headline gauges (GPU temp, GPU load, CPU temp, memory) with HistoryGraph trend tiles: each plots its session history with the current value, window min/max, a dashed warn-threshold line, and a kind-colored line (temp band / usage / accent). QPainter-drawn, no new dependency. Seeing changes over time is more useful than the live-only snapshot. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
447 lines
15 KiB
Python
447 lines
15 KiB
Python
"""Reusable GUI widgets: collapsible card, circular gauge, metric bar/row."""
|
||
|
||
from __future__ import annotations
|
||
|
||
from collections import deque
|
||
|
||
from PySide6.QtCore import QPointF, QRectF, Qt
|
||
from PySide6.QtGui import QColor, QFont, QPainter, QPainterPath, QPen
|
||
from PySide6.QtWidgets import (
|
||
QComboBox,
|
||
QFrame,
|
||
QHBoxLayout,
|
||
QLabel,
|
||
QPushButton,
|
||
QSizePolicy,
|
||
QVBoxLayout,
|
||
QWidget,
|
||
)
|
||
|
||
from ..core.sample import Reading
|
||
from ..render import format_value
|
||
from .theme import (
|
||
ACCENT,
|
||
CRIT,
|
||
GOOD,
|
||
MUTED,
|
||
TEMP_WARN,
|
||
TEXT,
|
||
TRACK,
|
||
USAGE_WARN,
|
||
WARN,
|
||
gauge_color,
|
||
temp_color,
|
||
)
|
||
|
||
_SEV = {
|
||
"critical": ("CRITICAL", CRIT),
|
||
"warning": ("WARNING", WARN),
|
||
"info": ("INFO", MUTED),
|
||
"ok": ("OK", GOOD),
|
||
}
|
||
|
||
|
||
def finding_card(finding, on_install=None, on_apply=None) -> QFrame:
|
||
"""A card for one M4/M6 Finding (severity-colored title, detail, suggested fix).
|
||
|
||
If the finding names an installable catalog component (``finding.action``) and an
|
||
``on_install(component)`` callback is given, an "Install" button is shown — so a
|
||
"tool not installed" finding becomes one click instead of a copy-pasted apt command.
|
||
|
||
If the finding names a runtime tunable (``finding.fix``) and an ``on_apply(fix_id,
|
||
value)`` callback is given, a dropdown of the live options + an Apply button is shown
|
||
(M6 live fixes — D22).
|
||
"""
|
||
label, color = _SEV.get(finding.severity, ("?", MUTED))
|
||
card = QFrame()
|
||
card.setObjectName("Card")
|
||
v = QVBoxLayout(card)
|
||
v.setContentsMargins(16, 12, 16, 12)
|
||
v.setSpacing(4)
|
||
|
||
head = QLabel(f"{label} · {finding.category}: {finding.title}")
|
||
head.setStyleSheet(f"color: {color}; font-weight: 700; background: transparent;")
|
||
head.setWordWrap(True)
|
||
v.addWidget(head)
|
||
|
||
if finding.detail:
|
||
detail = QLabel(finding.detail)
|
||
detail.setObjectName("Muted")
|
||
detail.setWordWrap(True)
|
||
v.addWidget(detail)
|
||
if finding.suggestion:
|
||
suggestion = QLabel(f"→ {finding.suggestion}")
|
||
suggestion.setStyleSheet(f"color: {ACCENT}; background: transparent;")
|
||
suggestion.setWordWrap(True)
|
||
v.addWidget(suggestion)
|
||
|
||
component = _installable_component(finding) if on_install else None
|
||
if component is not None:
|
||
row = QHBoxLayout()
|
||
row.addStretch(1)
|
||
btn = QPushButton(f"Install {component.name}")
|
||
btn.setObjectName("ActionButton")
|
||
btn.setCursor(Qt.CursorShape.PointingHandCursor)
|
||
btn.clicked.connect(lambda: on_install(component))
|
||
row.addWidget(btn)
|
||
v.addLayout(row)
|
||
|
||
tunable = _tunable(finding) if on_apply else None
|
||
if tunable is not None and tunable.options:
|
||
row = QHBoxLayout()
|
||
name = QLabel(f"{tunable.label}:")
|
||
name.setObjectName("Muted")
|
||
combo = QComboBox()
|
||
combo.addItems(tunable.options)
|
||
if tunable.current in tunable.options:
|
||
combo.setCurrentText(tunable.current)
|
||
combo.setCursor(Qt.CursorShape.PointingHandCursor)
|
||
apply_btn = QPushButton("Apply")
|
||
apply_btn.setObjectName("ActionButton")
|
||
apply_btn.setCursor(Qt.CursorShape.PointingHandCursor)
|
||
apply_btn.clicked.connect(lambda: on_apply(tunable.id, combo.currentText()))
|
||
row.addWidget(name)
|
||
row.addWidget(combo, 1)
|
||
row.addWidget(apply_btn)
|
||
v.addLayout(row)
|
||
if tunable.note:
|
||
note = QLabel(tunable.note)
|
||
note.setObjectName("Muted")
|
||
v.addWidget(note)
|
||
return card
|
||
|
||
|
||
def _tunable(finding):
|
||
"""The runtime tunable a finding can apply, if any."""
|
||
fix = getattr(finding, "fix", "")
|
||
if not fix:
|
||
return None
|
||
from ..core import fixes
|
||
|
||
return fixes.get_tunable(fix)
|
||
|
||
|
||
def _installable_component(finding):
|
||
"""The catalog component a finding offers to install, if any and if apt is usable."""
|
||
action = getattr(finding, "action", "")
|
||
if not action:
|
||
return None
|
||
from ..core import catalog, sysenv
|
||
|
||
if sysenv.package_manager() != "apt":
|
||
return None # apt-only (D15) — no one-click install elsewhere
|
||
return catalog.by_id(action)
|
||
|
||
|
||
class Card(QFrame):
|
||
"""A titled panel whose body collapses when the header is clicked."""
|
||
|
||
def __init__(self, title: str, subtitle: str | None = None) -> None:
|
||
super().__init__()
|
||
self.setObjectName("Card")
|
||
self._title = title
|
||
self._open = True
|
||
|
||
outer = QVBoxLayout(self)
|
||
outer.setContentsMargins(16, 12, 16, 12)
|
||
outer.setSpacing(8)
|
||
|
||
self._header = QPushButton()
|
||
self._header.setObjectName("CardHeader")
|
||
self._header.setFlat(True)
|
||
self._header.setCheckable(True)
|
||
self._header.setChecked(True)
|
||
self._header.setCursor(Qt.CursorShape.PointingHandCursor)
|
||
self._header.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
|
||
self._header.clicked.connect(self._toggle)
|
||
outer.addWidget(self._header)
|
||
|
||
self._body = QWidget()
|
||
self._body_layout = QVBoxLayout(self._body)
|
||
self._body_layout.setContentsMargins(0, 2, 0, 0)
|
||
self._body_layout.setSpacing(7)
|
||
if subtitle:
|
||
sub = QLabel(subtitle)
|
||
sub.setObjectName("Muted")
|
||
self._body_layout.addWidget(sub)
|
||
outer.addWidget(self._body)
|
||
|
||
self._refresh_header()
|
||
|
||
def add(self, widget: QWidget) -> None:
|
||
self._body_layout.addWidget(widget)
|
||
|
||
def _toggle(self) -> None:
|
||
self._open = self._header.isChecked()
|
||
self._body.setVisible(self._open)
|
||
self._refresh_header()
|
||
|
||
def _refresh_header(self) -> None:
|
||
chevron = "▾" if self._open else "▸"
|
||
self._header.setText(f"{chevron} {self._title}")
|
||
|
||
|
||
class StatGauge(QWidget):
|
||
"""A circular gauge for a single headline metric."""
|
||
|
||
def __init__(self, title: str, unit: str = "", vmax: float = 100.0, kind: str = "accent") -> None:
|
||
super().__init__()
|
||
self._title = title
|
||
self._unit = unit
|
||
self._max = vmax
|
||
self._kind = kind # "temp" | "usage" | "accent"
|
||
self._value: float | None = None
|
||
self.setMinimumSize(118, 140)
|
||
|
||
def set_value(self, value: float | None) -> None:
|
||
self._value = value
|
||
self.update()
|
||
|
||
def paintEvent(self, event) -> None: # noqa: N802 (Qt override)
|
||
p = QPainter(self)
|
||
p.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||
w, h = self.width(), self.height()
|
||
title_h = 20
|
||
side = min(w, h - title_h)
|
||
if side < 20:
|
||
p.end()
|
||
return
|
||
|
||
pen_w = max(7.0, side * 0.11)
|
||
inset = pen_w / 2 + 1
|
||
x = (w - side) / 2.0
|
||
arc = QRectF(x + inset, inset, side - 2 * inset, side - 2 * inset)
|
||
start = 225 * 16
|
||
full = -270 * 16
|
||
color = QColor(gauge_color(self._kind, self._value))
|
||
|
||
pen = QPen(QColor(TRACK))
|
||
pen.setWidthF(pen_w)
|
||
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
|
||
p.setPen(pen)
|
||
p.drawArc(arc, start, full)
|
||
|
||
frac = 0.0 if self._value is None else max(0.0, min(1.0, self._value / self._max))
|
||
if frac > 0:
|
||
pen.setColor(color)
|
||
p.setPen(pen)
|
||
p.drawArc(arc, start, int(full * frac))
|
||
|
||
# Value (colored to match the arc)
|
||
value_text = "—" if self._value is None else f"{self._value:.0f}"
|
||
fval = QFont()
|
||
fval.setPointSizeF(max(12.0, side * 0.21))
|
||
fval.setBold(True)
|
||
p.setFont(fval)
|
||
p.setPen(color if self._value is not None else QColor(MUTED))
|
||
p.drawText(QRectF(x, side * 0.20, side, side * 0.42), Qt.AlignmentFlag.AlignCenter, value_text)
|
||
|
||
# Unit
|
||
if self._unit and self._value is not None:
|
||
funit = QFont()
|
||
funit.setPointSizeF(max(8.0, side * 0.095))
|
||
p.setFont(funit)
|
||
p.setPen(QColor(MUTED))
|
||
p.drawText(
|
||
QRectF(x, side * 0.56, side, side * 0.18),
|
||
Qt.AlignmentFlag.AlignHCenter | Qt.AlignmentFlag.AlignTop,
|
||
self._unit,
|
||
)
|
||
|
||
# Title under the arc
|
||
ftitle = QFont()
|
||
ftitle.setPointSizeF(10.0)
|
||
ftitle.setBold(True)
|
||
p.setFont(ftitle)
|
||
p.setPen(QColor(MUTED))
|
||
p.drawText(
|
||
QRectF(0, side - 2, w, title_h + 4),
|
||
Qt.AlignmentFlag.AlignHCenter | Qt.AlignmentFlag.AlignVCenter,
|
||
self._title,
|
||
)
|
||
p.end()
|
||
|
||
|
||
class HistoryGraph(QWidget):
|
||
"""A headline metric as a trend: current value + window min/max + a history line.
|
||
|
||
Replaces the at-a-glance gauge with changes-over-time. `kind` drives the color
|
||
(temp band / usage / accent), matching StatGauge so the dashboard stays consistent.
|
||
"""
|
||
|
||
def __init__(self, title: str, unit: str = "", vmin: float = 0.0, vmax: float = 100.0,
|
||
kind: str = "accent", history: int = 180) -> None:
|
||
super().__init__()
|
||
self._title = title
|
||
self._unit = unit
|
||
self._min = vmin
|
||
self._max = vmax
|
||
self._kind = kind # "temp" | "usage" | "accent"
|
||
self._values: deque[float | None] = deque(maxlen=history)
|
||
self.setMinimumSize(160, 132)
|
||
|
||
def add_value(self, value: float | None) -> None:
|
||
self._values.append(value)
|
||
self.update()
|
||
|
||
def _fmt(self, value: float | None) -> str:
|
||
if value is None:
|
||
return "—"
|
||
if self._unit == "°C":
|
||
return f"{value:.0f}°"
|
||
if self._unit == "%":
|
||
return f"{value:.0f}%"
|
||
return f"{value:.0f}{self._unit}"
|
||
|
||
def paintEvent(self, event) -> None: # noqa: N802 (Qt override)
|
||
p = QPainter(self)
|
||
p.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||
w, h = self.width(), self.height()
|
||
pad = 10.0
|
||
present = [v for v in self._values if v is not None]
|
||
current = next((v for v in reversed(self._values) if v is not None), None)
|
||
color = QColor(gauge_color(self._kind, current))
|
||
|
||
ftitle = QFont()
|
||
ftitle.setPointSizeF(10.0)
|
||
ftitle.setBold(True)
|
||
p.setFont(ftitle)
|
||
p.setPen(QColor(MUTED))
|
||
p.drawText(QRectF(pad, 6, w - 2 * pad, 18),
|
||
Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter, self._title)
|
||
|
||
fval = QFont()
|
||
fval.setPointSizeF(21.0)
|
||
fval.setBold(True)
|
||
p.setFont(fval)
|
||
p.setPen(color if current is not None else QColor(MUTED))
|
||
p.drawText(QRectF(pad, 2, w - 2 * pad, 28),
|
||
Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignTop, self._fmt(current))
|
||
|
||
if present:
|
||
fsm = QFont()
|
||
fsm.setPointSizeF(8.5)
|
||
p.setFont(fsm)
|
||
p.setPen(QColor(MUTED))
|
||
p.drawText(QRectF(pad, 27, w - 2 * pad, 14), Qt.AlignmentFlag.AlignLeft,
|
||
f"min {self._fmt(min(present))} max {self._fmt(max(present))}")
|
||
|
||
g_top, g_bot = 48.0, h - pad
|
||
g_left, g_right = pad, w - pad
|
||
span = self._max - self._min
|
||
if g_bot - g_top < 12 or g_right - g_left < 12 or span <= 0:
|
||
p.end()
|
||
return
|
||
|
||
def y_of(v: float) -> float:
|
||
frac = (max(self._min, min(self._max, v)) - self._min) / span
|
||
return g_bot - frac * (g_bot - g_top)
|
||
|
||
warn = TEMP_WARN if self._kind == "temp" else (USAGE_WARN if self._kind == "usage" else None)
|
||
if warn is not None and self._min <= warn <= self._max:
|
||
pen = QPen(QColor(TRACK))
|
||
pen.setWidthF(1.0)
|
||
pen.setStyle(Qt.PenStyle.DashLine)
|
||
p.setPen(pen)
|
||
yw = y_of(warn)
|
||
p.drawLine(QPointF(g_left, yw), QPointF(g_right, yw))
|
||
|
||
maxlen = self._values.maxlen or 1
|
||
step = (g_right - g_left) / max(1, maxlen - 1)
|
||
n = len(self._values)
|
||
# Build the line newest-at-right; break it where readings are missing.
|
||
path = QPainterPath()
|
||
drawing = False
|
||
for i, v in enumerate(self._values):
|
||
if v is None:
|
||
drawing = False
|
||
continue
|
||
x = g_right - (n - 1 - i) * step
|
||
y = y_of(v)
|
||
if drawing:
|
||
path.lineTo(x, y)
|
||
else:
|
||
path.moveTo(x, y)
|
||
drawing = True
|
||
if not path.isEmpty():
|
||
pen = QPen(color)
|
||
pen.setWidthF(2.0)
|
||
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
|
||
pen.setJoinStyle(Qt.PenJoinStyle.RoundJoin)
|
||
p.setPen(pen)
|
||
p.drawPath(path)
|
||
p.end()
|
||
|
||
|
||
class MetricBar(QWidget):
|
||
"""A label + value with a thin progress bar (for 0–100% metrics)."""
|
||
|
||
def __init__(self, name: str, mode: str = "load") -> None:
|
||
super().__init__()
|
||
self._name = name
|
||
self._mode = mode # "load" (accent) | "usage" (threshold-colored)
|
||
self._text = "N/A"
|
||
self._frac = 0.0
|
||
self._color = MUTED
|
||
self.setMinimumHeight(36)
|
||
|
||
def set_reading(self, r: Reading) -> None:
|
||
self._text = format_value(r)
|
||
if r.value is None:
|
||
self._frac = 0.0
|
||
self._color = MUTED
|
||
else:
|
||
self._frac = max(0.0, min(1.0, r.value / 100.0))
|
||
self._color = gauge_color("usage" if self._mode == "usage" else "accent", r.value)
|
||
self.update()
|
||
|
||
def paintEvent(self, event) -> None: # noqa: N802
|
||
p = QPainter(self)
|
||
p.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||
w = self.width()
|
||
|
||
fname = QFont()
|
||
fname.setPointSizeF(10.5)
|
||
p.setFont(fname)
|
||
p.setPen(QColor(MUTED))
|
||
p.drawText(QRectF(0, 0, w * 0.6, 18), Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter, self._name)
|
||
|
||
fval = QFont()
|
||
fval.setPointSizeF(11.0)
|
||
fval.setBold(True)
|
||
p.setFont(fval)
|
||
p.setPen(QColor(self._color))
|
||
p.drawText(QRectF(w * 0.4, 0, w * 0.6, 18), Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter, self._text)
|
||
|
||
bar_y, bar_h = 23.0, 6.0
|
||
p.setPen(Qt.PenStyle.NoPen)
|
||
p.setBrush(QColor(TRACK))
|
||
p.drawRoundedRect(QRectF(0, bar_y, w, bar_h), 3, 3)
|
||
if self._frac > 0:
|
||
p.setBrush(QColor(self._color))
|
||
p.drawRoundedRect(QRectF(0, bar_y, w * self._frac, bar_h), 3, 3)
|
||
p.end()
|
||
|
||
|
||
class MetricRow(QWidget):
|
||
"""A simple label/value row; values in °C are colored by temperature."""
|
||
|
||
def __init__(self, name: str) -> None:
|
||
super().__init__()
|
||
layout = QHBoxLayout(self)
|
||
layout.setContentsMargins(0, 0, 0, 0)
|
||
name_label = QLabel(name)
|
||
name_label.setObjectName("Muted")
|
||
self._value = QLabel("N/A")
|
||
self._value.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
|
||
layout.addWidget(name_label)
|
||
layout.addStretch(1)
|
||
layout.addWidget(self._value)
|
||
|
||
def set_reading(self, r: Reading) -> None:
|
||
self._value.setText(format_value(r))
|
||
if r.unit == "°C" and r.value is not None:
|
||
self._value.setStyleSheet(f"color: {temp_color(r.value)}; font-weight: 600; background: transparent;")
|
||
else:
|
||
self._value.setStyleSheet(f"color: {TEXT}; font-weight: 600; background: transparent;")
|