zapretgui/tests/test_opaque_scroll.py
loop-uh 0fdbb421aa
All checks were successful
Zapret source guards / validate (push) Successful in 10s
Windows-сборка / Dev Dev 21.1.7.204 опубликован
Presets list: scroll without redrawing every tile, only over a flat background
The list is translucent, so Qt cannot reuse the pixels it already drew and
repainted every visible tile (70-140 Python paint calls) on each scroll step.
While a scroll is going on (and only then) the viewport is now declared opaque, so
Qt shifts the picture and asks for just the exposed strip; the strip gets the real
backdrop first. That is allowed only when the backdrop is flat, which is measured, not
assumed: Qt draws the window without the list and six points behind it must be the
same colour (a background image, another animation over the list or a hidden/minimised
window keep the old path). When the scroll stops (140 ms) the list is repainted once
the normal way. Measured on the real window: tiles drawn per scroll 3788 -> 688 (slow
notches) and 840 -> 60 (fast back and forth), CPU about 3x and 5x lower.

The check against a fresh render found a bug that was already there: QRect.center()
rounds toward zero in C++, so a tile whose top is above the list (negative y) drew its
icon and text 1 px lower than when fully visible and jumped 1 px as it crossed the top
edge. The centre is now computed by floor (ui/widgets/stable_center.py), identical for
every tile that is fully visible, used for tiles, folder headers and the marker.
After that, 384 random scroll steps with mouse movement (dark, light, tinted) match a fresh
render with no pixel off by more than 2 levels of 255.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 18:46:18 -04:00

239 lines
10 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.

from __future__ import annotations
import os
import unittest
from unittest import mock
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PyQt6.QtCore import Qt
from PyQt6.QtGui import QColor, QImage, QPainter
from PyQt6.QtWidgets import QApplication, QVBoxLayout, QWidget
import ui.widgets.opaque_scroll as opaque_scroll_module
from ui.presets_menu.delegate import PresetListDelegate
from ui.presets_menu.model import PresetListModel
from ui.presets_menu.view import LinkedWheelListView
from ui.widgets.fluent_scrollbar import install_fluent_scrollbars
BACKDROP = QColor(31, 41, 59)
class _FlatHost(QWidget):
def paintEvent(self, event) -> None: # noqa: N802
painter = QPainter(self)
painter.fillRect(self.rect(), BACKDROP)
painter.end()
class _StripedHost(QWidget):
"""Фон с картинкой: полосы разного цвета (как фоновое изображение окна)."""
def paintEvent(self, event) -> None: # noqa: N802
painter = QPainter(self)
for x in range(0, self.width(), 20):
painter.fillRect(x, 0, 10, self.height(), QColor(31, 41, 59) if (x // 20) % 2 else QColor(60, 80, 100))
painter.end()
def _rows() -> list[dict[str, object]]:
rows: list[dict[str, object]] = []
for group in range(4):
rows.append({"kind": "folder", "folder_key": f"f{group}", "name": f"Группа {group}", "is_collapsed": False, "count": 24})
for number in range(24):
rows.append({"kind": "preset", "name": f"general ALT{group}-{number}", "file_name": f"p{group}-{number}.txt",
"folder_key": f"f{group}", "depth": 1, "icon_color": ["", "#e0a030", "#40c080"][number % 3]})
return rows
class _Case(unittest.TestCase):
host_class = _FlatHost
@classmethod
def setUpClass(cls) -> None:
cls._app = QApplication.instance() or QApplication([])
def setUp(self) -> None:
self.host = self.host_class()
self.addCleanup(self.host.deleteLater)
layout = QVBoxLayout(self.host)
layout.setContentsMargins(20, 20, 20, 20)
self.busy = False
self.view = LinkedWheelListView(self.host)
self.view._opaque_scroll._is_busy = lambda: self.busy
self.model = PresetListModel(self.view)
self.model.set_rows(_rows())
self.view.setModel(self.model)
self.view.setItemDelegate(PresetListDelegate(self.view))
# Как в настоящей странице: место под полосу прокрутки отведено, она не
# лежит поверх списка (иначе Qt не двигает готовую картинку).
install_fluent_scrollbars(self.view, vertical=True, horizontal=False, reserve_vertical_space=True)
layout.addWidget(self.view)
self.host.resize(900, 600)
self.host.show()
self._settle()
self.view.doItemsLayout()
self._settle()
self.scroll = self.view._opaque_scroll
self.viewport = self.view.viewport()
self.addCleanup(self.scroll.end)
@staticmethod
def _settle() -> None:
for _ in range(4):
QApplication.processEvents()
def _scroll_to(self, value: int) -> None:
self.view.verticalScrollBar().setValue(value)
self._settle()
class ActivationTests(_Case):
def test_flat_backdrop_is_measured_and_the_viewport_becomes_opaque(self) -> None:
self._scroll_to(40)
self.assertTrue(self.scroll.is_active)
self.assertTrue(self.viewport.testAttribute(Qt.WidgetAttribute.WA_OpaquePaintEvent))
measured = QColor.fromRgba(self.scroll._backdrop.pixel(0, 0))
self.assertEqual((measured.red(), measured.green(), measured.blue(), measured.alpha()), (31, 41, 59, 255))
def test_the_mode_ends_after_the_scroll_stops_and_the_list_is_repainted_normally(self) -> None:
self._scroll_to(40)
with mock.patch.object(self.viewport, "update") as update:
self.scroll._idle.timeout.emit()
self.assertFalse(self.scroll.is_active)
self.assertFalse(self.viewport.testAttribute(Qt.WidgetAttribute.WA_OpaquePaintEvent))
update.assert_called_once_with()
def test_ending_twice_is_harmless(self) -> None:
self._scroll_to(40)
self.scroll.end()
self.scroll.end()
self.assertFalse(self.viewport.testAttribute(Qt.WidgetAttribute.WA_OpaquePaintEvent))
def test_the_idle_timer_is_restarted_by_every_step(self) -> None:
self._scroll_to(20)
with mock.patch.object(self.scroll._idle, "start") as restart:
self._scroll_to(30)
restart.assert_called()
def test_another_animation_over_the_list_keeps_the_normal_path(self) -> None:
self.busy = True
self._scroll_to(40)
self.assertFalse(self.scroll.is_active)
self.assertFalse(self.viewport.testAttribute(Qt.WidgetAttribute.WA_OpaquePaintEvent))
def test_hiding_the_list_ends_the_mode(self) -> None:
self._scroll_to(40)
self.assertTrue(self.scroll.is_active)
self.view.hide()
self.assertFalse(self.scroll.is_active)
self.assertFalse(self.viewport.testAttribute(Qt.WidgetAttribute.WA_OpaquePaintEvent))
def test_a_hidden_list_does_not_start_it(self) -> None:
self.view.hide()
self.scroll.on_scroll()
self.assertFalse(self.scroll.is_active)
def test_the_marker_slide_ends_the_mode_and_blocks_it(self) -> None:
from ui.widgets.active_row_motion import attach_active_row_motion
motion = attach_active_row_motion(self.view, PresetListModel.ActiveRole)
self._scroll_to(40)
self.assertTrue(self.scroll.is_active)
self.model.set_active_preset("p0-3.txt")
self.model.set_active_preset("p0-7.txt")
self._settle()
if motion.is_running():
self.assertFalse(self.scroll.is_active)
self._scroll_to(60)
self.assertFalse(self.scroll.is_active)
def test_the_list_paints_nothing_while_the_backdrop_is_being_measured(self) -> None:
painted: list[int] = []
original = self.view.itemDelegate()._paint_preset_row
self.view.itemDelegate()._paint_preset_row = lambda *args: (painted.append(1), original(*args))[1]
self.assertFalse(self.scroll.painting_suppressed)
self.scroll._measuring = True
try:
self.view.paintEvent(mock.Mock(region=lambda: None))
finally:
self.scroll._measuring = False
self.assertEqual(painted, [])
class StripedBackgroundTests(_Case):
host_class = _StripedHost
def test_a_background_that_is_not_flat_keeps_the_normal_path(self) -> None:
self._scroll_to(40)
self.assertFalse(self.scroll.is_active)
self.assertFalse(self.viewport.testAttribute(Qt.WidgetAttribute.WA_OpaquePaintEvent))
def test_the_failed_measurement_is_not_repeated_on_every_step(self) -> None:
self._scroll_to(40)
with mock.patch.object(self.scroll, "_measure_flat_backdrop") as measure:
self._scroll_to(50)
self._scroll_to(60)
measure.assert_not_called()
class DrawingTests(_Case):
def _count_tiles(self, steps) -> int:
drawn: list[int] = []
delegate = self.view.itemDelegate()
original = delegate._paint_preset_row
delegate._paint_preset_row = lambda *args: (drawn.append(1), original(*args))[1]
try:
for value in steps:
self._scroll_to(value)
finally:
delegate._paint_preset_row = original
return len(drawn)
def test_scrolling_draws_far_fewer_tiles_than_the_normal_path(self) -> None:
steps = [30, 45, 60, 75, 60, 45, 30, 15]
with_opaque = self._count_tiles(steps)
with mock.patch.object(opaque_scroll_module.OpaqueScroll, "_begin", return_value=False):
self.scroll.end()
self._scroll_to(0)
without = self._count_tiles(steps)
self.assertGreater(without, 150)
self.assertLess(with_opaque * 2, without)
@staticmethod
def _worst_difference(first: QImage, second: QImage, area) -> int:
worst = 0
for y in range(area.top(), area.bottom() + 1):
for x in range(area.left(), area.right() + 1):
a, b = QColor(first.pixel(x, y)), QColor(second.pixel(x, y))
worst = max(worst, abs(a.red() - b.red()), abs(a.green() - b.green()), abs(a.blue() - b.blue()))
return worst
def test_the_picture_in_the_middle_of_a_scroll_matches_a_fresh_render(self) -> None:
# Значки у краёв допускают расхождение в один уровень цвета из 255
# (округление сглаживания), остальное совпадает точка в точку.
window = self.host.windowHandle()
for value in (23, 41, 33, 58, 71, 49, 12, 66, 3, 5, 14, 9, 80, 11):
self._scroll_to(value)
self.assertTrue(self.scroll.is_active, value)
onscreen = window.screen().grabWindow(self.host.winId()).toImage().convertToFormat(QImage.Format.Format_ARGB32)
fresh = self.host.grab().toImage().convertToFormat(QImage.Format.Format_ARGB32)
area = self.viewport.rect().translated(self.viewport.mapTo(self.host, self.viewport.rect().topLeft()))
self.assertLessEqual(self._worst_difference(onscreen, fresh, area), 2, value)
def test_after_the_scroll_the_list_is_what_the_normal_path_draws(self) -> None:
for value in (23, 41, 33, 58, 71):
self._scroll_to(value)
self.scroll.end()
self._settle()
onscreen = self.host.windowHandle().screen().grabWindow(self.host.winId()).toImage().convertToFormat(QImage.Format.Format_ARGB32)
fresh = self.host.grab().toImage().convertToFormat(QImage.Format.Format_ARGB32)
area = self.viewport.rect().translated(self.viewport.mapTo(self.host, self.viewport.rect().topLeft()))
self.assertLessEqual(self._worst_difference(onscreen, fresh, area), 2)
if __name__ == "__main__":
unittest.main()