При наведении значок на любой кнопке слегка покачивается и затухает, при нажатии значок вжимается и пружинит обратно, а от точки клика по кнопке расходится мягкая волна. Работает для всех кнопок qfluentwidgets и для простых кнопок, которые qfluentwidgets создаёт сам (Отмена в диалогах, кнопка карточек настроек); боковое меню не затронуто. Подчиняется тумблеру «Живые анимации». Модуль ui/button_motion.py ставится при старте рядом с защитой theme-сигналов. ZaStoGram на странице Telegram Proxy теперь отдельной строкой «Не помогает прокси?» с пояснением и кнопкой «Скачать ZaStoGram», которая открывает страницу последнего выпуска.
203 lines
8.4 KiB
Python
203 lines
8.4 KiB
Python
from __future__ import annotations
|
|
|
|
import inspect
|
|
import math
|
|
import os
|
|
import time
|
|
import unittest
|
|
from pathlib import Path
|
|
from unittest import mock
|
|
|
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
|
|
|
from PyQt6.QtCore import QEvent, QPointF, Qt, qInstallMessageHandler
|
|
from PyQt6.QtGui import QMouseEvent
|
|
from PyQt6.QtWidgets import QApplication, QVBoxLayout, QWidget
|
|
|
|
import ui.button_motion as motion_module
|
|
from ui.button_motion import button_motion, install_button_motion
|
|
|
|
|
|
def _wait(seconds: float) -> None:
|
|
deadline = time.monotonic() + seconds
|
|
while time.monotonic() < deadline:
|
|
QApplication.processEvents()
|
|
|
|
|
|
def _mouse(kind: QEvent.Type, widget, pos: QPointF) -> QMouseEvent:
|
|
buttons = Qt.MouseButton.LeftButton if kind == QEvent.Type.MouseButtonPress else Qt.MouseButton.NoButton
|
|
return QMouseEvent(
|
|
kind,
|
|
pos,
|
|
widget.mapToGlobal(pos),
|
|
Qt.MouseButton.LeftButton,
|
|
buttons,
|
|
Qt.KeyboardModifier.NoModifier,
|
|
)
|
|
|
|
|
|
class ButtonMotionTests(unittest.TestCase):
|
|
@classmethod
|
|
def setUpClass(cls) -> None:
|
|
cls._app = QApplication.instance() or QApplication([])
|
|
install_button_motion()
|
|
|
|
def setUp(self) -> None:
|
|
patcher = mock.patch.object(motion_module, "are_live_animations_enabled", return_value=True)
|
|
patcher.start()
|
|
self.addCleanup(patcher.stop)
|
|
self.host = QWidget()
|
|
self.addCleanup(self.host.deleteLater)
|
|
self.layout = QVBoxLayout(self.host)
|
|
|
|
def _add(self, widget):
|
|
self.layout.addWidget(widget)
|
|
self.host.show()
|
|
QApplication.processEvents()
|
|
return widget
|
|
|
|
def _press(self, button, pos: QPointF | None = None) -> None:
|
|
point = pos or QPointF(6, 6)
|
|
QApplication.sendEvent(button, _mouse(QEvent.Type.MouseButtonPress, button, point))
|
|
|
|
def _release(self, button) -> None:
|
|
QApplication.sendEvent(button, _mouse(QEvent.Type.MouseButtonRelease, button, QPointF(6, 6)))
|
|
|
|
def test_hover_sways_icon_and_settles(self) -> None:
|
|
from qfluentwidgets import FluentIcon, PushButton
|
|
|
|
button = self._add(PushButton("Скачать", icon=FluentIcon.DOWNLOAD))
|
|
button.enterEvent(QEvent(QEvent.Type.Enter))
|
|
_wait(0.08)
|
|
|
|
motion = button_motion(button)
|
|
self.assertGreater(abs(motion.icon_angle()), 0.5)
|
|
self.assertLessEqual(abs(motion.icon_angle()), motion_module.WOBBLE_AMPLITUDE_DEG)
|
|
_wait(motion_module.WOBBLE_DURATION_MS / 1000 + 0.1)
|
|
self.assertEqual(motion.icon_angle(), 0.0)
|
|
|
|
def test_nothing_moves_when_live_animations_are_off(self) -> None:
|
|
from qfluentwidgets import FluentIcon, PushButton
|
|
|
|
button = self._add(PushButton("Копировать", icon=FluentIcon.COPY))
|
|
with mock.patch.object(motion_module, "are_live_animations_enabled", return_value=False):
|
|
button.enterEvent(QEvent(QEvent.Type.Enter))
|
|
self._press(button)
|
|
_wait(0.06)
|
|
|
|
motion = button_motion(button)
|
|
self.assertEqual(motion.icon_angle(), 0.0)
|
|
self.assertEqual(motion.icon_scale(), 1.0)
|
|
self.assertFalse(motion.ripple_active())
|
|
self._release(button)
|
|
|
|
def test_press_squeezes_icon_starts_ripple_and_release_springs_back(self) -> None:
|
|
from qfluentwidgets import FluentIcon, PrimaryPushButton
|
|
|
|
button = self._add(PrimaryPushButton("Открыть", icon=FluentIcon.SEND))
|
|
self._press(button, QPointF(20, 10))
|
|
_wait(0.15)
|
|
|
|
motion = button_motion(button)
|
|
self.assertLess(motion.icon_scale(), 0.9)
|
|
self.assertTrue(motion.ripple_active())
|
|
clicked = mock.Mock()
|
|
button.clicked.connect(clicked)
|
|
self._release(button)
|
|
clicked.assert_called_once()
|
|
_wait((motion_module.RELEASE_DURATION_MS + motion_module.RIPPLE_DURATION_MS) / 1000 + 0.1)
|
|
self.assertAlmostEqual(motion.icon_scale(), 1.0, places=3)
|
|
self.assertFalse(motion.ripple_active())
|
|
|
|
def test_icon_is_rotated_once_even_when_draw_icon_delegates(self) -> None:
|
|
import qfluentwidgets.components.widgets.button as fluent_button
|
|
from qfluentwidgets import FluentIcon, PrimaryDropDownPushButton, TogglePushButton
|
|
|
|
toggle = self._add(TogglePushButton("Режим", icon=FluentIcon.SEND))
|
|
dropdown = self._add(PrimaryDropDownPushButton("Ещё", icon=FluentIcon.COPY))
|
|
original_draw = fluent_button.drawIcon
|
|
|
|
for button, checked in ((toggle, False), (toggle, True), (dropdown, False)):
|
|
with self.subTest(button=type(button).__name__, checked=checked):
|
|
if button is toggle:
|
|
toggle.setChecked(checked)
|
|
button.enterEvent(QEvent(QEvent.Type.Enter))
|
|
_wait(0.08)
|
|
motion = button_motion(button)
|
|
seen: list[tuple[float, float]] = []
|
|
|
|
def record(icon, painter, rect, *args, _motion=motion, _seen=seen, **kwargs):
|
|
transform = painter.worldTransform()
|
|
_seen.append((math.degrees(math.atan2(transform.m12(), transform.m11())), _motion.icon_angle()))
|
|
return original_draw(icon, painter, rect, *args, **kwargs)
|
|
|
|
with mock.patch.object(fluent_button, "drawIcon", side_effect=record):
|
|
button.grab()
|
|
self.assertEqual(len(seen), 1)
|
|
painter_angle, motion_angle = seen[0]
|
|
self.assertGreater(abs(motion_angle), 0.5)
|
|
self.assertAlmostEqual(painter_angle, motion_angle, places=3)
|
|
_wait(motion_module.WOBBLE_DURATION_MS / 1000 + 0.05)
|
|
|
|
def test_ripple_paints_on_top_without_painter_conflicts(self) -> None:
|
|
from qfluentwidgets import DropDownPushButton, FluentIcon, PillPushButton, PushButton, PushSettingCard
|
|
|
|
from donater.premium_display import PREMIUM_TIERS, PremiumDisplay
|
|
from ui.subscription_title_badge import SubscriptionTitleBadge
|
|
|
|
card = PushSettingCard("Выбрать", FluentIcon.INFO, "Карточка", "Описание")
|
|
buttons = [
|
|
self._add(PushButton("Обычная", icon=FluentIcon.COPY)),
|
|
self._add(DropDownPushButton("Меню", icon=FluentIcon.COPY)),
|
|
self._add(PillPushButton("Pill", icon=FluentIcon.COPY)),
|
|
]
|
|
badge = SubscriptionTitleBadge(language_provider=lambda: "ru")
|
|
# Метка Premium рисует свой блик поверх кнопки вторым QPainter.
|
|
badge.set_display(PremiumDisplay(tier=next(iter(PREMIUM_TIERS)), days=37))
|
|
buttons.append(self._add(badge))
|
|
self._add(card)
|
|
buttons.append(card.button)
|
|
|
|
messages: list[str] = []
|
|
previous = qInstallMessageHandler(lambda _kind, _ctx, message: messages.append(message))
|
|
try:
|
|
for button in buttons:
|
|
self._press(button)
|
|
_wait(0.12)
|
|
for button in buttons:
|
|
with self.subTest(button=type(button).__name__):
|
|
self.assertTrue(button_motion(button).ripple_active())
|
|
button.grab()
|
|
for button in buttons:
|
|
self._release(button)
|
|
finally:
|
|
qInstallMessageHandler(previous)
|
|
self.assertEqual([m for m in messages if "paint" in m.lower()], [])
|
|
|
|
def test_plain_buttons_created_by_qfluentwidgets_get_gestures(self) -> None:
|
|
from qfluentwidgets import FluentIcon, MessageBoxBase, PrimaryPushSettingCard
|
|
|
|
card = self._add(PrimaryPushSettingCard("Открыть", FluentIcon.INFO, "Карточка"))
|
|
dialog = MessageBoxBase(self.host)
|
|
self.addCleanup(dialog.deleteLater)
|
|
|
|
for button in (card.button, dialog.cancelButton):
|
|
with self.subTest(button=button.text()):
|
|
self.assertTrue(getattr(button, motion_module._FILTERED_ATTR, False))
|
|
self._press(card.button)
|
|
self.assertTrue(button_motion(card.button).ripple_active())
|
|
self._release(card.button)
|
|
|
|
def test_module_contract(self) -> None:
|
|
source = inspect.getsource(motion_module)
|
|
self.assertNotIn("QPropertyAnimation(", source)
|
|
self.assertNotIn("themeChanged", source)
|
|
|
|
runtime_source = (Path(__file__).resolve().parents[1] / "src" / "main" / "qt_runtime.py").read_text(
|
|
encoding="utf-8"
|
|
)
|
|
self.assertIn("install_button_motion()", runtime_source)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|