zapret-kvn/xray_fluent/ui/nodes_table_model.py
loop-uh b46ecdf886 Пинг и тест скорости: быстрее, без подвисаний и заметно тише на линии
Тест скорости
- один оконный замер вместо трёх полных скачиваний по 100 МБ: первая
  секунда (разгон TCP) отбрасывается, дальше считаются 3 с, не больше 32 МиБ;
  повтор только если замер не удался;
- временный xray получает минимальный конфиг без правил маршрутизации: не
  грузит гео-базы (9–16 с на холодном диске) и не уводит замер «напрямую»;
- готовность ядра опрашивается сразу, без обязательной секунды ожидания;
- занятый порт (осиротевший xray-speedtest.exe) заменяется свободным вместо
  ложного результата по чужому серверу;
- конфиг с ключами сервера удаляется, как только ядро его прочитало;
- прогресс строки шлётся только при смене процента, а не на каждый чанк;
- случайный порядок серверов и случайная пауза между ними;
- пропущенные серверы — одной строкой в конце, а не плашкой на каждый;
- браузерный User-Agent вместо «ZapretKVN/SpeedTest».
Методика «умной проверки» авто-переключения не изменилась.

Пинг
- серверы с одним адресом и портом меряются одним соединением;
- случайный порядок, 8 одновременных соединений вместо 16, случайная
  задержка перед каждым;
- раунд можно остановить кнопкой.

Фоновый пинг активного сервера больше не идёт каждые 3 с всю сессию: раз в
минуту, пока есть трафик, раз в 20 с в тишине и часто — только когда прошлый
пинг не прошёл или запросы уходят без ответа.

Таблица серверов при сортировке по пингу или скорости перестраивается во
время раунда раз в 1,5 с, а не трижды в секунду.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 23:56:34 +03:00

764 lines
33 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.

"""Модель таблицы серверов: одна плоская модель вместо цепочки прокси.
Фильтр, сортировка и группировка считаются одним проходом по списку нод в
``_relayout``; результат — плоский список строк (заголовки групп и ноды,
дети свёрнутых групп в него не попадают). Тексты ячеек строки вычисляются
лениво при первой отрисовке и кэшируются в самой строке, поэтому отрисовка
видимой области не зависит от размера каталога, а точечные изменения
(пинг, скорость, активная нода) инвалидируют только свои строки.
Делегат читает строки напрямую через ``row_at``, минуя ``data()``.
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
from PyQt6.QtCore import QAbstractTableModel, QModelIndex, QSize, Qt, QTimer
from PyQt6.QtGui import QBrush, QFont
from qfluentwidgets import qconfig
from ..profiles.country_flags import get_flag_icon
from ..profiles.models import Node
from ..profiles.node_presentation import display_name, node_country
from .privacy import endpoint_text
from .theme import error_color, positive_color, text_muted_color, warning_color
NODE_ROW_HEIGHT = 28
PING_BUSY_ROLE = int(Qt.ItemDataRole.UserRole) + 1
SPEED_PROGRESS_ROLE = int(Qt.ItemDataRole.UserRole) + 2
ACTIVE_ROLE = int(Qt.ItemDataRole.UserRole) + 3
NODE_ID_ROLE = int(Qt.ItemDataRole.UserRole) + 4
GROUP_KEY_ROLE = int(Qt.ItemDataRole.UserRole) + 20
(
COL_NAME,
COL_TYPE,
COL_ADDRESS,
COL_GROUP,
COL_TAGS,
COL_PING,
COL_SPEED,
COL_LAST_USED,
COL_SOURCE,
) = range(9)
@dataclass(frozen=True, slots=True)
class NodeColumnSpec:
"""Single source of truth for one logical server-table column."""
key: str
title: str
default_visible: bool
default_width: int
minimum_width: int
maximum_width: int
sort_key: str | None = None
centered: bool = False
# The single flex column: it absorbs the leftover viewport width during
# the NodesPage relayout instead of using QHeaderView.ResizeMode.Stretch.
stretch: bool = False
# Logical order is stable for the model; visual order can be moved and saved.
# Maximums are deliberately generous sanity bounds (persisted values clamp
# against them); live layout is governed by the flex relayout in NodesPage.
COLUMN_SPECS = (
NodeColumnSpec("name", "Имя", True, 360, 220, 640, "name"),
NodeColumnSpec("type", "Тип", True, 110, 90, 200, "type", centered=True),
NodeColumnSpec("address", "Адрес", False, 180, 120, 360),
NodeColumnSpec("group", "Группа", False, 140, 80, 800, "group"),
NodeColumnSpec("tags", "Теги", False, 160, 90, 1000),
NodeColumnSpec("ping", "Пинг", True, 96, 80, 160, "ping", centered=True),
NodeColumnSpec("speed", "Скорость", True, 112, 96, 200, "speed", centered=True),
NodeColumnSpec(
"last_used", "Последнее использование", False, 156, 120, 480, "last_used"
),
NodeColumnSpec("source", "Источник", False, 150, 90, 1000),
)
# Compatibility exports used throughout the app and in persisted settings.
COLUMN_KEYS = [spec.key for spec in COLUMN_SPECS]
DEFAULT_VISIBLE_COLUMNS = [spec.key for spec in COLUMN_SPECS if spec.default_visible]
COLUMN_BY_KEY = {spec.key: spec for spec in COLUMN_SPECS}
_HEADERS = [spec.title for spec in COLUMN_SPECS]
_COLUMN_COUNT = len(COLUMN_SPECS)
CENTERED_COLUMNS = frozenset(index for index, spec in enumerate(COLUMN_SPECS) if spec.centered)
# Stable english sort keys (persisted in AppSettings.nodes_sort_key).
SORT_KEYS = ("manual", "name", "group", "type", "ping", "speed", "last_used")
DEFAULT_SORT_KEY = "manual"
GROUP_MODES = {"source": "Подписки", "group": "Группы", "country": "Страны", "type": "Протоколы", "none": "Без группировки"}
LOCAL_SOURCE_NAME = "Локальные"
# Статус строки для цвета текста (кисти берутся из темы в момент отрисовки).
STATUS_NONE, STATUS_ALIVE, STATUS_DEGRADED, STATUS_DEAD = range(4)
# Перестройка раскладки после метрик (пинг/скорость) не чаще, чем раз в N мс.
_METRIC_RELAYOUT_MS = 300
# Пока идёт раунд пинга или теста скорости, результаты сыплются непрерывно:
# при сортировке по метрике таблица иначе перестраивалась бы трижды в секунду
# (полная пересортировка + layoutChanged + перенос выделения) весь раунд.
_METRIC_RELAYOUT_BUSY_MS = 1500
def node_type_text(node: Node) -> str:
"""Return the protocol label, including legacy nodes with no scheme field."""
value = (node.scheme or "").strip()
if not value and isinstance(node.outbound, dict):
value = str(node.outbound.get("type") or "").strip()
if not value and "://" in (node.link or ""):
value = node.link.split("://", 1)[0].strip()
return value.upper() or "—"
def _format_time(value: str | None) -> str:
if not value:
return ""
try:
return datetime.fromisoformat(value.replace("Z", "+00:00")).strftime("%Y-%m-%d %H:%M")
except ValueError:
return value
def ping_status(node: Node) -> tuple[int, str | None]:
"""Статус, который рисуется на ячейке пинга (бывшая колонка «Статус»)."""
if node.is_alive is None:
return STATUS_NONE, None
if node.ping_ms is not None and node.speed_mbps is None and node.is_alive:
if node.speed_history:
return STATUS_DEGRADED, "Пинг есть, скорость нет — вероятно заблокирован провайдером"
return STATUS_NONE, None
if node.is_alive:
return STATUS_ALIVE, "Сервер работает"
return STATUS_DEAD, "Сервер недоступен"
class NodeRow:
"""Строка-сервер. ``texts``/``ping_status`` считаются лениво."""
__slots__ = ("key", "node", "texts", "status")
is_group = False
def __init__(self, node: Node) -> None:
self.key = "node:" + node.id
self.node = node
self.texts: tuple[str, ...] | None = None
self.status: int | None = None
class GroupRow:
__slots__ = ("key", "title", "count", "collapsed", "label")
is_group = True
def __init__(self, key: str, title: str, count: int, collapsed: bool) -> None:
self.key = key
self.title = title
self.count = count
self.collapsed = collapsed
self.label = f"{title} · {count}"
class NodesTableModel(QAbstractTableModel):
def __init__(self, parent=None):
super().__init__(parent)
self._nodes: list[Node] = []
self._by_id: dict[str, Node] = {}
self._snapshots: dict[str, tuple] = {}
self._layout_sigs: dict[str, tuple] = {}
# Параметры представления.
self._query = ""
self._group_filter = ""
self._tag_filter = ""
self._source_filter = ""
self._favorites_only = False
self._sort_key = DEFAULT_SORT_KEY
self._descending = False
self._group_mode = "source"
self._collapsed: set[str] = set()
# Оформление поверх нод.
self._source_names: dict[str, str] = {}
self._active_node_id: str | None = None
self._endpoints_revealed = False
self._busy_ping_ids: set[str] = set()
self._speed_progress: dict[str, int] = {}
# Результат раскладки.
self._rows: list[NodeRow | GroupRow] = []
self._row_by_key: dict[str, int] = {}
self._visible_node_count = 0
self._ordered_ids: list[str] = []
self._group_keys: list[str] = []
self._haystacks: dict[str, str] = {}
self._bold_font: QFont | None = None
self._relayout_timer = QTimer(self)
self._relayout_timer.setSingleShot(True)
self._relayout_timer.setInterval(_METRIC_RELAYOUT_MS)
self._relayout_timer.timeout.connect(self._relayout)
qconfig.themeChanged.connect(self._on_theme_changed)
# ── Данные ─────────────────────────────────────────────
def set_nodes(self, nodes: list[Node]) -> None:
"""Обновить каталог. Без изменения раскладки — только точечный repaint."""
new_nodes = list(nodes)
old_ids = [node.id for node in self._nodes]
self._nodes = new_nodes
self._by_id = {node.id: node for node in new_nodes}
snapshots = {node.id: self._snapshot(node) for node in new_nodes}
changed = {nid for nid, snap in snapshots.items() if self._snapshots.get(nid) != snap}
self._snapshots = snapshots
self._haystacks = {nid: text for nid, text in self._haystacks.items() if nid not in changed and nid in snapshots}
# Объекты нод могли быть пересозданы выше по стеку — обновить ссылки,
# а кэш текстов изменившихся строк сбросить до любой перестройки.
for row in self._rows:
if not row.is_group:
node = self._by_id.get(row.node.id)
if node is not None:
row.node = node
if row.node.id in changed:
row.texts = row.status = None
if [node.id for node in new_nodes] != old_ids or self._layout_changed_for(changed):
self._relayout()
return
self._repaint_nodes(changed)
def set_source_names(self, names: dict[str, str]) -> None:
names = dict(names)
if names == self._source_names:
return
self._source_names = names
self._haystacks.clear()
self._relayout()
@staticmethod
def _snapshot(node: Node) -> tuple:
return (node.name, node_type_text(node), node.server, node.port, node.group,
tuple(node.tags), node.ping_ms, node.speed_mbps, node.is_alive,
bool(node.speed_history), node.country_code, node.country_override,
node.is_favorite, node.subscription_id, node.last_used_at, node.sort_order)
# ── Параметры представления ────────────────────────────
def set_query(self, query: str) -> None:
query = (query or "").strip().lower()
if query != self._query:
self._query = query
self._relayout()
def set_group_filter(self, group: str) -> None:
if (group or "") != self._group_filter:
self._group_filter = group or ""
self._relayout()
def set_tag_filter(self, tag: str) -> None:
if (tag or "") != self._tag_filter:
self._tag_filter = tag or ""
self._relayout()
def set_source_filter(self, source: str) -> None:
if (source or "") != self._source_filter:
self._source_filter = source or ""
self._relayout()
def set_favorites_only(self, enabled: bool) -> None:
if bool(enabled) != self._favorites_only:
self._favorites_only = bool(enabled)
self._relayout()
def set_sort(self, key: str, descending: bool) -> None:
key = key if key in SORT_KEYS else DEFAULT_SORT_KEY
if (key, bool(descending)) != (self._sort_key, self._descending):
self._sort_key, self._descending = key, bool(descending)
self._relayout()
def sort_key(self) -> str:
return self._sort_key
def set_group_mode(self, mode: str) -> None:
mode = mode if mode in GROUP_MODES else "source"
if mode != self._group_mode:
self._group_mode = mode
self._relayout()
def group_mode(self) -> str:
return self._group_mode
def set_collapsed_groups(self, keys) -> None:
keys = set(keys)
if keys != self._collapsed:
self._collapsed = keys
self._relayout()
def collapsed_groups(self) -> set[str]:
return set(self._collapsed)
def group_keys(self) -> list[str]:
return list(self._group_keys)
# ── Оформление ─────────────────────────────────────────
def set_active_node_id(self, node_id: str | None) -> None:
node_id = node_id or None
if node_id == self._active_node_id:
return
previous, self._active_node_id = self._active_node_id, node_id
self._repaint_nodes({nid for nid in (previous, node_id) if nid})
def active_node_id(self) -> str | None:
return self._active_node_id
def set_endpoints_revealed(self, revealed: bool) -> None:
"""Reveal address data transiently; this state is never persisted."""
revealed = bool(revealed)
if revealed == self._endpoints_revealed:
return
self._endpoints_revealed = revealed
for row in self._rows:
if not row.is_group:
row.texts = None
self._repaint_all()
def endpoints_revealed(self) -> bool:
return self._endpoints_revealed
def set_ping_busy_ids(self, node_ids) -> None:
node_ids = set(node_ids)
if node_ids == self._busy_ping_ids:
return
changed = node_ids ^ self._busy_ping_ids
self._busy_ping_ids = node_ids
self._repaint_nodes(changed, COL_PING)
self._hasten_relayout()
def clear_ping_busy(self) -> None:
self.set_ping_busy_ids(set())
def is_ping_busy(self, node_id: str) -> bool:
return node_id in self._busy_ping_ids
def set_speed_progress_batch(self, progress: dict[str, int]) -> None:
changed = set()
for node_id, percent in progress.items():
percent = max(0, min(100, int(percent)))
if self._speed_progress.get(node_id) != percent:
self._speed_progress[node_id] = percent
changed.add(node_id)
self._repaint_nodes(changed, COL_SPEED)
def clear_speed_progress(self) -> None:
if self._speed_progress:
changed = set(self._speed_progress)
self._speed_progress.clear()
self._repaint_nodes(changed, COL_SPEED)
self._hasten_relayout()
def speed_progress(self, node_id: str) -> int | None:
return self._speed_progress.get(node_id)
# ── Результаты метрик (объекты нод уже изменены на месте) ──
def finish_ping_batch(self, node_ids) -> None:
node_ids = set(node_ids)
self._busy_ping_ids -= node_ids
self._metrics_changed(node_ids)
self._hasten_relayout()
def finish_speed(self, node_id: str) -> None:
self._speed_progress.pop(node_id, None)
self._metrics_changed({node_id})
def refresh_alive_status(self, node_id: str) -> None:
self._metrics_changed({node_id})
def refresh_countries(self, node_ids) -> None:
self._metrics_changed(set(node_ids))
def _metrics_changed(self, node_ids: set[str]) -> None:
changed = set()
for nid in node_ids:
node = self._by_id.get(nid)
if node is None:
continue
snap = self._snapshot(node)
if self._snapshots.get(nid) != snap:
self._snapshots[nid] = snap
changed.add(nid)
if self._layout_changed_for(changed):
# Пачки результатов пинга не должны пересортировывать таблицу на
# каждый ответ: одна перестройка на окно.
if not self._relayout_timer.isActive():
self._relayout_timer.start(
_METRIC_RELAYOUT_BUSY_MS if self._metrics_round_active() else _METRIC_RELAYOUT_MS
)
self._invalidate_nodes(node_ids)
def _metrics_round_active(self) -> bool:
return bool(self._busy_ping_ids or self._speed_progress)
def _hasten_relayout(self) -> None:
"""Раунд кончился — отложенную пересортировку не держим до конца окна."""
if self._relayout_timer.isActive() and not self._metrics_round_active():
self._relayout_timer.start(0)
# ── Доступ для страницы и делегата ─────────────────────
def row_at(self, row: int) -> NodeRow | GroupRow | None:
return self._rows[row] if 0 <= row < len(self._rows) else None
def row_of_node(self, node_id: str) -> int | None:
return self._row_by_key.get("node:" + node_id)
def row_of_key(self, key: str) -> int | None:
return self._row_by_key.get(key)
def node(self, node_id: str) -> Node | None:
return self._by_id.get(node_id)
def node_at_row(self, row: int) -> Node | None:
item = self.row_at(row)
return None if item is None or item.is_group else item.node
def collapsed_group_of(self, node_id: str) -> str | None:
"""Ключ свёрнутой группы, скрывающей ноду (None — нода видна или отфильтрована)."""
node = self._by_id.get(node_id)
if node is None or not self._accepts(node):
return None
key = self._group_of(node)[0]
return key if key and key in self._collapsed else None
def visible_node_count(self) -> int:
return self._visible_node_count
def visible_node_ids(self) -> list[str]:
"""Ноды после фильтра в порядке отображения (включая свёрнутые)."""
return list(self._ordered_ids)
def texts(self, row: NodeRow) -> tuple[str, ...]:
if row.texts is None:
row.texts = self._build_texts(row.node)
return row.texts
def status(self, row: NodeRow) -> int:
if row.status is None:
row.status = ping_status(row.node)[0]
return row.status
def source_name(self, node: Node) -> str:
if not node.subscription_id:
return LOCAL_SOURCE_NAME
return self._source_names.get(node.subscription_id, "Подписка")
# ── QAbstractTableModel ────────────────────────────────
def rowCount(self, parent: QModelIndex = QModelIndex()) -> int:
return 0 if parent.isValid() else len(self._rows)
def columnCount(self, parent: QModelIndex = QModelIndex()) -> int:
return 0 if parent.isValid() else _COLUMN_COUNT
def headerData(self, section: int, orientation: Qt.Orientation, role: int = Qt.ItemDataRole.DisplayRole):
if orientation == Qt.Orientation.Horizontal and 0 <= section < _COLUMN_COUNT:
if role == Qt.ItemDataRole.TextAlignmentRole:
return int(Qt.AlignmentFlag.AlignCenter if section in CENTERED_COLUMNS
else Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter)
if role == Qt.ItemDataRole.DisplayRole:
return _HEADERS[section]
return super().headerData(section, orientation, role)
def flags(self, index: QModelIndex) -> Qt.ItemFlag:
row = self.row_at(index.row()) if index.isValid() else None
if row is None:
return Qt.ItemFlag.NoItemFlags
if row.is_group:
return Qt.ItemFlag.ItemIsEnabled
return Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsSelectable
def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole):
# Делегат сюда не ходит; это API для тестов, подсказок и доступности.
row = self.row_at(index.row()) if index.isValid() else None
if row is None:
return None
col = index.column()
if role == Qt.ItemDataRole.SizeHintRole:
return QSize(0, NODE_ROW_HEIGHT)
if row.is_group:
if role == GROUP_KEY_ROLE:
return row.key
if role == Qt.ItemDataRole.DisplayRole and col == COL_NAME:
return row.label
if role == Qt.ItemDataRole.FontRole:
return self._bold()
return None
node = row.node
if role == NODE_ID_ROLE:
return node.id
if role == ACTIVE_ROLE:
return node.id == self._active_node_id
if role == PING_BUSY_ROLE:
return col == COL_PING and node.id in self._busy_ping_ids
if role == SPEED_PROGRESS_ROLE:
return self._speed_progress.get(node.id) if col == COL_SPEED else None
if role in (Qt.ItemDataRole.DisplayRole, Qt.ItemDataRole.EditRole):
return self.display_text(row, col)
if role == Qt.ItemDataRole.DecorationRole and col == COL_NAME:
return get_flag_icon(node_country(node))
if role == Qt.ItemDataRole.ToolTipRole:
return self.tooltip(node, col)
if role == Qt.ItemDataRole.ForegroundRole:
color = self.status_color(row, col)
return QBrush(color) if color is not None else None
if role == Qt.ItemDataRole.TextAlignmentRole and col in CENTERED_COLUMNS:
return int(Qt.AlignmentFlag.AlignCenter)
return None
def display_text(self, row: NodeRow, col: int) -> str:
node_id = row.node.id
if col == COL_PING and node_id in self._busy_ping_ids:
return ""
if col == COL_SPEED and node_id in self._speed_progress:
return ""
return self.texts(row)[col]
def status_color(self, row: NodeRow, col: int):
"""Цвет текста ячейки по статусу; None — обычный цвет темы.
У недоступного сервера красный только пинг, остальные ячейки
приглушены: иначе список из половины мёртвых серверов выглядит как
сплошная авария и перебивает рабочие строки.
"""
if col != COL_PING:
return text_muted_color() if row.node.is_alive is False else None
status = self.status(row)
if status == STATUS_ALIVE:
return positive_color()
if status == STATUS_DEGRADED:
return warning_color()
if status == STATUS_DEAD:
return error_color()
return None
def tooltip(self, node: Node, col: int) -> str | None:
endpoint = endpoint_text(node.server, node.port, self._endpoints_revealed)
if col == COL_NAME:
lines = [node.name or "Без имени", f"Тип: {node_type_text(node)}", f"Адрес: {endpoint}"]
if node.group:
lines.append(f"Группа: {node.group}")
if node.tags:
lines.append(f"Теги: {', '.join(node.tags)}")
lines.append(f"Источник: {self.source_name(node)}")
return "\n".join(lines)
if col == COL_ADDRESS:
return endpoint
if col == COL_PING:
if node.id in self._busy_ping_ids:
return "Проверка пинга..."
_, text = ping_status(node)
if text:
return f"Пинг: {node.ping_ms} ms\n{text}" if node.ping_ms is not None else text
return f"Пинг: {node.ping_ms} ms" if node.ping_ms is not None else None
if col == COL_SPEED and node.id in self._speed_progress:
return f"Тест скорости: {self._speed_progress[node.id]}%"
return None
# ── Раскладка ──────────────────────────────────────────
def _build_texts(self, node: Node) -> tuple[str, ...]:
return (
("★ " if node.is_favorite else "") + display_name(node.name),
node_type_text(node),
endpoint_text(node.server, node.port, self._endpoints_revealed),
node.group,
", ".join(node.tags),
"--" if node.ping_ms is None else f"{node.ping_ms} ms",
"--" if node.speed_mbps is None else f"{node.speed_mbps:.1f} MB/s",
_format_time(node.last_used_at),
self.source_name(node),
)
def _layout_sig(self, node: Node) -> tuple:
"""Значения ноды, от которых зависят фильтр, сортировка и группа."""
return (node.is_favorite, node.group, tuple(node.tags), node.subscription_id,
self._sort_value(node), self._group_of(node)[0],
self._haystack_source(node) if self._query else None)
def _layout_changed_for(self, node_ids: set[str]) -> bool:
changed = False
for nid in node_ids:
node = self._by_id.get(nid)
if node is None:
continue
sig = self._layout_sig(node)
if self._layout_sigs.get(nid) != sig:
self._layout_sigs[nid] = sig
changed = True
return changed
def _accepts(self, node: Node) -> bool:
if self._favorites_only and not node.is_favorite:
return False
if self._group_filter and node.group != self._group_filter:
return False
if self._tag_filter and self._tag_filter not in node.tags:
return False
if self._source_filter and self.source_name(node) != self._source_filter:
return False
if self._query:
haystack = self._haystacks.get(node.id)
if haystack is None:
haystack = self._haystacks[node.id] = self._haystack_source(node)
if self._query not in haystack:
return False
return True
def _haystack_source(self, node: Node) -> str:
return " ".join((node.name, node_type_text(node), node.server, node.group,
self.source_name(node), " ".join(node.tags))).lower()
def _sort_value(self, node: Node):
key = self._sort_key
if key == "manual":
return node.sort_order
if key == "type":
return node_type_text(node).casefold()
if key == "ping":
return node.ping_ms
if key == "speed":
return node.speed_mbps
value = {"name": node.name, "group": node.group, "last_used": node.last_used_at}[key]
return (value or "").casefold()
def _sorted(self, nodes: list[Node]) -> list[Node]:
if self._sort_key in ("ping", "speed"):
# Пустые метрики всегда в конце, в обоих направлениях.
known = [node for node in nodes if self._sort_value(node) is not None]
unknown = [node for node in nodes if self._sort_value(node) is None]
known.sort(key=self._sort_value, reverse=self._descending)
return known + unknown
return sorted(nodes, key=self._sort_value, reverse=self._descending)
def _group_of(self, node: Node) -> tuple[str, str]:
mode = self._group_mode
if mode == "none":
return "", ""
if mode == "source":
key = node.subscription_id or "local"
title = self._source_names.get(key, LOCAL_SOURCE_NAME if key == "local" else "Подписка")
return "source:" + key, title
if mode == "group":
return "group:" + node.group, node.group or "Без группы"
if mode == "country":
code = node_country(node)
return "country:" + code, code or "Страна не определена"
value = node_type_text(node)
return "type:" + value, value
def _relayout(self) -> None:
self._relayout_timer.stop()
visible = self._sorted([node for node in self._nodes if self._accepts(node)])
self._ordered_ids = [node.id for node in visible]
self._layout_sigs = {node.id: self._layout_sig(node) for node in self._nodes}
old_rows = {row.key: row for row in self._rows}
rows: list[NodeRow | GroupRow] = []
group_keys: list[str] = []
if self._group_mode == "none":
rows = [self._reuse_row(old_rows, node) for node in visible]
else:
groups: dict[str, tuple[str, list[Node]]] = {}
for node in visible:
key, title = self._group_of(node)
groups.setdefault(key, (title, []))[1].append(node)
for key, (title, members) in sorted(groups.items(), key=lambda item: (item[1][0].casefold(), item[0])):
collapsed = key in self._collapsed
group_keys.append(key)
rows.append(GroupRow(key, title, len(members), collapsed))
if not collapsed:
rows.extend(self._reuse_row(old_rows, node) for node in members)
self._apply_rows(rows)
self._group_keys = group_keys
self._visible_node_count = len(visible)
@staticmethod
def _reuse_row(old_rows: dict, node: Node) -> NodeRow:
row = old_rows.get("node:" + node.id)
if row is None or row.is_group:
return NodeRow(node)
row.node = node
return row
def _apply_rows(self, rows: list[NodeRow | GroupRow]) -> None:
new_keys = [row.key for row in rows]
old_keys = [row.key for row in self._rows]
if new_keys == old_keys:
self._rows = rows
self._repaint_all()
return
if set(new_keys) != set(old_keys):
# Состав строк изменился (фильтр, свёртка, импорт) — контракт Qt
# требует reset; выделение восстанавливает вид по ключам строк.
self.beginResetModel()
self._rows = rows
self._row_by_key = {key: index for index, key in enumerate(new_keys)}
self.endResetModel()
return
# Тот же состав, другой порядок — layoutChanged с переносом индексов,
# чтобы выделение и текущая строка поехали вместе со строками.
self.layoutAboutToBeChanged.emit()
persistent = self.persistentIndexList()
identities = [(self._rows[index.row()].key, index.column()) for index in persistent]
self._rows = rows
self._row_by_key = {key: index for index, key in enumerate(new_keys)}
self.changePersistentIndexList(
persistent,
[self.index(self._row_by_key[key], column) for key, column in identities],
)
self.layoutChanged.emit()
# ── Уведомления об изменениях ──────────────────────────
def _invalidate_nodes(self, node_ids) -> None:
rows = []
for nid in node_ids:
index = self._row_by_key.get("node:" + nid)
if index is not None:
item = self._rows[index]
item.texts = None
item.status = None
rows.append(index)
self._emit_rows(rows)
def _repaint_nodes(self, node_ids, column: int | None = None) -> None:
rows = [index for index in (self._row_by_key.get("node:" + nid) for nid in node_ids) if index is not None]
self._emit_rows(rows, column)
def _emit_rows(self, rows: list[int], column: int | None = None) -> None:
if not rows:
return
first_col, last_col = (column, column) if column is not None else (0, _COLUMN_COUNT - 1)
rows.sort()
start = previous = rows[0]
for row in rows[1:] + [None]:
if row is not None and row == previous + 1:
previous = row
continue
self.dataChanged.emit(self.index(start, first_col), self.index(previous, last_col))
if row is not None:
start = previous = row
def _repaint_all(self) -> None:
if self._rows:
self.dataChanged.emit(self.index(0, 0), self.index(len(self._rows) - 1, _COLUMN_COUNT - 1))
def _on_theme_changed(self, *_args) -> None:
self._bold_font = None
self._repaint_all()
def _bold(self) -> QFont:
if self._bold_font is None:
self._bold_font = QFont()
self._bold_font.setBold(True)
return self._bold_font