Some checks failed
Windows project source guards / test (push) Failing after 49s
Главный экран переделан по логике VPN-клиентов: сцена «Этот ПК — сфера — сервер» с туннелем, по которому течёт трафик; честный заголовок состояния («Защищено» только для TUN); строка сервера с флагом, сигналом и сменой сервера; плитки режима VPN/Прокси; плитки трафика; топ приложений; маршрутизация простым языком. CPU: единый FrameGate — кадры только пока анимацию видно; неподвижные слои в кэше QImage; кадр перерисовывает полосу туннеля без круга сферы; без трафика сцена замирает. Боковое меню: запоминается явный выбор кнопкой меню (nav_expanded), автосворачивание на узком окне предпочтение не меняет. Страница настроек больше не откатывает поля, которые пишет окно (геометрия, вид таблицы серверов, меню). Выход: ConnectionOrb держал сильную ссылку на окно — цикл владения, который GC не разрывает; окна дожидались финализации и иногда разрушались после QApplication (segfault). Теперь FrameGate хранит только weakref, а main() разрушает все окна верхнего уровня, пока QApplication жив.
301 lines
10 KiB
Python
301 lines
10 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._placeholder = ""
|
||
self.setMinimumHeight(96)
|
||
self.setMaximumHeight(150)
|
||
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 set_placeholder(self, text: str) -> None:
|
||
"""Подсказка поверх пустого графика (пока точек меньше двух)."""
|
||
if text != self._placeholder:
|
||
self._placeholder = text
|
||
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)
|
||
if self._placeholder and len(self._down_data) < 2:
|
||
painter.setPen(graph_text_color())
|
||
painter.drawText(QRectF(self.rect()), Qt.AlignmentFlag.AlignCenter, self._placeholder)
|
||
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 not compact:
|
||
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)
|
||
|
||
line_path = _smooth_path(points)
|
||
fill_path = QPainterPath(line_path)
|
||
fill_path.lineTo(QPointF(points[-1].x(), gy + gh))
|
||
fill_path.lineTo(QPointF(points[0].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)
|
||
|
||
painter.drawPath(line_path)
|
||
|
||
|
||
def _smooth_path(points: list[QPointF]) -> QPainterPath:
|
||
"""Плавная кривая через точки без «выбросов».
|
||
|
||
Контрольные точки кубики стоят на середине отрезка по X и на высоте
|
||
своих концов, поэтому кривая не выходит за значения соседних точек
|
||
(скорость не рисуется отрицательной и не завышает пики).
|
||
"""
|
||
path = QPainterPath(points[0])
|
||
for previous, point in zip(points, points[1:]):
|
||
middle = (previous.x() + point.x()) / 2
|
||
path.cubicTo(QPointF(middle, previous.y()), QPointF(middle, point.y()), point)
|
||
return path
|