zapret-kvn/xray_fluent/ui/nodes_table_model.py
loop-uh ab0b7897fc
All checks were successful
Windows project source guards / test (push) Successful in 1m30s
fix: preserve interactive server table and bound DNS fallback readiness
2026-09-06 02:07:48 +03:00

549 lines
20 KiB
Python

from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
from PyQt6.QtCore import QAbstractTableModel, QModelIndex, Qt
from PyQt6.QtGui import QBrush
from qfluentwidgets import qconfig
from ..profiles.country_flags import get_flag_icon
from ..profiles.models import Node
from ..profiles.node_presentation import node_country, display_name
from .privacy import endpoint_text
from .theme import error_color, success_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
FILTER_FIELDS_ROLE = int(Qt.ItemDataRole.UserRole) + 5
def _dead_brush() -> QBrush:
"""Brush for unreachable nodes; resolved lazily from the theme tokens."""
return QBrush(error_color())
def _alive_brush() -> QBrush:
return QBrush(success_color())
def _degraded_brush() -> QBrush:
return QBrush(warning_color())
(
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]
_CENTERED_COLUMNS = tuple(
index for index, spec in enumerate(COLUMN_SPECS) if spec.centered
)
_ROW_ROLES = [
Qt.ItemDataRole.DisplayRole,
Qt.ItemDataRole.ToolTipRole,
Qt.ItemDataRole.ForegroundRole,
PING_BUSY_ROLE,
SPEED_PROGRESS_ROLE,
]
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 _contiguous_ranges(rows: list[int]) -> list[tuple[int, int]]:
"""Group sorted row indexes into contiguous (first, last) ranges."""
ranges: list[tuple[int, int]] = []
for row in rows:
if ranges and ranges[-1][1] == row - 1:
ranges[-1] = (ranges[-1][0], row)
else:
ranges.append((row, row))
return ranges
class NodesTableModel(QAbstractTableModel):
def __init__(self, parent=None):
super().__init__(parent)
self._nodes: list[Node] = []
self._id_to_row: dict[str, int] = {}
self._snapshots = {}
self._busy_ping_ids: set[str] = set()
self._speed_progress: dict[str, int] = {}
self._source_names: dict[str, str] = {}
self._active_node_id: str | None = None
self._endpoints_revealed = False
# Status brushes depend on the theme: repaint foregrounds on change.
qconfig.themeChanged.connect(self._on_theme_changed)
def _on_theme_changed(self) -> None:
if not self._nodes:
return
self.dataChanged.emit(
self.index(0, 0),
self.index(len(self._nodes) - 1, len(_HEADERS) - 1),
[Qt.ItemDataRole.ForegroundRole],
)
def set_source_names(self, names: dict[str, str]) -> None:
self._source_names = dict(names)
def set_nodes(self, nodes: list[Node]) -> None:
self.beginResetModel()
self._nodes = list(nodes)
self._id_to_row = {node.id: row for row, node in enumerate(self._nodes)}
self._snapshots = {node.id: self._snapshot(node) for node in self._nodes}
self.endResetModel()
@staticmethod
def _snapshot(node):
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 update_nodes(self, nodes: list[Node]) -> None:
"""Diff-update by node id: no modelReset for point changes."""
new_nodes = list(nodes)
if not self._nodes or not new_nodes:
self.set_nodes(new_nodes)
return
new_by_id = {node.id: node for node in new_nodes}
old_ids = {node.id for node in self._nodes}
removed_rows = [
row for row, node in enumerate(self._nodes) if node.id not in new_by_id
]
added = [node for node in new_nodes if node.id not in old_ids]
# Fallback to a full reset when more than 50% of rows changed.
if 2 * (len(removed_rows) + len(added)) > max(len(self._nodes), len(new_nodes)):
self.set_nodes(new_nodes)
return
# Remove contiguous ranges in descending order.
for first, last in reversed(_contiguous_ranges(removed_rows)):
self.beginRemoveRows(QModelIndex(), first, last)
del self._nodes[first : last + 1]
self._id_to_row = {node.id: row for row, node in enumerate(self._nodes)}
self.endRemoveRows()
# Refresh surviving node objects (instances may be recreated upstream).
for row, node in enumerate(self._nodes):
self._nodes[row] = new_by_id[node.id]
if added:
first = len(self._nodes)
self.beginInsertRows(QModelIndex(), first, first + len(added) - 1)
self._nodes.extend(added)
self._id_to_row = {node.id: row for row, node in enumerate(self._nodes)}
self.endInsertRows()
else:
self._id_to_row = {node.id: row for row, node in enumerate(self._nodes)}
snapshots = {node.id: self._snapshot(node) for node in self._nodes}
changed = [row for row, node in enumerate(self._nodes)
if self._snapshots.get(node.id) != snapshots[node.id]]
self._snapshots = snapshots
for first, last in _contiguous_ranges(changed):
self.dataChanged.emit(self.index(first, 0), self.index(last, len(_HEADERS) - 1))
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
for nid in (previous, node_id):
if nid is None:
continue
row = self._id_to_row.get(nid)
if row is None:
continue
self.dataChanged.emit(
self.index(row, 0),
self.index(row, len(_HEADERS) - 1),
[Qt.ItemDataRole.FontRole, ACTIVE_ROLE],
)
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
if not self._nodes:
return
roles = [Qt.ItemDataRole.DisplayRole, Qt.ItemDataRole.ToolTipRole]
self.dataChanged.emit(
self.index(0, COL_ADDRESS),
self.index(len(self._nodes) - 1, COL_ADDRESS),
roles,
)
# The name tooltip also contains the endpoint.
self.dataChanged.emit(
self.index(0, COL_NAME),
self.index(len(self._nodes) - 1, COL_NAME),
[Qt.ItemDataRole.ToolTipRole],
)
def endpoints_revealed(self) -> bool:
return self._endpoints_revealed
def set_ping_busy(self, node_id: str, busy: bool) -> None:
changed = False
if busy:
if node_id not in self._busy_ping_ids:
self._busy_ping_ids.add(node_id)
changed = True
else:
if node_id in self._busy_ping_ids:
self._busy_ping_ids.discard(node_id)
changed = True
if changed:
self._emit_cell_changed(node_id, COL_PING)
def set_ping_busy_ids(self, node_ids: set[str]) -> None:
node_ids = set(node_ids)
if node_ids == self._busy_ping_ids:
return
self._busy_ping_ids = node_ids
self._emit_column_changed(COL_PING)
def clear_ping_busy(self) -> None:
if not self._busy_ping_ids:
return
self._busy_ping_ids.clear()
self._emit_column_changed(COL_PING)
def set_speed_progress(self, node_id: str, percent: int | None) -> None:
if percent is None:
if node_id not in self._speed_progress:
return
self._speed_progress.pop(node_id, None)
else:
percent = max(0, min(100, int(percent)))
if self._speed_progress.get(node_id) == percent:
return
self._speed_progress[node_id] = percent
self._emit_cell_changed(node_id, COL_SPEED)
def set_speed_progress_batch(self, progress: dict[str, int]) -> None:
changed_rows: list[int] = []
for node_id, percent in progress.items():
percent = max(0, min(100, int(percent)))
if self._speed_progress.get(node_id) == percent:
continue
self._speed_progress[node_id] = percent
row = self._id_to_row.get(node_id)
if row is not None:
changed_rows.append(row)
if not changed_rows:
return
self.dataChanged.emit(
self.index(min(changed_rows), COL_SPEED),
self.index(max(changed_rows), COL_SPEED),
[Qt.ItemDataRole.DisplayRole, Qt.ItemDataRole.ToolTipRole, SPEED_PROGRESS_ROLE],
)
def clear_speed_progress(self) -> None:
if not self._speed_progress:
return
self._speed_progress.clear()
self._emit_column_changed(COL_SPEED)
def row_for_node(self, node_id: str) -> int | None:
return self._id_to_row.get(node_id)
def node_at_row(self, row: int) -> Node | None:
if 0 <= row < len(self._nodes):
return self._nodes[row]
return None
def rowCount(self, parent: QModelIndex = QModelIndex()) -> int:
if parent.isValid():
return 0
return len(self._nodes)
def columnCount(self, parent: QModelIndex = QModelIndex()) -> int:
if parent.isValid():
return 0
return len(_HEADERS)
def headerData(self, section: int, orientation: Qt.Orientation, role: int = Qt.ItemDataRole.DisplayRole):
if role == Qt.ItemDataRole.TextAlignmentRole and orientation == Qt.Orientation.Horizontal:
return int(Qt.AlignmentFlag.AlignCenter if section in _CENTERED_COLUMNS else Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter)
if role == Qt.ItemDataRole.DisplayRole and orientation == Qt.Orientation.Horizontal:
if 0 <= section < len(_HEADERS):
return _HEADERS[section]
return super().headerData(section, orientation, role)
def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole):
if not index.isValid():
return None
row = index.row()
col = index.column()
if row < 0 or row >= len(self._nodes):
return None
node = self._nodes[row]
if role == NODE_ID_ROLE:
return node.id
if role == ACTIVE_ROLE:
return self._active_node_id is not None and 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:
if col == COL_SPEED:
return self._speed_progress.get(node.id)
return None
if role in (Qt.ItemDataRole.DisplayRole, Qt.ItemDataRole.EditRole):
return self._display_text(node, 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_text(node, col)
if role == Qt.ItemDataRole.ForegroundRole:
return self._foreground_brush(node, col)
if role == Qt.ItemDataRole.TextAlignmentRole and col in _CENTERED_COLUMNS:
return int(Qt.AlignmentFlag.AlignCenter)
return None
def refresh_ping(self, node_id: str) -> None:
self.finish_ping_batch({node_id})
def finish_ping(self, node_id: str) -> None:
self.finish_ping_batch({node_id})
def refresh_countries(self, node_ids) -> None:
rows = [self._id_to_row[nid] for nid in node_ids if nid in self._id_to_row]
if not rows:
return
for row in rows:
node = self._nodes[row]
self._snapshots[node.id] = self._snapshot(node)
self.dataChanged.emit(self.index(min(rows), COL_NAME), self.index(max(rows), COL_NAME),
[Qt.ItemDataRole.DecorationRole, Qt.ItemDataRole.ToolTipRole])
def finish_ping_batch(self, node_ids: set[str]) -> None:
"""Flush a batch of ping results with a single dataChanged emission."""
if not node_ids:
return
self._busy_ping_ids -= set(node_ids)
rows = [
row for row in (self._id_to_row.get(nid) for nid in node_ids) if row is not None
]
if not rows:
return
self.dataChanged.emit(self.index(min(rows), COL_PING), self.index(max(rows), COL_PING),
[Qt.ItemDataRole.DisplayRole, Qt.ItemDataRole.ToolTipRole, PING_BUSY_ROLE])
self.dataChanged.emit(self.index(min(rows), 0), self.index(max(rows), len(_HEADERS) - 1),
[Qt.ItemDataRole.ForegroundRole])
def refresh_speed(self, node_id: str) -> None:
self._emit_cell_changed(node_id, COL_SPEED)
def finish_speed(self, node_id: str) -> None:
self._speed_progress.pop(node_id, None)
self._emit_row_changed(node_id)
def refresh_alive_status(self, node_id: str) -> None:
self._emit_row_changed(node_id)
def _emit_row_changed(self, node_id: str) -> None:
row = self._id_to_row.get(node_id)
if row is None:
return
top_left = self.index(row, 0)
bottom_right = self.index(row, len(_HEADERS) - 1)
self.dataChanged.emit(top_left, bottom_right, _ROW_ROLES)
def _display_text(self, node: Node, col: int) -> str:
if col == COL_NAME:
return ("★ " if node.is_favorite else "") + display_name(node.name)
if col == COL_TYPE:
return node_type_text(node)
if col == COL_ADDRESS:
return endpoint_text(node.server, node.port, self._endpoints_revealed)
if col == COL_GROUP:
return node.group
if col == COL_TAGS:
return ", ".join(node.tags)
if col == COL_PING:
if node.id in self._busy_ping_ids:
return ""
return "--" if node.ping_ms is None else f"{node.ping_ms} ms"
if col == COL_SPEED:
if node.id in self._speed_progress:
return ""
return "--" if node.speed_mbps is None else f"{node.speed_mbps:.1f} MB/s"
if col == COL_LAST_USED:
return NodesTableModel._format_time(node.last_used_at)
if col == COL_SOURCE:
return self._source_name(node)
return ""
def _source_name(self, node: Node) -> str:
if not node.subscription_id:
return "Локальные"
return self._source_names.get(node.subscription_id, "Подписка")
def _tooltip_text(self, node: Node, col: int) -> str | None:
if col == COL_NAME:
lines = [node.name or "Без имени", f"Тип: {node_type_text(node)}"]
lines.append(
f"Адрес: {endpoint_text(node.server, node.port, self._endpoints_revealed)}"
)
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_text(node.server, node.port, self._endpoints_revealed)
if col == COL_PING:
if node.id in self._busy_ping_ids:
return "Проверка пинга..."
status_tooltip, _ = NodesTableModel._ping_status_meta(node)
if status_tooltip:
if node.ping_ms is not None:
return f"Пинг: {node.ping_ms} ms\n{status_tooltip}"
return status_tooltip
if node.ping_ms is not None:
return f"Пинг: {node.ping_ms} ms"
return None
if col == COL_SPEED and node.id in self._speed_progress:
return f"Тест скорости: {self._speed_progress[node.id]}%"
return None
@staticmethod
def _foreground_brush(node: Node, col: int) -> QBrush | None:
if node.is_alive is False:
return _dead_brush()
if col == COL_PING:
_, brush = NodesTableModel._ping_status_meta(node)
return brush
return None
@staticmethod
def _ping_status_meta(node: Node) -> tuple[str | None, QBrush | None]:
"""Status semantics rendered onto the ping cell (former «Статус» column)."""
if node.is_alive is None:
return None, None
if node.ping_ms is not None and node.speed_mbps is None and node.is_alive:
if node.speed_history:
return (
"Пинг есть, скорость нет — вероятно заблокирован провайдером",
_degraded_brush(),
)
return None, None
if node.is_alive:
return "Сервер работает", _alive_brush()
return "Сервер недоступен", _dead_brush()
@staticmethod
def _format_time(value: str | None) -> str:
if not value:
return ""
try:
dt = datetime.fromisoformat(value.replace("Z", "+00:00"))
return dt.strftime("%Y-%m-%d %H:%M")
except ValueError:
return value
def _emit_cell_changed(self, node_id: str, column: int) -> None:
row = self._id_to_row.get(node_id)
if row is None:
return
index = self.index(row, column)
self.dataChanged.emit(index, index, _ROW_ROLES)
def _emit_column_changed(self, column: int) -> None:
if not self._nodes:
return
self.dataChanged.emit(
self.index(0, column),
self.index(len(self._nodes) - 1, column),
[
Qt.ItemDataRole.DisplayRole,
Qt.ItemDataRole.ToolTipRole,
PING_BUSY_ROLE,
SPEED_PROGRESS_ROLE,
],
)