zapret-kvn/xray_fluent/ui/nodes_view.py
loop-uh 9edbcf4c6b
Some checks failed
Windows project source guards / test (push) Failing after 32s
Смена сервера всегда называет источник: [select] в логе, «Следующий» без фокуса
Инцидент 28.09.2026: сервер сменился Tiraru → Titiru (мёртвый Hysteria), а в
логе был только [core-switch] без причины — явное действие (кнопка
«Следующий», трей, таблица) было неотличимо от сбоя программы.

- SelectionSource (application/selection_source.py): кто сменил сервер и
  ручной ли это выбор. set_selected_node/switch_next_node принимают
  обязательный source= вместо reset_auto_switch; ручной источник сбрасывает
  учёт авто-переключения, автоматика (auto/smart/rotation) — нет.
- Каждая смена пишет одну строку
  [select] source=<код> (<описание>): «было» node_ref=… → «стало» node_ref=…
  node_ref совпадает с [runtime-map] и строками ядер. Покрыты и неявные
  смены: импорт, удаление, скрытие, подписка, восстановление Hysteria.
- UI передаёт жест: двойной клик/Enter/меню таблицы, трей, главная;
  сигнал NodesPage.selected_node_changed → connect_node_requested(id, source).
- Кнопка «Следующий» на главной не берёт фокус клавиатуры: пробел/Enter
  «мимо» больше не меняют сервер.
- tests/test_selection_source.py: AST-инвариант — каждый вызов команды смены
  передаёт source=, а .selected_node_id присваивают только функции из
  белого списка, которые пишут [select]. switch_prev_node (мёртвый) удалён.
2026-09-28 21:35:12 +03:00

536 lines
24 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.

"""Таблица серверов: лёгкий вид и делегат поверх ``NodesTableModel``.
Цена кадра определяется только видимыми ячейками:
* наведение/нажатие перерисовывают две строки, а не весь viewport
(у ``qfluentwidgets.TableView`` любое движение мыши — полный repaint);
* делегат рисует ячейку сам: строка берётся из модели напрямую
(``row_at``), цвета/шрифты/метрики — из палитры, которая пересобирается
только при смене темы или акцента; ``QStyle`` и ``data()`` не участвуют.
"""
from __future__ import annotations
from PyQt6.QtCore import QItemSelectionModel, QModelIndex, QPointF, QRect, QRectF, QSize, Qt, pyqtSignal
from PyQt6.QtGui import QColor, QFont, QFontMetrics, QPainter, QPainterPath, QPen
from PyQt6.QtWidgets import QAbstractItemView, QHeaderView, QStyle, QStyledItemDelegate, QTableView
from qfluentwidgets import FluentStyleSheet, SmoothScrollDelegate, isDarkTheme
from qfluentwidgets.common.font import getFont
from ..application.selection_source import SelectionSource
from ..profiles.country_flags import get_flag_icon
from ..profiles.node_presentation import node_country
from .nodes_table_model import (
CENTERED_COLUMNS,
COL_NAME,
COL_PING,
COL_SPEED,
COL_TYPE,
NodesTableModel,
)
from .theme import (
accent_color,
accent_soft_bg,
accent_soft_bg_hover,
on_theme_or_accent_changed,
text_color,
text_muted_color,
)
FLAG_SIZE = QSize(18, 13)
_ROW_MARGIN = 2 # вертикальный зазор между «карточками» строк
_EDGE_INSET = 4 # отступ скруглённых краёв строки от краёв viewport
_RADIUS = 5.0
_PILL_WIDTH = 3
_TEXT_PAD = 10
class _Palette:
"""Всё, что нужно для отрисовки, собранное один раз на тему/акцент."""
def __init__(self, font: QFont) -> None:
dark = isDarkTheme()
base = 255 if dark else 0
self.card = QColor(base, base, base, 6)
self.hover = QColor(base, base, base, 14)
self.pressed = QColor(base, base, base, 9 if dark else 6)
self.selected = QColor(base, base, base, 20 if dark else 14)
self.selected_hover = QColor(base, base, base, 28 if dark else 20)
self.active = accent_soft_bg()
self.active_hover = accent_soft_bg_hover()
self.accent = accent_color()
self.text = text_color()
self.muted = text_muted_color()
self.chevron = QColor(210, 210, 210) if dark else QColor(75, 75, 75)
self.track = QColor(self.muted)
self.track.setAlpha(80)
self.font = QFont(font)
self.bold = QFont(font)
self.bold.setBold(True)
self.metrics = QFontMetrics(self.font)
self.bold_metrics = QFontMetrics(self.bold)
self.badge_font = QFont(font)
if font.pixelSize() > 0: # getFont() задаёт размер в пикселях
self.badge_font.setPixelSize(max(9, font.pixelSize() - 2))
else:
self.badge_font.setPointSizeF(max(7.0, font.pointSizeF() - 1.5))
self.badge_font.setWeight(QFont.Weight.DemiBold)
self.badge_font.setLetterSpacing(QFont.SpacingType.PercentageSpacing, 104)
self.badge_metrics = QFontMetrics(self.badge_font)
self.badge_fill = QColor(base, base, base, 10)
self.badge_border = QColor(base, base, base, 40 if dark else 34)
self.spinner_pen = QPen(self.accent, 2)
self.spinner_pen.setCapStyle(Qt.PenCapStyle.RoundCap)
self.chevron_pen = QPen(self.chevron, 1.3)
self.chevron_pen.setCapStyle(Qt.PenCapStyle.RoundCap)
self.chevron_pen.setJoinStyle(Qt.PenJoinStyle.RoundJoin)
class NodesDelegate(QStyledItemDelegate):
def __init__(self, view: NodesView):
super().__init__(view)
self._view = view
self._palette: _Palette | None = None
self._flags: dict[tuple[str, float], object] = {}
on_theme_or_accent_changed(self.invalidate)
def invalidate(self, *_args) -> None:
self._palette = None
self._flags.clear()
def palette(self) -> _Palette:
if self._palette is None:
self._palette = _Palette(getFont(13))
return self._palette
def sizeHint(self, option, index) -> QSize:
return QSize(0, self._view.verticalHeader().defaultSectionSize())
def paint(self, painter: QPainter, option, index: QModelIndex) -> None:
view = self._view
model: NodesTableModel = view.model()
r = index.row()
item = model.row_at(r)
if item is None:
return
col = index.column()
pal = self.palette()
rect = option.rect
selected = bool(option.state & QStyle.StateFlag.State_Selected)
hovered = r == view.hover_row
first = col == view.first_column
last = col == view.last_column
if item.is_group:
if hovered:
self._fill(painter, rect, pal.hover, first, last)
if first:
self._paint_group_title(painter, rect, item, pal)
return
node = item.node
active = node.id == model.active_node_id()
if active:
fill = pal.active_hover if hovered else pal.active
elif selected:
fill = pal.selected_hover if hovered else pal.selected
elif r == view.pressed_row:
fill = pal.pressed
elif hovered:
fill = pal.hover
else:
fill = pal.card
self._fill(painter, rect, fill, first, last)
if first and (active or selected) and view.horizontalScrollBar().value() == 0:
self._paint_pill(painter, rect, pal, pressed=r == view.pressed_row)
if col == COL_PING and model.is_ping_busy(node.id):
self._paint_spinner(painter, rect, pal)
return
if col == COL_SPEED:
progress = model.speed_progress(node.id)
if progress is not None:
self._paint_progress(painter, rect, pal, progress)
return
text = model.display_text(item, col)
color = model.status_color(item, col) or pal.text
text_rect = rect.adjusted(_TEXT_PAD, 0, -_TEXT_PAD, 0)
if col == COL_NAME:
text_rect.setLeft(text_rect.left() + _PILL_WIDTH + 2)
pixmap = self._flag(node_country(node), painter)
if pixmap is not None:
y = rect.top() + (rect.height() - FLAG_SIZE.height()) // 2
painter.drawPixmap(text_rect.left(), y, pixmap)
text_rect.setLeft(text_rect.left() + FLAG_SIZE.width() + 8)
if not text:
return
if col == COL_TYPE:
self._paint_badge(painter, rect, pal, text, color)
return
painter.setFont(pal.font)
painter.setPen(color)
align = Qt.AlignmentFlag.AlignCenter if col in CENTERED_COLUMNS else (
Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter
)
painter.drawText(text_rect, align, pal.metrics.elidedText(text, Qt.TextElideMode.ElideRight, text_rect.width()))
# ── Примитивы ──────────────────────────────────────────
@staticmethod
def _fill(painter: QPainter, rect: QRect, color: QColor, first: bool, last: bool) -> None:
if not color.alpha():
return
body = rect.adjusted(0, _ROW_MARGIN, 0, -_ROW_MARGIN)
if not (first or last):
painter.fillRect(body, color)
return
# Скругление только у крайних видимых колонок; сегмент строго внутри
# своей ячейки, чтобы полупрозрачные куски не перекрывались.
left = body.left() + _EDGE_INSET if first else body.left() - int(_RADIUS) - 1
right = body.right() - _EDGE_INSET if last else body.right() + int(_RADIUS) + 1
painter.save()
painter.setClipRect(body)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
painter.setPen(Qt.PenStyle.NoPen)
painter.setBrush(color)
painter.drawRoundedRect(QRectF(left, body.top(), right - left + 1, body.height()), _RADIUS, _RADIUS)
painter.restore()
@staticmethod
def _paint_pill(painter: QPainter, rect: QRect, pal: _Palette, *, pressed: bool) -> None:
inset = round(rect.height() * (0.35 if pressed else 0.27))
painter.save()
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
painter.setPen(Qt.PenStyle.NoPen)
painter.setBrush(pal.accent)
painter.drawRoundedRect(QRectF(rect.left() + _EDGE_INSET + 3, rect.top() + inset, _PILL_WIDTH,
rect.height() - 2 * inset), 1.5, 1.5)
painter.restore()
def _paint_group_title(self, painter: QPainter, rect: QRect, item, pal: _Palette) -> None:
painter.save()
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
painter.setPen(pal.chevron_pen)
x, y = rect.left() + 14.0, float(rect.center().y()) + 0.5
if item.collapsed:
points = (QPointF(x - 1.5, y - 4), QPointF(x + 2.5, y), QPointF(x - 1.5, y + 4))
else:
points = (QPointF(x - 4, y - 2), QPointF(x, y + 2), QPointF(x + 4, y - 2))
path = QPainterPath(points[0])
path.lineTo(points[1])
path.lineTo(points[2])
painter.drawPath(path)
painter.setFont(pal.bold)
painter.setPen(pal.text)
text_rect = rect.adjusted(30, 0, -_TEXT_PAD, 0)
painter.drawText(text_rect, Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter,
pal.bold_metrics.elidedText(item.label, Qt.TextElideMode.ElideRight, text_rect.width()))
painter.restore()
@staticmethod
def _paint_badge(painter: QPainter, rect: QRect, pal: _Palette, text: str, color: QColor) -> None:
"""Протокол — компактная «пилюля» с обводкой, а не голый капслок."""
metrics = pal.badge_metrics
width = min(metrics.horizontalAdvance(text) + 16, rect.width() - 8)
height = min(18, rect.height() - 8)
badge = QRectF(0, 0, width, height)
badge.moveCenter(QRectF(rect).center())
painter.save()
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
painter.setPen(QPen(pal.badge_border, 1))
painter.setBrush(pal.badge_fill)
painter.drawRoundedRect(badge, height / 2, height / 2)
painter.setFont(pal.badge_font)
painter.setPen(color)
painter.drawText(badge, Qt.AlignmentFlag.AlignCenter,
metrics.elidedText(text, Qt.TextElideMode.ElideRight, int(badge.width()) - 8))
painter.restore()
@staticmethod
def _paint_spinner(painter: QPainter, rect: QRect, pal: _Palette) -> None:
size = max(8, min(16, rect.height() - 8, rect.width() - 8))
spinner = QRect(0, 0, size, size)
spinner.moveCenter(rect.center())
painter.save()
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
painter.setPen(pal.spinner_pen)
painter.drawArc(spinner, 35 * 16, 250 * 16)
painter.restore()
@staticmethod
def _paint_progress(painter: QPainter, rect: QRect, pal: _Palette, percent: int) -> None:
track = QRect(0, 0, max(0, rect.width() - 16), 6)
track.moveCenter(rect.center())
painter.save()
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
painter.setPen(Qt.PenStyle.NoPen)
painter.setBrush(pal.track)
painter.drawRoundedRect(track, 3, 3)
width = round(track.width() * max(0, min(100, percent)) / 100)
if width > 0:
fill = QRect(track)
fill.setWidth(width)
painter.setBrush(pal.accent)
painter.drawRoundedRect(fill, 3, 3)
painter.restore()
def _flag(self, code: str, painter: QPainter):
if not code:
return None
ratio = painter.device().devicePixelRatioF() if painter.device() is not None else 1.0
key = (code, ratio)
if key not in self._flags:
icon = get_flag_icon(code)
self._flags[key] = icon.pixmap(FLAG_SIZE, ratio) if icon is not None else None
return self._flags[key]
class NodesHeader(QHeaderView):
def paintSection(self, painter, rect, logical_index):
super().paintSection(painter, rect, logical_index)
painter.save()
painter.setPen(QColor(255, 255, 255, 28) if isDarkTheme() else QColor(0, 0, 0, 28))
painter.drawLine(rect.right(), rect.top() + 4, rect.right(), rect.bottom() - 4)
painter.restore()
class NodesView(QTableView):
"""Плоская таблица с группами-заголовками; свёртка — через модель."""
group_toggled = pyqtSignal(str, bool) # ключ группы, развёрнута
# Явная активация сервера (двойной клик или Enter по строке-серверу):
# id сервера и SelectionSource жеста. Простое выделение строк её не вызывает.
node_activated = pyqtSignal(str, object)
def __init__(self, parent=None):
super().__init__(parent)
self.setHorizontalHeader(NodesHeader(Qt.Orientation.Horizontal, self))
# Имя поля — как у qfluentwidgets.TableView (страница его настраивает).
self.scrollDelagate = SmoothScrollDelegate(self)
FluentStyleSheet.TABLE_VIEW.apply(self)
self.setShowGrid(False)
self.setMouseTracking(True)
self.setWordWrap(False)
self.horizontalHeader().setHighlightSections(False)
self.verticalHeader().setHighlightSections(False)
self.hover_row = -1
self.pressed_row = -1
self.first_column = 0
self.last_column = 0
self._saved_keys: list[str] = []
self._saved_current: str | None = None
self._saved_top: str | None = None
# Выделенные серверы, спрятанные свёрткой группы: строк в модели нет,
# но выделение не должно теряться (свернул/развернул — оно на месте).
self._parked_keys: set[str] = set()
self.setItemDelegate(NodesDelegate(self))
header = self.horizontalHeader()
header.sectionMoved.connect(lambda *_: self.update_edge_columns())
header.sectionCountChanged.connect(lambda *_: self.update_edge_columns())
self.doubleClicked.connect(self._on_double_clicked)
# ── Модель и выделение через reset ─────────────────────
def setModel(self, model: NodesTableModel) -> None:
super().setModel(model)
model.modelAboutToBeReset.connect(self._remember_view_state)
model.modelReset.connect(self._restore_view_state)
self.update_edge_columns()
def _remember_view_state(self) -> None:
model: NodesTableModel = self.model()
self._saved_keys = [model.row_at(index.row()).key for index in self.selectionModel().selectedRows()]
self._saved_keys.extend(key for key in self._parked_keys if key not in self._saved_keys)
current = self.currentIndex()
self._saved_current = model.row_at(current.row()).key if current.isValid() else None
top = self.rowAt(0)
self._saved_top = model.row_at(top).key if top >= 0 else None
self.hover_row = self.pressed_row = -1
def _restore_view_state(self) -> None:
model: NodesTableModel = self.model()
selection = self.selectionModel()
flags = QItemSelectionModel.SelectionFlag.Select | QItemSelectionModel.SelectionFlag.Rows
parked: set[str] = set()
selection.blockSignals(True)
try:
for key in self._saved_keys:
row = model.row_of_key(key)
if row is not None:
selection.select(model.index(row, 0), flags)
elif self._hidden_by_collapse(key):
parked.add(key) # отфильтрованные/удалённые — забываются
self._parked_keys = parked
row = model.row_of_key(self._saved_current) if self._saved_current else None
if row is not None:
selection.setCurrentIndex(model.index(row, 0), QItemSelectionModel.SelectionFlag.NoUpdate)
finally:
selection.blockSignals(False)
top = model.row_of_key(self._saved_top) if self._saved_top else None
if top is not None:
self.verticalScrollBar().setValue(top)
def _hidden_by_collapse(self, key: str) -> bool:
return key.startswith("node:") and self.model().collapsed_group_of(key[5:]) is not None
def parked_node_ids(self) -> set[str]:
"""Выделенные серверы внутри свёрнутых групп (проверка — по живой модели)."""
return {key[5:] for key in self._parked_keys if self._hidden_by_collapse(key)}
def selectionCommand(self, index, event=None):
flags = super().selectionCommand(index, event)
if flags & QItemSelectionModel.SelectionFlag.Clear:
# Обычный клик/стрелки заменяют выделение целиком — вместе со
# спрятанной частью; Ctrl+клик её не трогает.
self._parked_keys.clear()
return flags
def clearSelection(self) -> None:
self._parked_keys.clear()
super().clearSelection()
# ── Колонки ────────────────────────────────────────────
def update_edge_columns(self) -> None:
header = self.horizontalHeader()
visible = [header.logicalIndex(v) for v in range(header.count())
if not header.isSectionHidden(header.logicalIndex(v))]
edges = (visible[0], visible[-1]) if visible else (0, 0)
if edges != (self.first_column, self.last_column):
self.first_column, self.last_column = edges
self.viewport().update()
def setColumnHidden(self, column: int, hide: bool) -> None:
super().setColumnHidden(column, hide)
self.update_edge_columns()
# ── Наведение/нажатие: перерисовка только затронутых строк ──
def _update_row(self, row: int) -> None:
if row >= 0:
self.viewport().update(QRect(0, self.rowViewportPosition(row), self.viewport().width(), self.rowHeight(row)))
def _set_hover_row(self, row: int) -> None:
if row != self.hover_row:
previous, self.hover_row = self.hover_row, row
self._update_row(previous)
self._update_row(row)
def _set_pressed_row(self, row: int) -> None:
if row != self.pressed_row:
previous, self.pressed_row = self.pressed_row, row
self._update_row(previous)
self._update_row(row)
def mouseMoveEvent(self, event) -> None:
self._set_hover_row(self.rowAt(event.position().toPoint().y()))
super().mouseMoveEvent(event)
def leaveEvent(self, event) -> None:
self._set_hover_row(-1)
super().leaveEvent(event)
def wheelEvent(self, event) -> None:
super().wheelEvent(event)
self._set_hover_row(self.rowAt(self.viewport().mapFromGlobal(event.globalPosition().toPoint()).y()))
def mousePressEvent(self, event) -> None:
index = self.indexAt(event.position().toPoint())
if event.button() == Qt.MouseButton.LeftButton:
if not index.isValid():
return # клик по пустому месту не снимает выделение
item = self.model().row_at(index.row())
if item is not None and item.is_group:
self.selectionModel().setCurrentIndex(index, QItemSelectionModel.SelectionFlag.NoUpdate)
self.toggle_group(item.key)
return
if index.isValid():
self._set_pressed_row(index.row())
super().mousePressEvent(event)
def mouseReleaseEvent(self, event) -> None:
super().mouseReleaseEvent(event)
self._set_pressed_row(-1)
def mouseDoubleClickEvent(self, event) -> None:
item = self.model().row_at(self.rowAt(event.position().toPoint().y()))
if item is not None and item.is_group and event.button() == Qt.MouseButton.LeftButton:
# Первое нажатие уже свернуло/развернуло группу; второе нажатие
# двойного клика не должно откатывать это обратно.
return
super().mouseDoubleClickEvent(event)
def _on_double_clicked(self, index: QModelIndex) -> None:
node = self.model().node_at_row(index.row()) if index.isValid() else None
if node is not None:
self.node_activated.emit(node.id, SelectionSource.NODES_DOUBLE_CLICK)
# ── Группы ─────────────────────────────────────────────
def is_group_expanded(self, key: str) -> bool:
return key not in self.model().collapsed_groups()
def set_group_expanded(self, key: str, expanded: bool) -> None:
model: NodesTableModel = self.model()
collapsed = model.collapsed_groups()
if (key not in collapsed) == expanded:
return
model.set_collapsed_groups(collapsed - {key} if expanded else collapsed | {key})
self.group_toggled.emit(key, expanded)
def toggle_group(self, key: str) -> None:
self.set_group_expanded(key, not self.is_group_expanded(key))
def set_all_groups_expanded(self, expanded: bool) -> None:
model: NodesTableModel = self.model()
keys = model.group_keys()
changed = [key for key in keys if self.is_group_expanded(key) != expanded]
model.set_collapsed_groups(set() if expanded else set(keys))
for key in changed:
self.group_toggled.emit(key, expanded)
def expandAll(self) -> None:
self.set_all_groups_expanded(True)
def collapseAll(self) -> None:
self.set_all_groups_expanded(False)
def keyPressEvent(self, event) -> None:
index = self.currentIndex()
key = event.key()
model: NodesTableModel = self.model()
item = model.row_at(index.row()) if index.isValid() else None
if (item is not None and key in (Qt.Key.Key_Return, Qt.Key.Key_Enter)
and not event.modifiers() & ~Qt.KeyboardModifier.KeypadModifier):
# Enter: заголовок группы — свернуть/развернуть, сервер — подключить.
if item.is_group:
self.toggle_group(item.key)
else:
self.node_activated.emit(item.node.id, SelectionSource.NODES_ENTER)
return
if item is not None and not event.modifiers():
if item.is_group and key in (Qt.Key.Key_Left, Qt.Key.Key_Right, Qt.Key.Key_Space):
expanded = not self.is_group_expanded(item.key) if key == Qt.Key.Key_Space else key == Qt.Key.Key_Right
self.set_group_expanded(item.key, expanded)
return
if not item.is_group and key == Qt.Key.Key_Left:
# Влево с сервера — на заголовок его группы.
for row in range(index.row() - 1, -1, -1):
if model.row_at(row).is_group:
group_index = model.index(row, 0)
self.selectionModel().setCurrentIndex(group_index, QItemSelectionModel.SelectionFlag.NoUpdate)
self.scrollTo(group_index)
return
super().keyPressEvent(event)
def select_row(self, row: int) -> None:
index = self.model().index(row, 0)
if not index.isValid():
return
self._parked_keys.clear()
self.selectionModel().setCurrentIndex(
index, QItemSelectionModel.SelectionFlag.ClearAndSelect | QItemSelectionModel.SelectionFlag.Rows
)
self.scrollTo(index)