diff --git a/Furious/Qt/AppStyleSheet.py b/Furious/Qt/AppStyleSheet.py index 2710db8..111d29f 100644 --- a/Furious/Qt/AppStyleSheet.py +++ b/Furious/Qt/AppStyleSheet.py @@ -278,6 +278,7 @@ class AppStyleSheet: caretUpIcon = f'{iconPrefix}/caret-up-fill.svg' caretRightIcon = f'{iconPrefix}/caret-right-fill.svg' checkIcon = ':/Icons/bootstrap/white/check.svg' + clearIcon = f'{iconPrefix}/x.svg' return composeStyleSheet( palette, @@ -286,4 +287,5 @@ class AppStyleSheet: caretUpIcon, caretRightIcon, checkIcon, + clearIcon, ) diff --git a/Furious/Qt/QtWidgets.py b/Furious/Qt/QtWidgets.py index 2b8fcf0..2b973e8 100644 --- a/Furious/Qt/QtWidgets.py +++ b/Furious/Qt/QtWidgets.py @@ -25,7 +25,7 @@ from Furious.Qt.AppStyleSheet import * from Furious.Qt.DynamicTheme import * from Furious.Qt.DynamicTranslate import gettext as _ from Furious.Qt.QtGui import * -from Furious.Qt.Signals import connectWeakly +from Furious.Qt.Signals import connectWeakly, singleShotWeakly from PySide6 import QtCore from PySide6.QtGui import * @@ -625,8 +625,31 @@ class AppQLineEdit(Mixins.QTranslatable, QLineEdit): def __init__(self, *args, **kwargs): """Initialize the AppQLineEdit.""" + self._clearIconRefreshPending = False + super().__init__(*args, **kwargs) + def changeEvent(self, event): + """Refresh the cached native icon after Qt finishes applying its style.""" + super().changeEvent(event) + + if ( + event.type() == QtCore.QEvent.Type.StyleChange + and self.isClearButtonEnabled() + and not self._clearIconRefreshPending + ): + self._clearIconRefreshPending = True + + singleShotWeakly(0, self, '_refreshClearIcon') + + def _refreshClearIcon(self): + """Recreate the native action outside Qt's child-polishing traversal.""" + self._clearIconRefreshPending = False + + if self.isClearButtonEnabled(): + self.setClearButtonEnabled(False) + self.setClearButtonEnabled(True) + def retranslate(self): """Refresh translated text for the app q line edit.""" self.setPlaceholderText(_(self.placeholderText())) diff --git a/Furious/Qt/StyleSheets/Controls.py b/Furious/Qt/StyleSheets/Controls.py index a63bcb9..1977153 100644 --- a/Furious/Qt/StyleSheets/Controls.py +++ b/Furious/Qt/StyleSheets/Controls.py @@ -26,6 +26,7 @@ def controlStyleSheet( caretUpIcon, caretRightIcon, checkIcon, + clearIcon, ): """Return interactive control styling.""" return dedent(f""" @@ -361,6 +362,33 @@ def controlStyleSheet( selection-color: {palette['selection_text']}; }} + QLineEdit {{ + lineedit-clear-button-icon: url("{clearIcon}"); + }} + + /* Embedded actions keep Qt's geometry, without toolbar padding. */ + QLineEdit QToolButton {{ + min-width: 0; + min-height: 0; + padding: 0; + margin: 0; + border: none; + border-radius: 4px; + background-color: transparent; + }} + + QLineEdit QToolButton:hover {{ + background-color: {palette['hover']}; + }} + + QLineEdit QToolButton:pressed {{ + background-color: {palette['pressed']}; + }} + + QLineEdit QToolButton:disabled {{ + background-color: transparent; + }} + QLineEdit:hover, QTextEdit:hover, QPlainTextEdit:hover, diff --git a/Furious/Qt/StyleSheets/__init__.py b/Furious/Qt/StyleSheets/__init__.py index 30ac0b3..e9188b3 100644 --- a/Furious/Qt/StyleSheets/__init__.py +++ b/Furious/Qt/StyleSheets/__init__.py @@ -29,6 +29,7 @@ def composeStyleSheet( caretUpIcon, caretRightIcon, checkIcon, + clearIcon, ): """Return the complete stylesheet without exposing component fragments.""" return '\n\n'.join( @@ -40,6 +41,7 @@ def composeStyleSheet( caretUpIcon, caretRightIcon, checkIcon, + clearIcon, ), dataViewStyleSheet(palette, progressBarStyleSheet, checkIcon), ) diff --git a/tests/test_stylesheet_states.py b/tests/test_stylesheet_states.py index 1f3d7a9..f80548a 100644 --- a/tests/test_stylesheet_states.py +++ b/tests/test_stylesheet_states.py @@ -19,15 +19,15 @@ from __future__ import annotations -from Furious.Qt import AppStyleSheet +from Furious.Qt import AppQLineEdit, 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 PySide6.QtWidgets import QPushButton, QStyleOptionButton, QToolButton, QWidget -from tests.support import application, collectAtBoundary, processQtEvents +from tests.support import application, collectAtBoundary, processQtEvents, waitFor import unittest @@ -84,6 +84,74 @@ class StyleSheetStateRenderingTest(unittest.TestCase): return color + 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()