zapret-kvn/xray_fluent/ui/connection_orb.py
loop-uh 6ac583e90c
Some checks failed
Windows project source guards / test (push) Failing after 39s
Панель: текст слева, сцена справа, подключение — только кнопкой питания
Кнопка «Запустить/Остановить» дублировала кнопку питания и убрана.
Сфера стала полноценной кнопкой: фокус по Tab, Enter и пробел, рамка
фокуса с клавиатуры, подсказка и имя для диктора с действием.
Карточка подключения ниже: состояние текстом слева, сцена справа, на
узком окне сцена над текстом. Пояснение, что идёт через туннель, ушло
из шапки в подпись переключателя «Системный прокси Windows».
2026-09-26 21:51:54 +03:00

302 lines
13 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.

"""«Сфера подключения» — векторная эмблема состояния с кнопкой питания.
Рисуется QPainter'ом (без SVG-файлов и растров), поэтому чёткая на любом
DPI и сразу подхватывает цвета темы/акцента.
Состояния:
* ``idle`` — спокойное приглушённое кольцо, без анимации;
* ``connecting`` — по кольцу бежит «комета» (дуга с хвостом);
* ``connected`` — кольцо цвета успеха, медленное «дыхание» свечения и три
спутника на орбитах;
* ``error`` — кольцо цвета ошибки, без анимации.
Сфера — единственная кнопка подключения на панели: она берёт фокус по Tab,
срабатывает на Enter и пробел, показывает рамку фокуса и несёт имя действия
для экранного диктора (``set_action_text``).
Экономия CPU: таймер кадров (``motion.FrameGate``) работает только в
анимированных состояниях и только пока виджет виден, окно не свёрнуто и в
Windows не выключена анимация; перерисовывается лишь собственный
прямоугольник виджета. Фаза анимации считается от реального времени, так что
просадка кадров не замедляет движение.
"""
from __future__ import annotations
import math
from PyQt6.QtCore import QElapsedTimer, QEvent, QPointF, QRectF, Qt, pyqtSignal
from PyQt6.QtGui import QColor, QPainter, QPen, QRadialGradient
from PyQt6.QtWidgets import QSizePolicy, QWidget
from .motion import FrameGate
from .theme import accent_color, error_color, on_theme_or_accent_changed, positive_color, text_color, text_muted_color
IDLE, CONNECTING, CONNECTED, ERROR = "idle", "connecting", "connected", "error"
_STATES = (IDLE, CONNECTING, CONNECTED, ERROR)
# Интервалы кадров: «комете» нужна плавность, дыханию хватает 20 fps.
_FRAME_MS = {CONNECTING: 33, CONNECTED: 50}
_BREATH_PERIOD_S = 3.2
_SPIN_PERIOD_S = 1.15
# (доля радиуса орбиты, период оборота в секундах, начальный угол, радиус точки)
_SATELLITES = ((0.92, 9.0, 0.0, 3.2), (0.92, 9.0, math.pi, 2.4), (0.64, 5.5, math.pi / 2, 2.2))
class ConnectionOrb(QWidget):
clicked = pyqtSignal()
def __init__(self, parent: QWidget | None = None, diameter: int = 148):
super().__init__(parent)
self.setFixedSize(diameter, diameter)
self.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
self.setMouseTracking(True)
self.setCursor(Qt.CursorShape.PointingHandCursor)
self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
self._state = IDLE
self._key_pressed = False
self._keyboard_focus = False
self._hover = False
self._pressed = False
self._clock = QElapsedTimer()
self._clock.start()
self._frames = FrameGate(self)
self._frames.frame.connect(self.update)
on_theme_or_accent_changed(self._on_theme_changed)
def set_diameter(self, diameter: int) -> None:
"""Размер сферы под плотность панели (маленький экран — меньше)."""
if (diameter, diameter) != (self.width(), self.height()):
self.setFixedSize(diameter, diameter)
self.update()
# ── Состояние ──────────────────────────────────────────
def state(self) -> str:
return self._state
def set_state(self, state: str) -> None:
state = state if state in _STATES else IDLE
if state == self._state:
return
self._state = state
self._frames.set_interval(_FRAME_MS.get(state))
self.update()
def set_action_text(self, text: str) -> None:
"""Что сделает нажатие: подсказка и имя для экранного диктора."""
if text != self.toolTip():
self.setToolTip(text)
self.setAccessibleName(text)
def is_animating(self) -> bool:
return self._frames.is_running()
def _on_theme_changed(self, *_args) -> None:
self.update()
# ── Мышь: кликабелен только центральный диск ───────────
def _in_button(self, pos) -> bool:
center = QPointF(self.width() / 2, self.height() / 2)
radius = self._radius() * 0.5
return (pos.x() - center.x()) ** 2 + (pos.y() - center.y()) ** 2 <= radius * radius
def mouseMoveEvent(self, event) -> None:
hover = self._in_button(event.position())
if hover != self._hover:
self._hover = hover
self.setCursor(Qt.CursorShape.PointingHandCursor if hover else Qt.CursorShape.ArrowCursor)
self.update()
super().mouseMoveEvent(event)
def leaveEvent(self, event) -> None:
if self._hover or self._pressed:
self._hover = self._pressed = False
self.update()
super().leaveEvent(event)
def mousePressEvent(self, event) -> None:
if event.button() == Qt.MouseButton.LeftButton and self._in_button(event.position()):
self._pressed = True
self.update()
return
super().mousePressEvent(event)
def mouseReleaseEvent(self, event) -> None:
if self._pressed and event.button() == Qt.MouseButton.LeftButton:
self._pressed = False
self.update()
if self._in_button(event.position()) and self.isEnabled():
self.clicked.emit()
return
super().mouseReleaseEvent(event)
# ── Клавиатура: Enter — сразу, пробел — по отпусканию, как у кнопок ──
_ACTIVATE_KEYS = (Qt.Key.Key_Return, Qt.Key.Key_Enter)
def keyPressEvent(self, event) -> None:
key = event.key()
if key in self._ACTIVATE_KEYS and not event.isAutoRepeat():
if self.isEnabled():
self.clicked.emit()
return
if key == Qt.Key.Key_Space and not event.isAutoRepeat():
self._key_pressed = self._pressed = True
self.update()
return
super().keyPressEvent(event)
def keyReleaseEvent(self, event) -> None:
if event.key() == Qt.Key.Key_Space and self._key_pressed and not event.isAutoRepeat():
self._key_pressed = self._pressed = False
self.update()
if self.isEnabled():
self.clicked.emit()
return
super().keyReleaseEvent(event)
def focusInEvent(self, event) -> None:
self.update()
super().focusInEvent(event)
def focusOutEvent(self, event) -> None:
self._key_pressed = self._pressed = False
self.update()
super().focusOutEvent(event)
def _shows_focus_ring(self) -> bool:
# Рамка только при фокусе с клавиатуры: клик мышью её не рисует.
return self.hasFocus() and self._keyboard_focus
def event(self, event) -> bool:
if event.type() == QEvent.Type.FocusIn:
self._keyboard_focus = event.reason() in (
Qt.FocusReason.TabFocusReason, Qt.FocusReason.BacktabFocusReason, Qt.FocusReason.ShortcutFocusReason,
)
return super().event(event)
def changeEvent(self, event) -> None:
if event.type() == QEvent.Type.EnabledChange:
self.update()
super().changeEvent(event)
# ── Отрисовка ──────────────────────────────────────────
def _radius(self) -> float:
return min(self.width(), self.height()) / 2 - 4
def _state_color(self) -> QColor:
if self._state == CONNECTED:
return positive_color()
if self._state == CONNECTING:
return accent_color()
if self._state == ERROR:
return error_color()
return text_muted_color()
def paintEvent(self, event) -> None:
seconds = self._clock.elapsed() / 1000.0
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
center = QPointF(self.width() / 2, self.height() / 2)
radius = self._radius()
color = self._state_color()
enabled = self.isEnabled()
# Свечение: у подключённого «дышит», у остальных — ровное и слабое.
if self._state == CONNECTED:
breath = 0.5 + 0.5 * math.sin(2 * math.pi * seconds / _BREATH_PERIOD_S)
glow_alpha = int(55 + 45 * breath)
else:
glow_alpha = 40 if self._state in (CONNECTING, ERROR) else 18
glow = QRadialGradient(center, radius)
inner = QColor(color)
inner.setAlpha(glow_alpha)
outer = QColor(color)
outer.setAlpha(0)
glow.setColorAt(0.45, inner)
glow.setColorAt(1.0, outer)
painter.setPen(Qt.PenStyle.NoPen)
painter.setBrush(glow)
painter.drawEllipse(center, radius, radius)
# Кольцо-дорожка.
ring_radius = radius * 0.78
ring_rect = QRectF(center.x() - ring_radius, center.y() - ring_radius, ring_radius * 2, ring_radius * 2)
track = QColor(text_muted_color())
track.setAlpha(55)
painter.setBrush(Qt.BrushStyle.NoBrush)
painter.setPen(QPen(track, 3))
painter.drawEllipse(ring_rect)
if self._state == CONNECTING:
self._paint_comet(painter, ring_rect, color, seconds)
elif self._state in (CONNECTED, ERROR):
ring = QPen(color, 3)
painter.setPen(ring)
painter.drawEllipse(ring_rect)
if self._state == CONNECTED:
self._paint_satellites(painter, center, radius, color, seconds)
# Центральная кнопка.
disc_radius = radius * (0.47 if self._pressed else 0.5)
fill = QColor(color)
fill.setAlpha(60 if self._hover and enabled else 34)
border = QColor(color)
border.setAlpha(150 if enabled else 60)
painter.setPen(QPen(border, 1.2))
painter.setBrush(fill)
painter.drawEllipse(center, disc_radius, disc_radius)
self._paint_power_icon(painter, center, disc_radius, enabled)
if self._shows_focus_ring():
focus = QPen(text_color(), 2)
focus.setStyle(Qt.PenStyle.DotLine)
painter.setPen(focus)
painter.setBrush(Qt.BrushStyle.NoBrush)
painter.drawEllipse(center, radius * 0.5 + 5, radius * 0.5 + 5)
painter.end()
@staticmethod
def _paint_comet(painter: QPainter, rect: QRectF, color: QColor, seconds: float) -> None:
head = -360.0 * ((seconds / _SPIN_PERIOD_S) % 1.0) # Qt: против часовой при +
# Хвост из нескольких сегментов с убывающей прозрачностью.
segments = 6
span = 110.0
for index in range(segments):
part = QColor(color)
part.setAlpha(int(255 * (1 - index / segments) ** 1.6))
pen = QPen(part, 3.2)
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
painter.setPen(pen)
start = head + index * span / segments
painter.drawArc(rect, int(start * 16), int(span / segments * 16))
@staticmethod
def _paint_satellites(painter: QPainter, center: QPointF, radius: float, color: QColor, seconds: float) -> None:
painter.setPen(Qt.PenStyle.NoPen)
for orbit, period, phase, dot in _SATELLITES:
angle = phase + 2 * math.pi * seconds / period
point = QPointF(center.x() + math.cos(angle) * radius * orbit,
center.y() + math.sin(angle) * radius * orbit)
halo = QColor(color)
halo.setAlpha(60)
painter.setBrush(halo)
painter.drawEllipse(point, dot * 2.2, dot * 2.2)
painter.setBrush(color)
painter.drawEllipse(point, dot, dot)
def _paint_power_icon(self, painter: QPainter, center: QPointF, disc_radius: float, enabled: bool) -> None:
icon_color = self._state_color() if self._state != IDLE else text_color()
if not enabled:
icon_color.setAlpha(110)
pen = QPen(icon_color, max(2.0, disc_radius * 0.085))
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
painter.setPen(pen)
painter.setBrush(Qt.BrushStyle.NoBrush)
r = disc_radius * 0.42
arc = QRectF(center.x() - r, center.y() - r + disc_radius * 0.04, r * 2, r * 2)
# Дуга с разрывом сверху (60°) и вертикальная черта.
painter.drawArc(arc, 120 * 16, 300 * 16)
painter.drawLine(QPointF(center.x(), center.y() - r * 1.18), QPointF(center.x(), center.y() - r * 0.05))