zapret-kvn/xray_fluent/ui/singbox/lists.py
loop-uh dd0ccadaff
Some checks failed
Windows project source guards / test (push) Failing after 41s
Правила: простое правило в духе v2rayN и «Проверить сайт»
«Добавить правило» → «Сайты, IP, программы (просто)»: куда (через VPN /
напрямую / блок) и списки по строке в синтаксисе v2rayN. 2ip.ru — сайт с
поддоменами (domain_suffix), geosite:/geoip: — существующие наборы,
сайты и программы — логическое ИЛИ. Документ не меняется до «Добавить»,
правило встаёт сразу после sniff/hijack-dns, «Добавить и применить» —
тем же путём, что «Применить».

«Проверить сайт» над списком проходит правила в порядке ядра и
показывает сработавшее правило и выход; условия по программе — «может
перехватить». Наборы проверяет само ядро (sing-box rule-set match -f),
вне GUI-потока, с номером поколения.
2026-09-27 13:07:02 +03:00

366 lines
13 KiB
Python

"""Ordered lists of native objects and nested sub-page navigation."""
from __future__ import annotations
from dataclasses import dataclass
import json
from typing import Callable
from PyQt6.QtCore import QEasingCurve, QPoint, QRectF, Qt, QVariantAnimation, pyqtSignal
from PyQt6.QtGui import QPainter
from PyQt6.QtWidgets import QHBoxLayout, QVBoxLayout, QWidget
from qfluentwidgets import (
Action,
BodyLabel,
CaptionLabel,
CardWidget,
FluentIcon as FIF,
PopUpAniStackedWidget,
RoundMenu,
StrongBodyLabel,
TransparentPushButton,
TransparentToolButton,
)
from ..detail_page import DetailPage
from ..motion import reduced_motion
from ..qt_lifecycle import dispose_later
from ..theme import accent_color
from .art import KindBadge, with_alpha
from .visuals import Visual
def popup_menu(anchor: QWidget, entries: list[tuple[str, Callable[[], None], str]]) -> None:
"""Show a menu under ``anchor``; entries are ``(text, callback, submenu)``.
Built on click and owned by the window, like the other context menus of
the app: a ``RoundMenu`` must never be destroyed together with a form that
is being rebuilt. After closing it is disposed with cyclic GC paused.
"""
owner = anchor.window()
menu = RoundMenu(parent=owner)
submenus: dict[str, RoundMenu] = {}
for text, callback, submenu in entries:
target = menu
if submenu:
target = submenus.get(submenu)
if target is None:
target = RoundMenu(submenu, menu)
submenus[submenu] = target
action = Action(text, menu)
action.triggered.connect(lambda _checked=False, cb=callback: cb())
target.addAction(action)
for submenu in submenus.values():
menu.addMenu(submenu)
menu.closedSignal.connect(lambda: dispose_later(menu))
menu.exec(anchor.mapToGlobal(QPoint(0, anchor.height())))
def menu_button(text: str, parent: QWidget, entries: Callable[[], list[tuple[str, Callable[[], None], str]]]) -> TransparentPushButton:
button = TransparentPushButton(FIF.ADD, text, parent)
button.clicked.connect(lambda: popup_menu(button, entries()))
return button
@dataclass(frozen=True)
class RowInfo:
title: str
subtitle: str = ""
note: str = ""
removable: bool = True
visual: Visual | None = None
@dataclass(frozen=True)
class AddOption:
label: str
#: Новый объект, который сразу встаёт в конец списка и открывается.
factory: Callable[[], dict] | None = None
submenu: str = ""
#: Вместо добавления — своё действие (например, страница «Простое правило»).
run: Callable[[], None] | None = None
class _Row(CardWidget):
"""One list row; updated in place so reordering creates no widgets."""
def __init__(self, parent: QWidget | None = None):
super().__init__(parent)
self.index = 0
layout = QHBoxLayout(self)
layout.setContentsMargins(14, 8, 8, 8)
layout.setSpacing(10)
self.number = CaptionLabel("", self)
self.number.setFixedWidth(18)
self.number.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
layout.addWidget(self.number)
self.badge = KindBadge(self)
layout.addWidget(self.badge, 0, Qt.AlignmentFlag.AlignVCenter)
self._flash = 0.0
self._flash_anim = QVariantAnimation(self)
self._flash_anim.setStartValue(1.0)
self._flash_anim.setEndValue(0.0)
self._flash_anim.setDuration(900)
self._flash_anim.setEasingCurve(QEasingCurve.Type.OutCubic)
self._flash_anim.valueChanged.connect(self._on_flash)
column = QVBoxLayout()
column.setSpacing(2)
self.title = BodyLabel("", self)
self.title.setWordWrap(True)
column.addWidget(self.title)
self.subtitle = CaptionLabel("", self)
self.subtitle.setWordWrap(True)
column.addWidget(self.subtitle)
self.note = CaptionLabel("", self)
self.note.setWordWrap(True)
column.addWidget(self.note)
layout.addLayout(column, 1)
self.up = TransparentToolButton(FIF.UP, self)
self.up.setToolTip("Выше")
self.down = TransparentToolButton(FIF.DOWN, self)
self.down.setToolTip("Ниже")
self.copy = TransparentToolButton(FIF.COPY, self)
self.copy.setToolTip("Дублировать")
self.remove = TransparentToolButton(FIF.DELETE, self)
self.remove.setToolTip("Удалить")
for button in (self.up, self.down, self.copy, self.remove):
layout.addWidget(button, 0, Qt.AlignmentFlag.AlignVCenter)
def flash(self) -> None:
"""Highlight the row briefly (added, duplicated or moved here)."""
if reduced_motion():
return
self._flash_anim.stop()
self._flash_anim.start()
def _on_flash(self, value) -> None:
self._flash = float(value)
self.update()
def paintEvent(self, event) -> None:
super().paintEvent(event)
if self._flash <= 0.01:
return
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
painter.setPen(Qt.PenStyle.NoPen)
painter.setBrush(with_alpha(accent_color(), 70 * self._flash))
painter.drawRoundedRect(QRectF(self.rect()).adjusted(1, 1, -1, -1), 8, 8)
painter.end()
def show_info(self, index: int, info: RowInfo, count: int) -> None:
self.index = index
self.number.setText(str(index + 1))
self.badge.setVisible(info.visual is not None)
if info.visual is not None:
self.badge.set_visual(info.visual)
self.title.setText(info.title)
for label, text in ((self.subtitle, info.subtitle), (self.note, info.note)):
label.setText(text)
label.setVisible(bool(text))
self.up.setEnabled(index > 0)
self.down.setEnabled(index < count - 1)
self.remove.setEnabled(info.removable)
class ObjectList(QWidget):
"""Editable ordered list of dicts that lives inside the native document."""
changed = pyqtSignal()
open_requested = pyqtSignal(int)
def __init__(
self,
items: Callable[[], list],
ensure_items: Callable[[], list],
describe: Callable[[dict, int], RowInfo],
add_options: list[AddOption],
parent: QWidget | None = None,
*,
add_text: str = "Добавить",
empty_text: str = "Пока пусто.",
can_remove: Callable[[dict], str] | None = None,
):
super().__init__(parent)
self._items = items
self._ensure_items = ensure_items
self._describe = describe
self._can_remove = can_remove
self._layout = QVBoxLayout(self)
self._layout.setContentsMargins(0, 0, 0, 0)
self._layout.setSpacing(6)
self._rows_host = QWidget(self)
self._rows = QVBoxLayout(self._rows_host)
self._rows.setContentsMargins(0, 0, 0, 0)
self._rows.setSpacing(6)
self._layout.addWidget(self._rows_host)
self._row_widgets: list[_Row] = []
self._empty = CaptionLabel(empty_text, self)
self._layout.addWidget(self._empty)
self.add_button = self._make_add_button(add_text, add_options)
self._layout.addWidget(self.add_button, 0, Qt.AlignmentFlag.AlignLeft)
self.refresh()
def _make_add_button(self, text: str, options: list[AddOption]) -> QWidget:
self.options = list(options)
if len(options) == 1:
button = TransparentPushButton(FIF.ADD, text, self)
button.clicked.connect(lambda: self._add(options[0]))
return button
return menu_button(
text,
self,
lambda: [(option.label, lambda item=option: self._add(item), option.submenu) for option in options],
)
def refresh(self) -> None:
items = self._items()
while len(self._row_widgets) > len(items):
row = self._row_widgets.pop()
self._rows.removeWidget(row)
dispose_later(row)
while len(self._row_widgets) < len(items):
row = _Row(self._rows_host)
row.clicked.connect(lambda r=row: self.open_requested.emit(r.index))
row.up.clicked.connect(lambda _c=False, r=row: self._move(r.index, -1))
row.down.clicked.connect(lambda _c=False, r=row: self._move(r.index, 1))
row.copy.clicked.connect(lambda _c=False, r=row: self._duplicate(r.index))
row.remove.clicked.connect(lambda _c=False, r=row: self._remove(r.index))
self._rows.addWidget(row)
self._row_widgets.append(row)
for index, (row, item) in enumerate(zip(self._row_widgets, items)):
row.show_info(index, self._describe(item if isinstance(item, dict) else {}, index), len(items))
self._empty.setVisible(not items)
def flash_row(self, index: int) -> None:
self._flash_row(index)
def _flash_row(self, index: int) -> None:
if 0 <= index < len(self._row_widgets):
self._row_widgets[index].flash()
def _add(self, option: AddOption) -> None:
if option.run is not None:
option.run()
return
items = self._ensure_items()
items.append(option.factory())
self.refresh()
self._flash_row(len(items) - 1)
self.changed.emit()
self.open_requested.emit(len(items) - 1)
def _move(self, index: int, delta: int) -> None:
items = self._items()
target = index + delta
if not 0 <= target < len(items):
return
items[index], items[target] = items[target], items[index]
self.refresh()
self._flash_row(target)
self.changed.emit()
def _duplicate(self, index: int) -> None:
items = self._items()
copy = json.loads(json.dumps(items[index]))
if isinstance(copy, dict) and isinstance(copy.get("tag"), str):
copy["tag"] = _unique_tag(copy["tag"], items)
items.insert(index + 1, copy)
self.refresh()
self._flash_row(index + 1)
self.changed.emit()
def _remove(self, index: int) -> None:
items = self._items()
if self._can_remove is not None:
problem = self._can_remove(items[index])
if problem:
from qfluentwidgets import MessageBox
box = MessageBox("Удалить?", problem, self.window())
box.yesButton.setText("Удалить")
box.cancelButton.setText("Отмена")
if not box.exec():
return
del items[index]
self.refresh()
self.changed.emit()
def _unique_tag(tag: str, items: list) -> str:
existing = {item.get("tag") for item in items if isinstance(item, dict)}
index = 2
while f"{tag}-{index}" in existing:
index += 1
return f"{tag}-{index}"
def unique_tag(base: str, items: list) -> str:
existing = {item.get("tag") for item in items if isinstance(item, dict)}
if base not in existing:
return base
return _unique_tag(base, items)
class NavStack(PopUpAniStackedWidget):
"""Root view plus a stack of :class:`DetailPage` sub-pages.
Sub-pages slide in from the right; going back, the previous page slides in
from the left. The current page switches at once (only the incoming page's
position is animated), so the stack state never lags behind the animation.
"""
popped = pyqtSignal()
_SLIDE = 56
def __init__(self, root: QWidget, parent: QWidget | None = None):
super().__init__(parent)
self.root = root
self.addWidget(root, deltaX=-self._SLIDE, deltaY=0)
self._pages: list[DetailPage] = []
def _slide_to(self, widget: QWidget, delta_x: int) -> None:
self.setAnimationEnabled(not reduced_motion())
info = self.aniInfos[self.indexOf(widget)]
info.deltaX, info.deltaY = delta_x, 0
self.setCurrentWidget(widget, duration=220, easingCurve=QEasingCurve.Type.OutCubic)
def push(self, page: DetailPage) -> None:
page.back_requested.connect(self.pop)
self._pages.append(page)
self.addWidget(page, deltaX=self._SLIDE, deltaY=0)
self._slide_to(page, self._SLIDE)
def pop(self) -> None:
if not self._pages:
return
page = self._pages.pop()
previous = self._pages[-1] if self._pages else self.root
self._slide_to(previous, -self._SLIDE)
self.removeWidget(page)
dispose_later(page)
self.popped.emit()
def pop_all(self) -> None:
while self._pages:
page = self._pages.pop()
self.removeWidget(page)
dispose_later(page)
self.popped.emit()
self.setAnimationEnabled(False)
self.setCurrentWidget(self.root)
@property
def depth(self) -> int:
return len(self._pages)
def section_header(title: str, parent: QWidget) -> QWidget:
"""Subsection title; explanations live in the section guide dialog."""
host = QWidget(parent)
layout = QVBoxLayout(host)
layout.setContentsMargins(0, 6, 0, 0)
layout.setSpacing(2)
layout.addWidget(StrongBodyLabel(title, host))
return host