diff --git a/Furious/Qt/QtWidgets.py b/Furious/Qt/QtWidgets.py index 40c198f9..456b26fa 100644 --- a/Furious/Qt/QtWidgets.py +++ b/Furious/Qt/QtWidgets.py @@ -2397,11 +2397,13 @@ class AppQSpinBox(QSpinBox): class AppQTableView(QTableView): """Represent app Qt table view.""" + SelectionDelegateType = _AppQTableSelectionDelegate + def __init__(self, *args, **kwargs): """Initialize the AppQTableView.""" super().__init__(*args, **kwargs) - _installRoundedSelectionDelegate(self, _AppQTableSelectionDelegate) + _installRoundedSelectionDelegate(self, self.SelectionDelegateType) self.setWordWrap(False) self.setAlternatingRowColors(True) diff --git a/Furious/Qt/StyleSheets/DataViews.py b/Furious/Qt/StyleSheets/DataViews.py index 0b909376..dc9f8354 100644 --- a/Furious/Qt/StyleSheets/DataViews.py +++ b/Furious/Qt/StyleSheets/DataViews.py @@ -63,6 +63,10 @@ def dataViewStyleSheet(palette, progressBarStyleSheet, checkIcon): color: {palette['text_strong']}; }} + ServerTableView {{ + qproperty-hoverTextColor: {palette['text_strong']}; + }} + QTableView::item:selected, QTableWidget::item:selected, QTreeView::item:selected, diff --git a/Furious/Widget/AGENTS.md b/Furious/Widget/AGENTS.md index 2a905322..0e8af21f 100644 --- a/Furious/Widget/AGENTS.md +++ b/Furious/Widget/AGENTS.md @@ -62,6 +62,9 @@ source tree's root. Favorite SVG decoration follows the remark's foreground and selected-text roles, including privilege-dependent connection colors. Use the shared color authority and view palette rather than a dark/light test; keep selected icon and text behavior aligned. The selected-rendering cases in `tests/test_qt_interactions.py` are the pixel anchor. + The server table's view-owned selection delegate also applies the stylesheet's hover text token to favorite + decoration while leaving selected painting to Qt. The color is a Qt stylesheet property, not a separate theme + decision or hover-state registry. Mouse-hover rendering cases cover color restoration and delegate destruction. - Endpoint lookup belongs to `EndpointInfoService`; the map renders validated results and has a no-WebEngine fallback. Optional WebEngine import failure must not prevent importing the widget/package, and hidden presentation must not retarget a queued lookup. diff --git a/Furious/Widget/ServerTableView.py b/Furious/Widget/ServerTableView.py index 27e8c519..aa6cf21c 100644 --- a/Furious/Widget/ServerTableView.py +++ b/Furious/Widget/ServerTableView.py @@ -31,6 +31,7 @@ from Furious.Plugins import ( ) from Furious.Qt import * from Furious.Qt.Signals import connectWeakly, singleShotWeakly +from Furious.Qt.QtWidgets import _AppQTableSelectionDelegate from Furious.Qt import gettext as _ from Furious.Service import ( ProfileTestField, @@ -495,6 +496,24 @@ def _favoriteIconWithColor(colorName, selectedColorName): return icon +class _ServerTableSelectionDelegate(_AppQTableSelectionDelegate): + """Keep favorite decorations aligned with native hover text painting.""" + + def initStyleOption(self, option, index): + super().initStyleOption(option, index) + + if ( + option.state & QStyle.StateFlag.State_MouseOver + and not option.state & QStyle.StateFlag.State_Selected + and not option.icon.isNull() + ): + color = self.parent().hoverTextColor + + if color.isValid(): + name = color.name(QColor.NameFormat.HexArgb) + option.icon = _favoriteIconWithColor(name, name) + + class UserServersTableModel(QtCore.QAbstractTableModel): """Expose user servers table data through a Qt item model.""" @@ -928,6 +947,8 @@ class ServerTableView( activeServerChanged = QtCore.Signal() + SelectionDelegateType = _ServerTableSelectionDelegate + RowHeight = 42 Headers = [ @@ -942,8 +963,19 @@ class ServerTableView( ServerTableColumn('Speed'), ] + @QtCore.Property(QColor) + def hoverTextColor(self): + """Read the hover text token supplied by the application stylesheet.""" + return self._hoverTextColor + + @hoverTextColor.setter + def hoverTextColor(self, color): + self._hoverTextColor = QColor(color) + def __init__(self, *args, **kwargs): """Initialize the server table view.""" + self._hoverTextColor = QColor() + configurationEditorFactory, self.qrCodeWindowFactory, importActionsFactory = ( kwargs.pop('configurationEditorFactory'), kwargs.pop('qrCodeWindowFactory'), diff --git a/tests/README.md b/tests/README.md index 9f2f1038..9b8d938a 100644 --- a/tests/README.md +++ b/tests/README.md @@ -141,7 +141,7 @@ retains the worker/script until helper completion, before cleanup admits another | [test_dialog_geometry.py](test_dialog_geometry.py) | Dialog show/open/exec preparation, sizing/centering, failure cleanup, specialized message-box geometry and transient destruction. | | [test_layout_matrix.py](test_layout_matrix.py) | Navigation and message-box layouts in fresh processes at scale factors 1, 1.25, 1.5, and 2, under both themes. | | [test_ui_behavior.py](test_ui_behavior.py) | Translation and editor mappings, settings construction visibility/organization/search/platform gates, stable server/routing moves, real QR rendering/decoding, incremental log filtering/tail behavior, connected-routing change notices, message boxes and connection-error copy/log navigation, shared Home/tray state, preserved message payloads and managed-versus-plain button retranslation. | -| [test_qt_interactions.py](test_qt_interactions.py) | Real keyboard/mouse/focus and proxy mapping, scoped shortcuts, sorting/selection, stable subscription deletion confirmations, debounced Home search, favorite actions and intersecting filters, Tests-menu selection color, shared settings, batched import/delete/duplication, cancellation, preparation failure, and owner destruction. | +| [test_qt_interactions.py](test_qt_interactions.py) | Real keyboard/mouse/focus and proxy mapping, scoped shortcuts, sorting/selection, stable subscription deletion confirmations, debounced Home search, favorite actions and intersecting filters, selected/hovered favorite SVG and remark colors across themes and privilege states, Tests-menu selection color, shared settings, batched import/delete/duplication, cancellation, preparation failure, and owner destruction. | | [test_qr_export_scalability.py](test_qr_export_scalability.py) | Production capture cap, immediate single export, incremental yielding, failure/cancel/close paths, immutable snapshots, window-owned state destruction. | | [test_stylesheet_states.py](test_stylesheet_states.py) | Targeted rendering/alpha/geometry assertions for table/list insets, popup corners, clear buttons, focus/disabled states, and stylesheet composition. | | [test_theme_transition.py](test_theme_transition.py) | Real cross-fades, immediate theme activation, interruption, per-window resize/destruction, coordinator teardown, animation policy, native resize/deletion probes, deferred completion delivery, replacement/stop flushing and owner-first cancellation. | diff --git a/tests/test_qt_interactions.py b/tests/test_qt_interactions.py index 03285abe..f3867ef2 100644 --- a/tests/test_qt_interactions.py +++ b/tests/test_qt_interactions.py @@ -2231,6 +2231,7 @@ class ProfileMutationBatchTest(unittest.TestCase): index = table.proxyIndexFromSourceIndex(remark) option = QStyleOptionViewItem() option.initFrom(table) + option.state &= ~QStyle.StateFlag.State_MouseOver table.itemDelegate().initStyleOption(option, index) self.assertEqual(option.text, profiles[0].itemRemark) @@ -2310,6 +2311,7 @@ class ProfileMutationBatchTest(unittest.TestCase): option = QStyleOptionViewItem() option.initFrom(table) + option.state &= ~QStyle.StateFlag.State_MouseOver table.itemDelegate().initStyleOption(option, index) self.assertEqual( @@ -2367,6 +2369,125 @@ class ProfileMutationBatchTest(unittest.TestCase): QtGui.QColor(AppHue.currentColor()), ) + def testHoveredFavoriteIconMatchesRenderedRemarkAndRestoresProfileColor(self): + """Real mouse hover keeps the SVG and text aligned in either theme.""" + with self.table(2) as (table, controller): + for profile in Storage.UserServers(): + profile.metadata.favorite = True + + table.resize(900, 220) + table.show() + + delegate = table.itemDelegate() + + self.assertIs(delegate, table.selectionDelegate) + self.assertIs(delegate.parent(), table) + + with ( + mock.patch( + 'Furious.Qt.DynamicTheme.AppConnectionController', + return_value=controller, + ), + mock.patch('Furious.Qt.DynamicTheme.SystemRuntime.isAdmin') as isAdmin, + ): + for theme in (AppStyleSheet.Light, AppStyleSheet.Dark): + table.setStyleSheet(AppStyleSheet.forTheme(theme)) + + for connected, admin in ( + (False, False), + (True, False), + (True, True), + ): + controller.isConnected.return_value = connected + isAdmin.return_value = admin + + table.activateItemByIndex(0, True) + table.sourceModel.emitAllChanged() + table.clearSelection() + + for row in (0, 1): + index = table.proxyIndexFromSourceRow(row) + cellRect = table.visualRect(index) + + for hovered in (True, False): + with self.subTest( + theme=theme, + connected=connected, + admin=admin, + row=row, + hovered=hovered, + ): + QTest.mouseMove( + table.viewport(), + ( + cellRect.center() + if hovered + else QtCore.QPoint( + 5, table.viewport().height() - 5 + ) + ), + ) + processQtEvents() + + image = table.viewport().grab(cellRect).toImage() + + color = QtGui.QColor( + AppStyleSheet.Palettes[theme]['text_strong'] + if hovered + else ( + AppHue.currentColor() + if row == 0 + else table.palette().color( + QtGui.QPalette.ColorRole.Text + ) + ) + ) + + option = QStyleOptionViewItem() + option.initFrom(table) + option.widget = table + option.rect = cellRect + option.decorationSize = table.iconSize() + table.itemDelegate().initStyleOption(option, index) + + for element in ( + QStyle.SubElement.SE_ItemViewItemDecoration, + QStyle.SubElement.SE_ItemViewItemText, + ): + region = ( + table.style() + .subElementRect(element, option, table) + .translated(-cellRect.topLeft()) + ) + ratio = image.devicePixelRatio() + + pixels = [ + image.pixelColor(x, y) + for x in range( + round(region.left() * ratio), + round((region.right() + 1) * ratio), + ) + for y in range( + round(region.top() * ratio), + round((region.bottom() + 1) * ratio), + ) + ] + + self.assertTrue(pixels) + + closest = min( + max( + abs(pixel.red() - color.red()), + abs(pixel.green() - color.green()), + abs(pixel.blue() - color.blue()), + ) + for pixel in pixels + ) + + self.assertLessEqual(closest, 12, element.name) + + self.assertFalse(isValid(delegate)) + def testSelectedFavoriteIconMatchesRenderedRemarkAndRestoresProfileColor(self): with self.table(2) as (table, controller): for profile in Storage.UserServers(): @@ -2431,6 +2552,19 @@ class ProfileMutationBatchTest(unittest.TestCase): if selected: table.selectAll() + QTest.mouseMove( + table.viewport(), + ( + table.visualRect( + table.proxyIndexFromSourceRow(0) + ).center() + if selected + else QtCore.QPoint( + 5, table.viewport().height() - 5 + ) + ), + ) + processQtEvents() backgrounds = {}