Accent gamma now applies live across the whole UI: custom painters subscribe to themeColorChanged via new on_accent_changed helpers, the active node row and active zapret preset get a soft accent fill, the download graph line follows the accent, and the settings card offers 8 preset swatches plus a custom picker. Accent values are validated and normalized; the default moved to constants.DEFAULT_ACCENT_COLOR. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
292 lines
9.3 KiB
Python
292 lines
9.3 KiB
Python
from __future__ import annotations
|
||
|
||
from collections import deque
|
||
from typing import TYPE_CHECKING
|
||
|
||
from PyQt6.QtCore import QPointF, QRectF, Qt, pyqtSignal
|
||
from PyQt6.QtGui import QColor, QFont, QLinearGradient, QPainter, QPainterPath, QPen
|
||
from PyQt6.QtWidgets import QVBoxLayout, QWidget
|
||
|
||
from .fluent_dialog import FluentDialog
|
||
from .theme import (
|
||
graph_bg_color,
|
||
graph_down_color,
|
||
graph_grid_color,
|
||
graph_text_color,
|
||
graph_up_color,
|
||
on_theme_or_accent_changed,
|
||
)
|
||
|
||
if TYPE_CHECKING:
|
||
from PyQt6.QtGui import QPaintEvent, QMouseEvent
|
||
|
||
_MAX_POINTS = 60
|
||
_DETAIL_MAX_POINTS = 300
|
||
|
||
|
||
def _format_speed_short(bps: float) -> str:
|
||
if bps < 1024:
|
||
return f"{int(bps)} B/s"
|
||
if bps < 1024 * 1024:
|
||
return f"{bps / 1024:.0f} KB/s"
|
||
if bps < 1024 * 1024 * 1024:
|
||
return f"{bps / (1024 * 1024):.1f} MB/s"
|
||
return f"{bps / (1024 * 1024 * 1024):.2f} GB/s"
|
||
|
||
|
||
class TrafficGraphWidget(QWidget):
|
||
"""Compact live traffic graph with download/upload lines."""
|
||
|
||
clicked = pyqtSignal()
|
||
|
||
def __init__(self, parent: QWidget | None = None, max_points: int = _MAX_POINTS):
|
||
super().__init__(parent)
|
||
self._max_points = max_points
|
||
self._down_data: deque[float] = deque(maxlen=max_points)
|
||
self._up_data: deque[float] = deque(maxlen=max_points)
|
||
self.setMinimumHeight(80)
|
||
self.setMaximumHeight(120)
|
||
self.setCursor(Qt.CursorShape.PointingHandCursor)
|
||
self.setToolTip("Нажмите для подробного графика")
|
||
on_theme_or_accent_changed(self._on_theme_changed)
|
||
|
||
def _on_theme_changed(self, *args) -> None:
|
||
self.update()
|
||
|
||
def add_point(self, down_bps: float, up_bps: float) -> None:
|
||
self._down_data.append(max(0.0, down_bps))
|
||
self._up_data.append(max(0.0, up_bps))
|
||
self.update()
|
||
|
||
def clear_data(self) -> None:
|
||
self._down_data.clear()
|
||
self._up_data.clear()
|
||
self.update()
|
||
|
||
def mouseReleaseEvent(self, event: QMouseEvent) -> None:
|
||
if event.button() == Qt.MouseButton.LeftButton:
|
||
self.clicked.emit()
|
||
super().mouseReleaseEvent(event)
|
||
|
||
def paintEvent(self, event: QPaintEvent) -> None:
|
||
painter = QPainter(self)
|
||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||
_draw_graph(painter, self.rect(), self._down_data, self._up_data, compact=True)
|
||
painter.end()
|
||
|
||
|
||
class DetailTrafficGraphWidget(QWidget):
|
||
"""Larger traffic graph for the detail dialog."""
|
||
|
||
def __init__(self, parent: QWidget | None = None):
|
||
super().__init__(parent)
|
||
self._down_data: deque[float] = deque(maxlen=_DETAIL_MAX_POINTS)
|
||
self._up_data: deque[float] = deque(maxlen=_DETAIL_MAX_POINTS)
|
||
self.setMinimumHeight(300)
|
||
self.setMinimumWidth(240)
|
||
on_theme_or_accent_changed(self._on_theme_changed)
|
||
|
||
def _on_theme_changed(self, *args) -> None:
|
||
self.update()
|
||
|
||
def set_data(self, down: deque[float], up: deque[float]) -> None:
|
||
self._down_data = deque(down, maxlen=_DETAIL_MAX_POINTS)
|
||
self._up_data = deque(up, maxlen=_DETAIL_MAX_POINTS)
|
||
self.update()
|
||
|
||
def add_point(self, down_bps: float, up_bps: float) -> None:
|
||
self._down_data.append(max(0.0, down_bps))
|
||
self._up_data.append(max(0.0, up_bps))
|
||
self.update()
|
||
|
||
def paintEvent(self, event: QPaintEvent) -> None:
|
||
painter = QPainter(self)
|
||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||
_draw_graph(painter, self.rect(), self._down_data, self._up_data, compact=False)
|
||
painter.end()
|
||
|
||
|
||
class TrafficGraphDialog(FluentDialog):
|
||
"""Dialog showing a detailed traffic graph."""
|
||
|
||
def __init__(self, down_data: deque[float], up_data: deque[float], parent: QWidget | None = None):
|
||
super().__init__(parent)
|
||
self.setWindowTitle("История трафика")
|
||
self.setMinimumSize(700, 420)
|
||
self.resize(800, 480)
|
||
|
||
layout = QVBoxLayout(self)
|
||
layout.setContentsMargins(16, 16, 16, 16)
|
||
|
||
self.graph = DetailTrafficGraphWidget(self)
|
||
self.graph.set_data(down_data, up_data)
|
||
layout.addWidget(self.graph)
|
||
|
||
def add_point(self, down_bps: float, up_bps: float) -> None:
|
||
self.graph.add_point(down_bps, up_bps)
|
||
|
||
|
||
def _draw_graph(
|
||
painter: QPainter,
|
||
rect: QRectF | any,
|
||
down_data: deque[float],
|
||
up_data: deque[float],
|
||
compact: bool,
|
||
) -> None:
|
||
r = QRectF(rect)
|
||
w, h = r.width(), r.height()
|
||
|
||
# Theme tokens are resolved lazily on every paint (they follow the theme).
|
||
color_down = graph_down_color()
|
||
color_up = graph_up_color()
|
||
color_grid = graph_grid_color()
|
||
color_bg = graph_bg_color()
|
||
color_text = graph_text_color()
|
||
|
||
# background
|
||
painter.setPen(Qt.PenStyle.NoPen)
|
||
painter.setBrush(color_bg)
|
||
painter.drawRoundedRect(r, 6, 6)
|
||
|
||
margin_left = 8 if compact else 60
|
||
margin_right = 8
|
||
margin_top = 6 if compact else 24
|
||
margin_bottom = 6 if compact else 24
|
||
graph_x = r.x() + margin_left
|
||
graph_y = r.y() + margin_top
|
||
graph_w = w - margin_left - margin_right
|
||
graph_h = h - margin_top - margin_bottom
|
||
|
||
if graph_w < 10 or graph_h < 10:
|
||
return
|
||
|
||
# scale
|
||
all_vals = list(down_data) + list(up_data)
|
||
max_val = max(all_vals) if all_vals else 1.0
|
||
max_val = max(max_val, 100.0) # minimum scale 100 B/s
|
||
max_val *= 1.15 # headroom
|
||
|
||
# grid lines
|
||
pen = QPen(color_grid)
|
||
pen.setWidthF(0.5)
|
||
painter.setPen(pen)
|
||
grid_lines = 3 if compact else 5
|
||
for i in range(grid_lines + 1):
|
||
y = graph_y + graph_h * i / grid_lines
|
||
painter.drawLine(QPointF(graph_x, y), QPointF(graph_x + graph_w, y))
|
||
|
||
# axis labels (detail mode only)
|
||
if not compact:
|
||
painter.setPen(color_text)
|
||
font = QFont()
|
||
font.setPixelSize(10)
|
||
painter.setFont(font)
|
||
for i in range(grid_lines + 1):
|
||
y = graph_y + graph_h * i / grid_lines
|
||
val = max_val * (1.0 - i / grid_lines)
|
||
label = _format_speed_short(val)
|
||
painter.drawText(QRectF(r.x() + 2, y - 8, margin_left - 6, 16),
|
||
Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter, label)
|
||
|
||
# time labels
|
||
n = max(len(down_data), len(up_data), 1)
|
||
for sec in [0, n // 4, n // 2, 3 * n // 4, n - 1]:
|
||
if sec >= n:
|
||
continue
|
||
x = graph_x + graph_w * sec / max(n - 1, 1)
|
||
ago = n - 1 - sec
|
||
label = f"-{ago}с" if ago > 0 else "сейчас"
|
||
painter.drawText(QRectF(x - 20, graph_y + graph_h + 4, 40, 16),
|
||
Qt.AlignmentFlag.AlignCenter, label)
|
||
|
||
# title
|
||
painter.setPen(color_text)
|
||
font.setPixelSize(12)
|
||
painter.setFont(font)
|
||
painter.drawText(QRectF(graph_x, r.y() + 2, graph_w, 18),
|
||
Qt.AlignmentFlag.AlignCenter, "История трафика")
|
||
|
||
# draw lines
|
||
_draw_line(painter, graph_x, graph_y, graph_w, graph_h, down_data, max_val, color_down, compact)
|
||
_draw_line(painter, graph_x, graph_y, graph_w, graph_h, up_data, max_val, color_up, compact)
|
||
|
||
# legend
|
||
if compact:
|
||
font = QFont()
|
||
font.setPixelSize(9)
|
||
painter.setFont(font)
|
||
lx = graph_x + 4
|
||
ly = graph_y + 2
|
||
|
||
painter.setPen(color_down)
|
||
painter.drawText(QPointF(lx, ly + 9), "↓ Загрузка")
|
||
painter.setPen(color_up)
|
||
painter.drawText(QPointF(lx + 64, ly + 9), "↑ Выгрузка")
|
||
else:
|
||
font = QFont()
|
||
font.setPixelSize(11)
|
||
painter.setFont(font)
|
||
lx = graph_x + graph_w - 120
|
||
ly = r.y() + 4
|
||
|
||
painter.setPen(Qt.PenStyle.NoPen)
|
||
painter.setBrush(color_down)
|
||
painter.drawRoundedRect(QRectF(lx, ly + 2, 10, 10), 2, 2)
|
||
painter.setPen(color_down)
|
||
painter.drawText(QPointF(lx + 14, ly + 11), "Загрузка")
|
||
|
||
painter.setPen(Qt.PenStyle.NoPen)
|
||
painter.setBrush(color_up)
|
||
painter.drawRoundedRect(QRectF(lx + 86, ly + 2, 10, 10), 2, 2)
|
||
painter.setPen(color_up)
|
||
painter.drawText(QPointF(lx + 100, ly + 11), "Выгрузка")
|
||
|
||
|
||
def _draw_line(
|
||
painter: QPainter,
|
||
gx: float, gy: float, gw: float, gh: float,
|
||
data: deque[float],
|
||
max_val: float,
|
||
color: QColor,
|
||
compact: bool,
|
||
) -> None:
|
||
n = len(data)
|
||
if n < 2:
|
||
return
|
||
|
||
points: list[QPointF] = []
|
||
for i, val in enumerate(data):
|
||
x = gx + gw * i / (n - 1)
|
||
y = gy + gh * (1.0 - min(val / max_val, 1.0))
|
||
points.append(QPointF(x, y))
|
||
|
||
# gradient fill
|
||
gradient = QLinearGradient(0, gy, 0, gy + gh)
|
||
fill_color = QColor(color)
|
||
fill_color.setAlpha(50 if compact else 40)
|
||
gradient.setColorAt(0.0, fill_color)
|
||
fill_color.setAlpha(5)
|
||
gradient.setColorAt(1.0, fill_color)
|
||
|
||
fill_path = QPainterPath()
|
||
fill_path.moveTo(QPointF(points[0].x(), gy + gh))
|
||
for pt in points:
|
||
fill_path.lineTo(pt)
|
||
fill_path.lineTo(QPointF(points[-1].x(), gy + gh))
|
||
fill_path.closeSubpath()
|
||
|
||
painter.setPen(Qt.PenStyle.NoPen)
|
||
painter.setBrush(gradient)
|
||
painter.drawPath(fill_path)
|
||
|
||
# line
|
||
pen = QPen(color)
|
||
pen.setWidthF(1.5 if compact else 2.0)
|
||
painter.setPen(pen)
|
||
painter.setBrush(Qt.BrushStyle.NoBrush)
|
||
|
||
line_path = QPainterPath()
|
||
line_path.moveTo(points[0])
|
||
for pt in points[1:]:
|
||
line_path.lineTo(pt)
|
||
painter.drawPath(line_path)
|