Files
LorenEteval_Furious/tests/test_stylesheet_states.py
T
2026-09-08 23:42:03 +08:00

585 lines
24 KiB
Python
Raw 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.
# Copyright (C) 2024present Loren Eteval & contributors <loren.eteval@proton.me>
#
# This file is part of Furious.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
"""Protect the application's intentional visual-state precedence."""
from __future__ import annotations
from Furious.Qt import (
AppQComboBox,
AppQLineEdit,
AppQListView,
AppQMenu,
AppQTableView,
AppStyleSheet,
)
from Furious.Widget.NavigationView import NavigationView
from PySide6 import QtCore
from PySide6.QtGui import (
QColor,
QImage,
QPalette,
QStandardItem,
QStandardItemModel,
)
from PySide6.QtTest import QTest
from PySide6.QtWidgets import (
QGraphicsDropShadowEffect,
QPushButton,
QStyleOptionButton,
QToolButton,
QWidget,
)
from tests.support import application, collectAtBoundary, processQtEvents, waitFor
import shiboken6
import unittest
class StyleSheetStateRenderingTest(unittest.TestCase):
"""Exercise focus and disabled precedence through Qt's styled renderer."""
@classmethod
def setUpClass(cls):
"""Create the process-wide headless QApplication."""
application()
def tearDown(self):
"""Finish deferred widget deletion between tests."""
collectAtBoundary()
@staticmethod
def focusedEdgeColors(button):
"""Render the four straight edge centers of a focused, hovered button."""
button.resize(220, 40)
button.show()
button.setFocus(QtCore.Qt.FocusReason.TabFocusReason)
QTest.mouseMove(button, button.rect().center())
processQtEvents()
image = button.grab().toImage()
points = (
(image.width() // 2, 0),
(image.width() - 1, image.height() // 2),
(image.width() // 2, image.height() - 1),
(0, image.height() // 2),
)
colors = tuple(image.pixelColor(*point).rgba() for point in points)
button.close()
button.deleteLater()
return colors
@staticmethod
def disabledButtonTextColor(button):
"""Return the effective styled text color for one disabled button."""
button.setDisabled(True)
button.resize(220, 40)
button.show()
processQtEvents()
option = QStyleOptionButton()
button.initStyleOption(option)
color = option.palette.color(QPalette.ColorRole.ButtonText).rgba()
button.close()
button.deleteLater()
return color
def assertItemViewInsetsAndListCorners(self, view, theme):
"""Check zero table padding and intact padded list corners."""
if isinstance(view, AppQTableView):
self.assertEqual(view.contentsRect(), view.rect().adjusted(1, 1, -1, -1))
return
actual = view.grab().toImage()
frame = QImage(actual.size(), QImage.Format.Format_ARGB32_Premultiplied)
frame.setDevicePixelRatio(actual.devicePixelRatio())
frame.fill(QtCore.Qt.transparent)
view.render(frame, renderFlags=QWidget.RenderFlag.DrawWindowBackground)
panel = QColor(AppStyleSheet.Palettes[theme]['panel'])
paddingPoint = QtCore.QPoint(view.width() // 2, 2)
host = view.parentWidget()
hostImage = host.grab().toImage()
hostPoint = view.mapTo(host, paddingPoint)
self.assertEqual(
hostImage.pixelColor(
round(hostPoint.x() * hostImage.devicePixelRatio()),
round(hostPoint.y() * hostImage.devicePixelRatio()),
),
QColor(AppStyleSheet.Palettes[theme]['window']),
)
cornerSize = round(8 * actual.devicePixelRatio())
for left in (0, actual.width() - cornerSize):
for top in (0, actual.height() - cornerSize):
checked = 0
for x in range(left, left + cornerSize):
for y in range(top, top + cornerSize):
expected = frame.pixelColor(x, y)
if expected.alpha() and expected != panel:
self.assertEqual(
actual.pixelColor(x, y),
expected,
f"frame pixel {(x, y)}",
)
checked += 1
self.assertGreater(checked, 0)
def testItemViewInsetsAndListCornersAcrossThemes(self):
"""Keep table insets and list corners stable while resizing and scrolling."""
app = application()
originalStyleSheet = app.styleSheet()
try:
for viewType in (AppQTableView, AppQListView):
host = QWidget()
view = viewType(host)
view.move(10, 10)
model = QStandardItemModel(view)
for row in range(20):
model.appendRow([QStandardItem(f'Item {row}') for _ in range(5)])
view.setModel(model)
view.setAlternatingRowColors(True)
try:
for theme in (AppStyleSheet.Light, AppStyleSheet.Dark):
app.setStyleSheet(AppStyleSheet.forTheme(theme))
for direction in (QtCore.Qt.LeftToRight, QtCore.Qt.RightToLeft):
view.setLayoutDirection(direction)
for size in (
QtCore.QSize(360, 180),
QtCore.QSize(620, 340),
):
with self.subTest(
view=viewType,
theme=theme,
direction=direction,
size=size,
):
view.resize(size)
host.resize(size + QtCore.QSize(20, 20))
host.show()
view.setCurrentIndex(model.index(0, 0))
view.scrollToTop()
processQtEvents()
self.assertItemViewInsetsAndListCorners(view, theme)
view.setCurrentIndex(model.index(19, 4))
view.scrollToBottom()
view.horizontalScrollBar().setValue(
view.horizontalScrollBar().maximum()
)
processQtEvents()
self.assertItemViewInsetsAndListCorners(view, theme)
model.clear()
processQtEvents()
self.assertItemViewInsetsAndListCorners(view, AppStyleSheet.Dark)
viewportImage = view.viewport().grab().toImage()
self.assertEqual(
viewportImage.pixelColor(viewportImage.rect().center()),
QColor(AppStyleSheet.Palettes[AppStyleSheet.Dark]['panel']),
)
finally:
host.close()
host.deleteLater()
processQtEvents()
finally:
app.setStyleSheet(originalStyleSheet)
def assertRoundedPopup(self, popup):
"""Require clear outer corners and an opaque painted surface."""
image = popup.grab().toImage()
self.assertTrue(image.hasAlphaChannel())
for x in (0, image.width() - 1):
for y in (0, image.height() - 1):
self.assertEqual(image.pixelColor(x, y).alpha(), 0)
self.assertEqual(
image.pixelColor(image.width() // 2, image.height() // 2).alpha(), 255
)
self.assertTrue(popup.windowFlags() & QtCore.Qt.WindowType.FramelessWindowHint)
def testMenuCornersStayTransparentAcrossThemesAndReopens(self):
"""Keep menu and submenu surfaces rounded without changing activation."""
app = application()
originalStyleSheet = app.styleSheet()
menu = AppQMenu()
action = menu.addAction('Example')
action.setCheckable(True)
submenu = AppQMenu(parent=menu)
submenu.setTitle('More')
submenu.addAction('Another example')
menu.addMenu(submenu)
try:
for theme in (AppStyleSheet.Light, AppStyleSheet.Dark) * 3:
app.setStyleSheet(AppStyleSheet.forTheme(theme))
for direction in (QtCore.Qt.LeftToRight, QtCore.Qt.RightToLeft):
with self.subTest(theme=theme, direction=direction):
# Reproduce the shadow installed by the Windows 11 style
# even when the headless test application uses Fusion.
shadow = QGraphicsDropShadowEffect(menu)
shadow.setBlurRadius(3)
shadow.setOffset(3, 3)
menu.setGraphicsEffect(shadow)
menu.setLayoutDirection(direction)
menu.popup(QtCore.QPoint(20, 20))
processQtEvents()
self.assertRoundedPopup(menu)
self.assertFalse(shiboken6.isValid(shadow))
menu.setActiveAction(submenu.menuAction())
openKey = (
QtCore.Qt.Key_Right
if direction == QtCore.Qt.LeftToRight
else QtCore.Qt.Key_Left
)
QTest.keyClick(menu, openKey)
waitFor(submenu.isVisible)
self.assertRoundedPopup(submenu)
QTest.keyClick(submenu, QtCore.Qt.Key_Escape)
self.assertFalse(submenu.isVisible())
menu.setActiveAction(action)
wasChecked = action.isChecked()
QTest.keyClick(menu, QtCore.Qt.Key_Return)
self.assertEqual(action.isChecked(), not wasChecked)
self.assertFalse(menu.isVisible())
finally:
menu.close()
menu.deleteLater()
processQtEvents()
app.setStyleSheet(originalStyleSheet)
def testComboPopupCornersStayTransparentAcrossThemesAndReopens(self):
"""Paint one rounded view and retain native keyboard selection and reuse."""
app = application()
originalStyleSheet = app.styleSheet()
combo = AppQComboBox()
combo.addItems(['First', 'Second', 'Third'])
combo.resize(260, 36)
combo.show()
popup = combo.view().window()
# Some native styles already enable translucency while polishing.
popup.setAttribute(QtCore.Qt.WidgetAttribute.WA_TranslucentBackground)
try:
for theme in (AppStyleSheet.Light, AppStyleSheet.Dark) * 3:
app.setStyleSheet(AppStyleSheet.forTheme(theme))
for direction in (QtCore.Qt.LeftToRight, QtCore.Qt.RightToLeft):
with self.subTest(theme=theme, direction=direction):
shadow = QGraphicsDropShadowEffect(popup)
shadow.setBlurRadius(3)
shadow.setOffset(3, 3)
popup.setGraphicsEffect(shadow)
combo.setLayoutDirection(direction)
combo.setCurrentIndex(0)
combo.showPopup()
processQtEvents()
self.assertIs(combo.view().window(), popup)
self.assertRoundedPopup(popup)
# Dropdown padding must remain opaque over the page below.
popupImage = combo.view().grab().toImage()
self.assertEqual(
popupImage.pixelColor(
popupImage.width() // 2,
round(2 * popupImage.devicePixelRatio()),
).alpha(),
255,
)
self.assertFalse(shiboken6.isValid(shadow))
QTest.keyClick(combo.view(), QtCore.Qt.Key_Down)
QTest.keyClick(combo.view(), QtCore.Qt.Key_Return)
self.assertEqual(combo.currentIndex(), 1)
self.assertFalse(popup.isVisible())
combo.showPopup()
processQtEvents()
self.assertRoundedPopup(popup)
QTest.keyClick(combo.view(), QtCore.Qt.Key_Escape)
self.assertFalse(popup.isVisible())
self.assertEqual(combo.currentIndex(), 1)
finally:
combo.hidePopup()
combo.close()
combo.deleteLater()
processQtEvents()
app.setStyleSheet(originalStyleSheet)
def testClearButtonKeepsNativeBehaviorAndGeometryAcrossThemes(self):
"""Keep embedded buttons centered, theme-correct and bounded after restyling."""
app = application()
originalStyleSheet = app.styleSheet()
edit = AppQLineEdit()
edit.setClearButtonEnabled(True)
edit.resize(340, 36)
edit.show()
edit.activateWindow()
edit.setFocus()
try:
for theme in (AppStyleSheet.Light, AppStyleSheet.Dark) * 3:
for direction in (QtCore.Qt.LeftToRight, QtCore.Qt.RightToLeft):
with self.subTest(theme=theme, direction=direction):
edit.setLayoutDirection(direction)
edit.setText('example search')
edit.setSelection(0, 7)
app.setStyleSheet(AppStyleSheet.forTheme(theme))
processQtEvents()
self.assertEqual(edit.selectedText(), 'example')
buttons = edit.findChildren(QToolButton)
self.assertEqual(len(buttons), 1)
button = buttons[0]
self.assertTrue(edit.rect().contains(button.geometry()))
self.assertLessEqual(
abs(
button.geometry().center().y()
- edit.rect().center().y()
),
1,
)
self.assertFalse(button.icon().isNull())
pixmap = button.icon().pixmap(16, 16).toImage()
colors = [
pixmap.pixelColor(x, y)
for x in range(pixmap.width())
for y in range(pixmap.height())
if pixmap.pixelColor(x, y).alpha() > 128
]
self.assertTrue(colors)
self.assertTrue(
all(
(color.lightness() > 128)
== (theme == AppStyleSheet.Dark)
for color in colors
)
)
QTest.mouseClick(button, QtCore.Qt.LeftButton)
self.assertEqual(edit.text(), '')
self.assertTrue(edit.hasFocus())
self.assertTrue(waitFor(lambda: not button.isVisible()))
edit.setText('read only')
edit.setReadOnly(True)
app.setStyleSheet(AppStyleSheet.forTheme(AppStyleSheet.Light))
processQtEvents()
self.assertEqual(edit.text(), 'read only')
self.assertFalse(edit.findChild(QToolButton).isEnabled())
app.setStyleSheet(AppStyleSheet.forTheme(AppStyleSheet.Dark))
edit.setClearButtonEnabled(False)
processQtEvents()
self.assertFalse(edit.isClearButtonEnabled())
self.assertFalse(edit.findChildren(QToolButton))
finally:
edit.close()
edit.deleteLater()
processQtEvents()
app.setStyleSheet(originalStyleSheet)
def testFlatAndLinkButtonsRetainFocusedOutlineDuringHover(self):
"""Do not let flat presentation clear the keyboard-focus frame."""
app = application()
originalStyleSheet = app.styleSheet()
try:
for theme in (AppStyleSheet.Light, AppStyleSheet.Dark):
app.setStyleSheet(AppStyleSheet.forTheme(theme))
regular = QPushButton('Regular')
flat = QPushButton('Flat')
flat.setFlat(True)
link = QPushButton('Link')
link.setFlat(True)
link.setObjectName('SettingsLinkButton')
expected = self.focusedEdgeColors(regular)
with self.subTest(theme=theme, button='flat'):
self.assertEqual(self.focusedEdgeColors(flat), expected)
with self.subTest(theme=theme, button='settings-link'):
self.assertEqual(self.focusedEdgeColors(link), expected)
finally:
app.setStyleSheet(originalStyleSheet)
def testObjectSpecificButtonsUseDisabledForeground(self):
"""Do not let object-name color rules defeat disabled semantics."""
app = application()
originalStyleSheet = app.styleSheet()
try:
for theme in (AppStyleSheet.Light, AppStyleSheet.Dark):
app.setStyleSheet(AppStyleSheet.forTheme(theme))
expected = self.disabledButtonTextColor(QPushButton('Disabled'))
for objectName in ('SettingsLinkButton', 'NavigationPageButton'):
button = QPushButton('Disabled')
button.setObjectName(objectName)
if objectName == 'NavigationPageButton':
button.setCheckable(True)
button.setChecked(True)
with self.subTest(theme=theme, objectName=objectName):
self.assertEqual(
self.disabledButtonTextColor(button),
expected,
)
finally:
app.setStyleSheet(originalStyleSheet)
def testNavigationCompositeLabelTracksItsOwnVisualState(self):
"""Style the real navigation label without ancestor pseudo-states."""
app = application()
originalStyleSheet = app.styleSheet()
try:
for theme in (AppStyleSheet.Light, AppStyleSheet.Dark):
app.setStyleSheet(AppStyleSheet.forTheme(theme))
navigation = NavigationView()
navigation.addPage('home', QWidget(), 'Home', 'house-door.svg')
navigation.addPage('log', QWidget(), 'Log', 'pin-angle.svg')
navigation.setExpanded(True, animated=False)
navigation.resize(240, 400)
navigation.show()
processQtEvents()
homeButton = navigation._pages['home'].button
logButton = navigation._pages['log'].button
homeLabel = homeButton._textLabel
logLabel = logButton._textLabel
palette = AppStyleSheet.paletteForTheme(theme)
with self.subTest(theme=theme, state='selected'):
self.assertEqual(
homeLabel.palette().color(QPalette.ColorRole.WindowText),
QColor(palette['text_strong']),
)
with self.subTest(theme=theme, state='unselected'):
self.assertEqual(
logLabel.palette().color(QPalette.ColorRole.WindowText),
QColor(palette['text']),
)
navigation.setCurrentPage('log')
processQtEvents()
with self.subTest(theme=theme, state='selection-changed'):
self.assertEqual(
homeLabel.palette().color(QPalette.ColorRole.WindowText),
QColor(palette['text']),
)
self.assertEqual(
logLabel.palette().color(QPalette.ColorRole.WindowText),
QColor(palette['text_strong']),
)
navigation.setDisabled(True)
processQtEvents()
for label in (homeLabel, logLabel):
with self.subTest(theme=theme, state='disabled'):
self.assertEqual(
label.palette().color(QPalette.ColorRole.WindowText),
QColor(palette['disabled']),
)
navigation.close()
navigation.deleteLater()
processQtEvents()
finally:
app.setStyleSheet(originalStyleSheet)
class StyleSheetStateCompositionTest(unittest.TestCase):
"""Protect shared composite geometry and semantic-state selector ordering."""
def testCompositeInputSubcontrolsRemainInsideTheOuterFrame(self):
"""Keep combo and spin hover fills out of the focus-border region."""
selectors = (
'QComboBox::drop-down {',
'QSpinBox::up-button,',
'QSpinBox::down-button,',
)
for theme in (AppStyleSheet.Light, AppStyleSheet.Dark):
stylesheet = AppStyleSheet.forTheme(theme)
for selector in selectors:
with self.subTest(theme=theme, selector=selector):
rule = stylesheet.split(selector, 1)[1].split('}', 1)[0]
self.assertIn('subcontrol-origin: padding;', rule)
def testSemanticStateSelectorsCannotBeReplacedByDecorativeHover(self):
"""Keep selected, checked, and disabled semantics above hover styling."""
protectedRelationships = (
('QListView::item:hover {', 'QListView::item:selected {'),
('QToolButton:hover {', 'QToolButton:checked {'),
('QMenu::item:selected {', 'QMenu::item:disabled:selected {'),
('QCheckBox::indicator:hover {', 'QCheckBox::indicator:checked {'),
('QCheckBox::indicator:checked {', 'QCheckBox::indicator:disabled,'),
)
for theme in (AppStyleSheet.Light, AppStyleSheet.Dark):
stylesheet = AppStyleSheet.forTheme(theme)
for decorative, semantic in protectedRelationships:
with self.subTest(theme=theme, semantic=semantic):
self.assertLess(
stylesheet.index(decorative),
stylesheet.index(semantic),
)
self.assertIn('QTabBar::tab:hover:!selected {', stylesheet)
self.assertIn('QPushButton:flat:focus {', stylesheet)
self.assertIn('QPushButton#SettingsLinkButton:disabled {', stylesheet)
self.assertIn(
'QPushButton#NavigationPageButton:checked:disabled {',
stylesheet,
)
if __name__ == '__main__':
unittest.main()