zapret-kvn/xray_fluent/ui/traffic_graph.py
loop-uh 3ac6243243 feat: global accent color scheme
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>
2026-08-20 17:09:51 +03:00

292 lines
9.3 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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)