From 1ae88d09e02f51148b4dc6b6e5af716c1843fa1e Mon Sep 17 00:00:00 2001 From: Loren Eteval Date: Sat, 22 Aug 2026 18:30:49 +0800 Subject: [PATCH] Audit application stylesheet state interactions Signed-off-by: Loren Eteval --- Furious/Qt/AGENTS.md | 3 + Furious/Qt/StyleSheets/Base.py | 9 + Furious/Qt/StyleSheets/Controls.py | 25 ++- Furious/Widget/NavigationView.py | 15 ++ tests/test_architecture_refactors.py | 17 ++ tests/test_stylesheet_states.py | 254 +++++++++++++++++++++++++++ 6 files changed, 319 insertions(+), 4 deletions(-) create mode 100644 tests/test_stylesheet_states.py diff --git a/Furious/Qt/AGENTS.md b/Furious/Qt/AGENTS.md index ceaad0b..0ee8372 100644 --- a/Furious/Qt/AGENTS.md +++ b/Furious/Qt/AGENTS.md @@ -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 diff --git a/Furious/Qt/StyleSheets/Base.py b/Furious/Qt/StyleSheets/Base.py index 808cb20..ac1a472 100644 --- a/Furious/Qt/StyleSheets/Base.py +++ b/Furious/Qt/StyleSheets/Base.py @@ -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; diff --git a/Furious/Qt/StyleSheets/Controls.py b/Furious/Qt/StyleSheets/Controls.py index 9979164..a63bcb9 100644 --- a/Furious/Qt/StyleSheets/Controls.py +++ b/Furious/Qt/StyleSheets/Controls.py @@ -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; diff --git a/Furious/Widget/NavigationView.py b/Furious/Widget/NavigationView.py index 1dd9937..e5a2ebe 100644 --- a/Furious/Widget/NavigationView.py +++ b/Furious/Widget/NavigationView.py @@ -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: diff --git a/tests/test_architecture_refactors.py b/tests/test_architecture_refactors.py index bc9942f..828f801 100644 --- a/tests/test_architecture_refactors.py +++ b/tests/test_architecture_refactors.py @@ -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) diff --git a/tests/test_stylesheet_states.py b/tests/test_stylesheet_states.py new file mode 100644 index 0000000..1f3d7a9 --- /dev/null +++ b/tests/test_stylesheet_states.py @@ -0,0 +1,254 @@ +# Copyright (C) 2024–present Loren Eteval & contributors +# +# 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 . + +"""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()