Audit application stylesheet state interactions

Signed-off-by: Loren Eteval <loren.eteval@proton.me>
This commit is contained in:
Loren Eteval
2026-08-22 21:28:19 +08:00
parent 226727cead
commit 1ae88d09e0
6 changed files with 319 additions and 4 deletions
+3
View File
@@ -16,6 +16,9 @@ Use the `manage-qt-pyside6-lifetimes` skill for any Qt ownership or lifecycle ch
where practical.
- `AppStyleSheet` remains the sole public stylesheet authority. Internal `StyleSheets` modules are data-oriented QSS
fragments that consume the centralized semantic palette; application consumers must not import fragments directly.
- For composite controls, let the outer widget own its frame, radius, and focus indication. Sub-control hover/pressed
fills must remain inside that frame (normally through padding/content origin), and state-order changes should be
verified in both application themes.
## Ownership and destruction
+9
View File
@@ -244,6 +244,15 @@ def baseStyleSheet(palette):
color: {palette['text_strong']};
}}
QPushButton#SettingsLinkButton:focus {{
border: 1px solid {palette['accent']};
}}
QPushButton#SettingsLinkButton:disabled {{
border-color: transparent;
color: {palette['disabled']};
}}
QCheckBox#SettingsToggle::indicator {{
width: 34px;
height: 18px;
+21 -4
View File
@@ -231,17 +231,30 @@ def controlStyleSheet(
background-color: {palette['accent_soft_hover']};
}}
QPushButton#NavigationPageButton QLabel {{
QLabel#NavigationPageButtonText {{
border: none;
background-color: transparent;
color: {palette['text']};
}}
QPushButton#NavigationPageButton:checked QLabel {{
QLabel#NavigationPageButtonText[selected="true"] {{
color: {palette['text_strong']};
font-weight: 600;
}}
QPushButton#NavigationToggleButton:disabled,
QPushButton#NavigationPageButton:disabled {{
color: {palette['disabled']};
}}
QPushButton#NavigationPageButton:checked:disabled {{
background-color: {palette['accent_soft']};
}}
QLabel#NavigationPageButtonText:disabled {{
color: {palette['disabled']};
}}
QPushButton#SearchButton {{
padding: 0;
}}
@@ -319,6 +332,10 @@ def controlStyleSheet(
background-color: transparent;
}}
QPushButton:flat:focus {{
border-color: {palette['accent']};
}}
QPushButton:disabled {{
border-color: {palette['border']};
background-color: {palette['raised']};
@@ -432,7 +449,7 @@ def controlStyleSheet(
QSpinBox::up-button,
QDoubleSpinBox::up-button {{
subcontrol-origin: border;
subcontrol-origin: padding;
subcontrol-position: top right;
width: 28px;
height: 16px;
@@ -445,7 +462,7 @@ def controlStyleSheet(
QSpinBox::down-button,
QDoubleSpinBox::down-button {{
subcontrol-origin: border;
subcontrol-origin: padding;
subcontrol-position: bottom right;
width: 28px;
height: 16px;
+15
View File
@@ -57,6 +57,13 @@ class _NavigationButton(IconTextPushButton):
self.selectionIndicator = None
if hasSelectionIndicator:
# Qt does not reliably evaluate a button pseudo-state when that
# state appears on the ancestor side of a descendant QSS selector.
# Keep the visible label's selected state on the label itself so
# checked and disabled colors remain independent.
self._textLabel.setObjectName('NavigationPageButtonText')
self._textLabel.setProperty('selected', False)
self.selectionIndicator = QFrame(parent=self)
self.selectionIndicator.setObjectName('NavigationSelectionIndicator')
self.selectionIndicator.setFixedWidth(self.SelectionIndicatorWidth)
@@ -73,6 +80,14 @@ class _NavigationButton(IconTextPushButton):
@QtCore.Slot(bool)
def _selectionChanged(self, selected: bool):
"""Show the independent indicator for a selected page."""
self._textLabel.setProperty('selected', selected)
style = self._textLabel.style()
style.unpolish(self._textLabel)
style.polish(self._textLabel)
self._textLabel.update()
self.selectionIndicator.setVisible(selected)
if selected:
+17
View File
@@ -500,3 +500,20 @@ class StyleSheetCompositionTest(TestCase):
dropDownRule = dropDownRule.split('}', 1)[0]
self.assertIn('subcontrol-origin: padding;', dropDownRule)
def testSpinBoxButtonHoverStaysInsideTheFocusBorder(self):
"""Keep both spin-button hover fills inside the outer focus outline."""
selectors = (
'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):
buttonRule = stylesheet.split(selector, 1)[1]
buttonRule = buttonRule.split('}', 1)[0]
self.assertIn('subcontrol-origin: padding;', buttonRule)
+254
View File
@@ -0,0 +1,254 @@
# 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 AppStyleSheet
from Furious.Widget.NavigationView import NavigationView
from PySide6 import QtCore
from PySide6.QtGui import QColor, QPalette
from PySide6.QtTest import QTest
from PySide6.QtWidgets import QPushButton, QStyleOptionButton, QWidget
from tests.support import application, collectAtBoundary, processQtEvents
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 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()