zapret-kvn/xray_fluent/ui/zapret_browser.py
loop-uh 672e41fdd1
Some checks failed
Windows project source guards / test (push) Failing after 28s
Zapret: макет с нуля — список ↔ карточка вместо выпадающих списков
- Пресет выбирается на странице пресетов: поиск, в строке — что пресет
  обходит (цели из --hostlist/--ipset/--hostlist-domains/--filter-l7 и
  «остальной трафик»), в карточке — цели, порты перехвата, профили и
  кнопки «Выбрать», «Изменить», «Удалить». Выпадающий список на 33
  пункта, не влезавший в окно, убран.
- Стратегия вида сервера: поиск, фильтр по метке с количеством, строки
  «название + описание» с галочкой у используемой; карточка с описанием,
  автором и аргументами по параметрам, кнопка «Использовать».
  Подсветка ничего не меняет, кнопка и переключатель обхода применяют
  сразу — отдельного «Применить» больше нет.
- На узком окне карточка уходит под список, ничего не обрезается.
- Сводка пресета считается при чтении списка (presets.summarize_arguments).
2026-09-28 21:28:46 +03:00

639 lines
25 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.

"""List ↔ card browsers of the Zapret section: strategies and presets.
Both catalogs are too long for a combo box (≈470 strategies, ≈30 presets), so
they share one shape: a searchable list of two-line rows on the left and a card
describing the highlighted item on the right, with the action button in the
card. Highlighting only shows an item; the button makes it the one in use,
marked by a check in the list. On a narrow window the card goes under the list.
"""
from __future__ import annotations
from typing import Mapping
from PyQt6.QtCore import QModelIndex, QPointF, QRect, QRectF, QSize, Qt, QTimer, pyqtSignal
from PyQt6.QtGui import QColor, QFont, QPainter
from PyQt6.QtWidgets import (
QBoxLayout,
QHBoxLayout,
QListWidgetItem,
QSizePolicy,
QStyleOptionViewItem,
QVBoxLayout,
QWidget,
)
from qfluentwidgets import (
BodyLabel,
CaptionLabel,
CardWidget,
ComboBox,
FluentIcon as FIF,
ListWidget,
PlainTextEdit,
PrimaryPushButton,
SearchLineEdit,
StrongBodyLabel,
SubtitleLabel,
TransparentPushButton,
)
from qfluentwidgets.components.widgets.list_view import ListItemDelegate
from ..engines.zapret.presets import PresetInfo
from ..engines.zapret.strategies import (
CUSTOM_STRATEGY_ID,
STRATEGY_LABEL_TITLES,
STRATEGY_LABELS,
Strategy,
validate_custom_strategy,
)
from .singbox.art import draw_icon
from .theme import (
accent_color,
error_color,
info_color,
positive_color,
text_color,
text_muted_color,
warning_color,
)
ROW_HEIGHT = 50
#: Below this width the card moves under the list.
STACK_WIDTH = 820
_SEARCH_DEBOUNCE_MS = 150
ID_ROLE = Qt.ItemDataRole.UserRole
SUBTITLE_ROLE = Qt.ItemDataRole.UserRole + 1
CHIP_ROLE = Qt.ItemDataRole.UserRole + 2
TONE_ROLE = Qt.ItemDataRole.UserRole + 3
MARK_ROLE = Qt.ItemDataRole.UserRole + 4
_UNLABELED = "-"
_CUSTOM_TITLE = "Своя стратегия"
def tone_color(tone: str) -> QColor:
"""Colour of a chip: catalog labels and the «Работает» state."""
if tone == "running":
return accent_color()
if tone == "recommended":
return positive_color()
if tone == "caution":
return error_color()
if tone == "experimental":
return warning_color()
if tone == "game":
return info_color()
if tone == "stable":
return accent_color()
return text_muted_color()
class TwoLineDelegate(ListItemDelegate):
"""Check mark · title over a muted subtitle · optional chip on the right."""
def sizeHint(self, option: QStyleOptionViewItem, index: QModelIndex) -> QSize:
return QSize(super().sizeHint(option, index).width(), ROW_HEIGHT)
def initStyleOption(self, option: QStyleOptionViewItem, index: QModelIndex) -> None:
super().initStyleOption(option, index)
option.text = "" # drawn below in two lines
def paint(self, painter: QPainter, option: QStyleOptionViewItem, index: QModelIndex) -> None:
super().paint(painter, option, index)
title = str(index.data(Qt.ItemDataRole.DisplayRole) or "")
subtitle = str(index.data(SUBTITLE_ROLE) or "")
chip = str(index.data(CHIP_ROLE) or "")
tone = str(index.data(TONE_ROLE) or "")
marked = bool(index.data(MARK_ROLE))
painter.save()
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
rect = option.rect
small = QFont(option.font)
small.setPointSizeF(max(7.5, option.font.pointSizeF() - 1.5))
if marked:
draw_icon(painter, FIF.ACCEPT, QPointF(rect.left() + 22, rect.center().y()), 14, accent_color())
right = rect.right() - 12
if chip:
painter.setFont(small)
width = painter.fontMetrics().horizontalAdvance(chip) + 16
chip_rect = QRect(right - width, rect.center().y() - 9, width, 18)
color = tone_color(tone)
fill = QColor(color)
fill.setAlpha(40)
painter.setPen(Qt.PenStyle.NoPen)
painter.setBrush(fill)
painter.drawRoundedRect(QRectF(chip_rect), 9, 9)
painter.setPen(color)
painter.drawText(chip_rect, int(Qt.AlignmentFlag.AlignCenter), chip)
right -= width + 10
left = rect.left() + 40
width = max(40, right - left)
top_half = QRect(left, rect.top() + 6, width, rect.height() // 2 - 4)
bottom_half = QRect(left, rect.center().y() + 1, width, rect.height() // 2 - 6)
title_font = QFont(option.font)
title_font.setBold(marked)
painter.setFont(title_font)
painter.setPen(accent_color() if marked else text_color())
painter.drawText(
top_half if subtitle else rect.adjusted(left - rect.left(), 0, 0, 0),
int(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter),
painter.fontMetrics().elidedText(title, Qt.TextElideMode.ElideRight, width),
)
if subtitle:
painter.setFont(small)
painter.setPen(text_muted_color())
painter.drawText(
bottom_half,
int(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter),
painter.fontMetrics().elidedText(subtitle, Qt.TextElideMode.ElideRight, width),
)
painter.restore()
def new_list(parent: QWidget) -> ListWidget:
view = ListWidget(parent)
view.setItemDelegate(TwoLineDelegate(view))
view.setUniformItemSizes(True)
# Pixel scrolling parks the view mid-row; whole rows read as intended.
view.setVerticalScrollMode(ListWidget.ScrollMode.ScrollPerItem)
view.setMinimumHeight(ROW_HEIGHT * 5)
view.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
return view
class SplitView(QWidget):
"""List on the left, card on the right; stacked when the window is narrow."""
def __init__(self, master: QWidget, detail: QWidget, parent: QWidget | None = None):
super().__init__(parent)
self.master = master
self.detail = detail
self._layout = QBoxLayout(QBoxLayout.Direction.LeftToRight, self)
self._layout.setContentsMargins(0, 0, 0, 0)
self._layout.setSpacing(12)
self._layout.addWidget(master, 3)
self._layout.addWidget(detail, 2)
detail.setMinimumWidth(280)
@property
def stacked(self) -> bool:
return self._layout.direction() == QBoxLayout.Direction.TopToBottom
def resizeEvent(self, event) -> None:
super().resizeEvent(event)
stacked = self.width() < STACK_WIDTH
if stacked != self.stacked:
self._layout.setDirection(
QBoxLayout.Direction.TopToBottom if stacked else QBoxLayout.Direction.LeftToRight
)
# Under the list the card takes only the height it needs.
self._layout.setStretchFactor(self.detail, 0 if stacked else 2)
self.detail.setSizePolicy(
QSizePolicy.Policy.Preferred,
QSizePolicy.Policy.Maximum if stacked else QSizePolicy.Policy.Preferred,
)
def _card(parent: QWidget) -> tuple[CardWidget, QVBoxLayout]:
card = CardWidget(parent)
layout = QVBoxLayout(card)
layout.setContentsMargins(18, 16, 18, 16)
layout.setSpacing(8)
return card, layout
def readable_arguments(args: tuple[str, ...]) -> str:
"""One parameter per line: ``--lua-desync=fake`` then `` blob=tls_google``…
A desync line is one long token that a label cannot wrap; split at its
``:`` parameters it fits the card and reads as a recipe.
"""
lines: list[str] = []
for arg in args:
head, _, params = arg.partition(":")
lines.append(head)
lines.extend(f" {param}" for param in params.split(":") if params)
return "\n".join(lines)
def _wrapped(label: QWidget) -> QWidget:
label.setWordWrap(True)
label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
return label
# ── strategies ───────────────────────────────────────────────────────────────
class StrategyBrowser(QWidget):
"""Pick the strategy a server kind uses; the button in the card applies it."""
chosen = pyqtSignal(str, str) # strategy id, own strategy text
def __init__(self, parent: QWidget | None = None):
super().__init__(parent)
self._entries: dict[str, Strategy] = {}
self._chosen_id = ""
self._custom_text = ""
self._highlight = ""
self._ids: list[str] = []
master = QWidget(self)
column = QVBoxLayout(master)
column.setContentsMargins(0, 0, 0, 0)
column.setSpacing(8)
tools = QHBoxLayout()
tools.setSpacing(8)
self.search = SearchLineEdit(master)
self.search.setPlaceholderText("Поиск по названию, описанию, аргументам")
self.search.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
tools.addWidget(self.search, 1)
self.group = ComboBox(master)
self.group.setMinimumWidth(190)
tools.addWidget(self.group)
column.addLayout(tools)
self.count_label = CaptionLabel("", master)
column.addWidget(self.count_label)
self.list = new_list(master)
column.addWidget(self.list, 1)
detail, card = _card(self)
self.title_label = SubtitleLabel("", detail)
self.title_label.setWordWrap(True)
card.addWidget(self.title_label)
self.meta_label = CaptionLabel("", detail)
card.addWidget(self.meta_label)
self.description_label = _wrapped(BodyLabel("", detail))
card.addWidget(self.description_label)
self.args_title = StrongBodyLabel("Аргументы winws2", detail)
card.addWidget(self.args_title)
self.args_label = _wrapped(CaptionLabel("", detail))
mono = QFont("Consolas")
mono.setStyleHint(QFont.StyleHint.Monospace)
mono.setPointSizeF(max(8.0, self.args_label.font().pointSizeF() - 0.5))
self.args_label.setFont(mono)
card.addWidget(self.args_label)
self.custom_edit = PlainTextEdit(detail)
self.custom_edit.setPlaceholderText("# комментарий\n--payload=tls_client_hello\n--lua-desync=...")
self.custom_edit.setFont(mono)
self.custom_edit.setMinimumHeight(140)
card.addWidget(self.custom_edit, 1)
self.error_label = _wrapped(CaptionLabel("", detail))
card.addWidget(self.error_label)
card.addStretch(1)
self.use_btn = PrimaryPushButton(FIF.ACCEPT, "Использовать", detail)
self.use_btn.clicked.connect(self._use)
card.addWidget(self.use_btn)
self.split = SplitView(master, detail, self)
outer = QVBoxLayout(self)
outer.setContentsMargins(0, 0, 0, 0)
outer.addWidget(self.split)
self._debounce = QTimer(self)
self._debounce.setSingleShot(True)
self._debounce.setInterval(_SEARCH_DEBOUNCE_MS)
self._debounce.timeout.connect(self._rebuild)
self.search.textChanged.connect(lambda _text: self._debounce.start())
self.group.currentIndexChanged.connect(lambda _index: self._rebuild())
self.list.currentRowChanged.connect(self._on_row)
self.custom_edit.textChanged.connect(self._sync_button)
# ── public API ──
def set_entries(self, entries: Mapping[str, Strategy]) -> None:
"""Load a catalog; the group filter lists every label with its count."""
self._entries = dict(entries)
counts: dict[str, int] = {}
for entry in self._entries.values():
key = entry.label if entry.label in STRATEGY_LABELS else _UNLABELED
counts[key] = counts.get(key, 0) + 1
self.group.blockSignals(True)
self.group.clear()
self.group.addItem(f"Все · {len(self._entries)}", userData="")
for label in (*STRATEGY_LABELS, _UNLABELED):
if counts.get(label):
title = STRATEGY_LABEL_TITLES.get(label, "Без метки")
self.group.addItem(f"{title} · {counts[label]}", userData=label)
self.group.setCurrentIndex(0)
self.group.blockSignals(False)
self.search.blockSignals(True)
self.search.clear()
self.search.blockSignals(False)
self._rebuild()
def set_chosen(self, strategy_id: str, custom_text: str) -> None:
"""The strategy in use; the list opens on it."""
self._chosen_id = strategy_id
self._custom_text = custom_text
self.custom_edit.blockSignals(True)
self.custom_edit.setPlainText(custom_text)
self.custom_edit.blockSignals(False)
self._highlight = strategy_id
self._rebuild()
def chosen_id(self) -> str:
return self._chosen_id
def highlighted_id(self) -> str:
return self._highlight
def highlight(self, strategy_id: str) -> None:
self._highlight = strategy_id
self._sync_list()
self._show()
# ── list ──
def _visible(self) -> list[Strategy]:
query = self.search.text().strip().casefold()
group = str(self.group.currentData() or "")
rank = {label: index for index, label in enumerate(STRATEGY_LABELS)}
result = [
entry for entry in self._entries.values()
if (not group or (entry.label if entry.label in rank else _UNLABELED) == group)
and (not query or query in entry.search_haystack)
]
result.sort(key=lambda entry: (rank.get(entry.label, len(rank)), entry.name.casefold()))
return result
def _rebuild(self) -> None:
entries = self._visible()
query = self.search.text().strip().casefold()
self.list.blockSignals(True)
self.list.clear()
self._ids = []
if not query or query in _CUSTOM_TITLE.casefold():
item = QListWidgetItem(_CUSTOM_TITLE)
item.setData(SUBTITLE_ROLE, "Свои строки --lua-desync из инструкций к zapret")
self._add(item, CUSTOM_STRATEGY_ID)
for entry in entries:
item = QListWidgetItem(entry.name)
item.setData(SUBTITLE_ROLE, entry.description or " ".join(entry.args))
if entry.label in STRATEGY_LABEL_TITLES:
item.setData(CHIP_ROLE, entry.label_title)
item.setData(TONE_ROLE, entry.label)
self._add(item, entry.strategy_id)
self.list.blockSignals(False)
self.count_label.setText(f"Показано {len(entries)} из {len(self._entries)}")
self._sync_list()
self._show()
def _add(self, item: QListWidgetItem, strategy_id: str) -> None:
item.setData(ID_ROLE, strategy_id)
item.setData(MARK_ROLE, strategy_id == self._chosen_id)
item.setSizeHint(QSize(0, ROW_HEIGHT))
self.list.addItem(item)
self._ids.append(strategy_id)
def _sync_list(self) -> None:
"""Keep the highlight on its row without letting a filter move it."""
self.list.blockSignals(True)
for row, strategy_id in enumerate(self._ids):
self.list.item(row).setData(MARK_ROLE, strategy_id == self._chosen_id)
if self._highlight in self._ids:
row = self._ids.index(self._highlight)
self.list.setCurrentRow(row)
self.list.scrollToItem(self.list.item(row), ListWidget.ScrollHint.PositionAtCenter)
else:
self.list.setCurrentRow(-1)
self.list.blockSignals(False)
def _on_row(self, row: int) -> None:
if 0 <= row < len(self._ids):
self._highlight = self._ids[row]
self._show()
# ── card ──
def _show(self) -> None:
strategy_id = self._highlight
custom = strategy_id == CUSTOM_STRATEGY_ID
entry = self._entries.get(strategy_id)
self.error_label.setText("")
self.custom_edit.setVisible(custom)
self.args_title.setVisible(entry is not None)
self.args_label.setVisible(entry is not None)
if custom:
self.title_label.setText(_CUSTOM_TITLE)
self.meta_label.setText("")
self.description_label.setText(
"Строки --lua-desync=…, --payload=…, --out-range=… и комментарии #. "
"Порты, адрес сервера и нужные блобы приложение добавит само."
)
elif entry is not None:
self.title_label.setText(entry.name)
meta = [entry.label_title] if entry.label_title else []
if entry.author:
meta.append(f"автор {entry.author}")
self.meta_label.setText(" · ".join(meta))
parts = [entry.description or "Описания нет."]
if entry.blob_dependencies:
parts.append("Блобы: " + ", ".join(entry.blob_dependencies))
self.description_label.setText("\n".join(parts))
self.args_label.setText(readable_arguments(entry.args))
else:
self.title_label.setText("Стратегия не выбрана")
self.meta_label.setText("")
self.description_label.setText(
f"«{strategy_id}» нет в каталоге." if strategy_id else "Выберите стратегию в списке."
)
self.meta_label.setVisible(bool(self.meta_label.text()))
self._sync_button()
def _sync_button(self) -> None:
strategy_id = self._highlight
custom = strategy_id == CUSTOM_STRATEGY_ID
known = custom or strategy_id in self._entries
in_use = strategy_id == self._chosen_id and (
not custom or self.custom_edit.toPlainText() == self._custom_text
)
self.use_btn.setEnabled(known and not in_use)
self.use_btn.setText("Используется" if in_use else "Использовать")
def _use(self) -> None:
strategy_id = self._highlight
text = self.custom_edit.toPlainText()
if strategy_id == CUSTOM_STRATEGY_ID:
try:
validate_custom_strategy(text)
except ValueError as exc:
self.error_label.setText(str(exc))
return
else:
text = self._custom_text # a catalog choice keeps the saved own text
self._chosen_id = strategy_id
self._custom_text = text
self._sync_list()
self._sync_button()
self.chosen.emit(strategy_id, text)
# ── presets ──────────────────────────────────────────────────────────────────
class PresetBrowser(QWidget):
"""Pick the preset winws2 runs; edit, delete or start it from the card."""
chosen = pyqtSignal(str)
edit_requested = pyqtSignal(str)
delete_requested = pyqtSignal(str)
def __init__(self, parent: QWidget | None = None):
super().__init__(parent)
self._presets: list[PresetInfo] = []
self._chosen = ""
self._running = ""
self._highlight = ""
self._ids: list[str] = []
master = QWidget(self)
column = QVBoxLayout(master)
column.setContentsMargins(0, 0, 0, 0)
column.setSpacing(8)
self.search = SearchLineEdit(master)
self.search.setPlaceholderText("Поиск по названию и целям")
column.addWidget(self.search)
self.count_label = CaptionLabel("", master)
column.addWidget(self.count_label)
self.list = new_list(master)
column.addWidget(self.list, 1)
detail, card = _card(self)
self.title_label = SubtitleLabel("", detail)
self.title_label.setWordWrap(True)
card.addWidget(self.title_label)
self.meta_label = CaptionLabel("", detail)
card.addWidget(self.meta_label)
self.description_label = _wrapped(BodyLabel("", detail))
card.addWidget(self.description_label)
card.addWidget(StrongBodyLabel("Что обходит", detail))
self.targets_label = _wrapped(BodyLabel("", detail))
card.addWidget(self.targets_label)
card.addWidget(StrongBodyLabel("Перехват", detail))
self.ports_label = _wrapped(CaptionLabel("", detail))
card.addWidget(self.ports_label)
card.addStretch(1)
self.choose_btn = PrimaryPushButton(FIF.ACCEPT, "Выбрать", detail)
self.choose_btn.clicked.connect(lambda: self.chosen.emit(self._highlight))
card.addWidget(self.choose_btn)
actions = QHBoxLayout()
self.edit_btn = TransparentPushButton(FIF.EDIT, "Изменить", detail)
self.edit_btn.clicked.connect(lambda: self.edit_requested.emit(self._highlight))
self.delete_btn = TransparentPushButton(FIF.DELETE, "Удалить", detail)
self.delete_btn.clicked.connect(lambda: self.delete_requested.emit(self._highlight))
actions.addWidget(self.edit_btn)
actions.addWidget(self.delete_btn)
actions.addStretch(1)
card.addLayout(actions)
self.split = SplitView(master, detail, self)
outer = QVBoxLayout(self)
outer.setContentsMargins(0, 0, 0, 0)
outer.addWidget(self.split)
self.search.textChanged.connect(lambda _text: self._rebuild())
self.list.currentRowChanged.connect(self._on_row)
def set_presets(self, presets: list[PresetInfo], chosen: str, running: str) -> None:
self._presets = list(presets)
self._chosen = chosen
self._running = running
if not self._highlight or self._highlight not in {preset.name for preset in presets}:
self._highlight = chosen
self._rebuild()
def highlight(self, name: str) -> None:
self._highlight = name
self._sync_list()
self._show()
def highlighted(self) -> str:
return self._highlight
def _rebuild(self) -> None:
query = self.search.text().strip().casefold()
shown = [
preset for preset in self._presets
if not query
or query in preset.name.casefold()
or query in " ".join(preset.summary.targets).casefold()
]
self.list.blockSignals(True)
self.list.clear()
self._ids = []
for preset in shown:
item = QListWidgetItem(preset.name)
item.setData(SUBTITLE_ROLE, preset.description or preset.summary.short())
item.setData(ID_ROLE, preset.name)
if preset.name == self._running:
item.setData(CHIP_ROLE, "Работает")
item.setData(TONE_ROLE, "running")
item.setSizeHint(QSize(0, ROW_HEIGHT))
self.list.addItem(item)
self._ids.append(preset.name)
self.list.blockSignals(False)
self.count_label.setText(f"Показано {len(shown)} из {len(self._presets)}")
self._sync_list()
self._show()
def _sync_list(self) -> None:
self.list.blockSignals(True)
for row, name in enumerate(self._ids):
self.list.item(row).setData(MARK_ROLE, name == self._chosen)
if self._highlight in self._ids:
row = self._ids.index(self._highlight)
self.list.setCurrentRow(row)
self.list.scrollToItem(self.list.item(row), ListWidget.ScrollHint.PositionAtCenter)
else:
self.list.setCurrentRow(-1)
self.list.blockSignals(False)
def _on_row(self, row: int) -> None:
if 0 <= row < len(self._ids):
self._highlight = self._ids[row]
self._show()
def _show(self) -> None:
preset = next((item for item in self._presets if item.name == self._highlight), None)
for widget in (self.choose_btn, self.edit_btn, self.delete_btn):
widget.setEnabled(preset is not None)
if preset is None:
self.title_label.setText("Пресет не выбран")
for label in (self.meta_label, self.description_label, self.targets_label, self.ports_label):
label.setText("")
self.choose_btn.setText("Выбрать")
return
summary = preset.summary
self.title_label.setText(preset.name)
state = []
if preset.name == self._running:
state.append("работает сейчас")
elif preset.name == self._chosen:
state.append("выбран")
state.append(f"профилей: {summary.profiles}")
self.meta_label.setText(" · ".join(state))
self.description_label.setText(preset.description)
self.description_label.setVisible(bool(preset.description))
targets = list(summary.targets)
if summary.catch_all:
targets.append("весь остальной трафик на перехваченных портах")
self.targets_label.setText(", ".join(targets) or "Целей нет")
ports = []
if summary.tcp_ports:
ports.append(f"TCP {summary.tcp_ports}")
if summary.udp_ports:
ports.append(f"UDP {summary.udp_ports}")
self.ports_label.setText("\n".join(ports) or "Порты не заданы")
chosen = preset.name == self._chosen
self.choose_btn.setEnabled(not chosen)
self.choose_btn.setText("Выбран" if chosen else "Выбрать")