From f4ecd5ce1b323de063c616f6106665be8d1b650f Mon Sep 17 00:00:00 2001 From: Loren Eteval Date: Thu, 20 Aug 2026 16:55:48 +0800 Subject: [PATCH] Modernize Fluent item views Signed-off-by: Loren Eteval --- Furious/Backends/Xray/AssetListWidget.py | 60 ++++++++++----- Furious/Backends/Xray/RoutingWindow.py | 50 ++++++------ Furious/Qt/QtWidgets.py | 96 +++++++++++++++++++++++- Furious/Qt/StyleSheets/DataViews.py | 27 ++++--- Furious/Qt/__init__.py | 4 +- 5 files changed, 179 insertions(+), 58 deletions(-) diff --git a/Furious/Backends/Xray/AssetListWidget.py b/Furious/Backends/Xray/AssetListWidget.py index 35b76ad..b0f7c30 100644 --- a/Furious/Backends/Xray/AssetListWidget.py +++ b/Furious/Backends/Xray/AssetListWidget.py @@ -15,7 +15,7 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -"""Provide widgets for Xray asset viewer Qt list widget.""" +"""Provide widgets for the Xray asset viewer list.""" from __future__ import annotations @@ -59,17 +59,21 @@ class MBoxAssetExists(AppQMessageBox): self.moveToCenter() -class XrayAssetListWidget(Mixins.ThemeAware, AppQListWidget): - """Provide the Xray asset viewer Qt list widget.""" +class XrayAssetListWidget(Mixins.ThemeAware, AppQListView): + """Provide the model-based Xray asset viewer list.""" def __init__(self, *args, **kwargs): - """Initialize the Xray asset list widget.""" + """Initialize the Xray asset list view.""" super().__init__(*args, **kwargs) + self.assetModel = QStandardItemModel(parent=self) + self.setModel(self.assetModel) + self.setAlternatingRowColors(True) - self.setSelectionBehavior(AppQListWidget.SelectionBehavior.SelectRows) - self.setSelectionMode(AppQListWidget.SelectionMode.ExtendedSelection) + self.setSelectionBehavior(AppQListView.SelectionBehavior.SelectRows) + self.setSelectionMode(AppQListView.SelectionMode.ExtendedSelection) + self.setEditTriggers(AppQListView.EditTrigger.NoEditTriggers) self.setIconSize(QtCore.QSize(64, 64)) if PLATFORM == 'Linux' and SystemRuntime.ubuntuRelease() == '20.04': @@ -96,6 +100,12 @@ class XrayAssetListWidget(Mixins.ThemeAware, AppQListWidget): self.setContextMenuPolicy(QtCore.Qt.ContextMenuPolicy.CustomContextMenu) self.customContextMenuRequested.connect(self.handleCustomContextMenuRequested) + def showEvent(self, event): + """Recalculate item geometry after the view receives its display style.""" + super().showEvent(event) + + self.scheduleDelayedItemsLayout() + @QtCore.Slot(QtCore.QPoint) def handleCustomContextMenuRequested(self, point): """Handle custom context menu requested.""" @@ -103,7 +113,7 @@ class XrayAssetListWidget(Mixins.ThemeAware, AppQListWidget): def flushItemByTheme(self, theme: str): """Refresh item by theme.""" - self.clear() + self.assetModel.clear() maxlen = max( len(filename) @@ -125,7 +135,8 @@ class XrayAssetListWidget(Mixins.ThemeAware, AppQListWidget): '%Y-%m-%d %H:%M:%S' ) - item = QListWidgetItem(f'{filename:{maxlen + 6}}{mdate}') + item = QStandardItem(f'{filename:{maxlen + 6}}{mdate}') + item.setData(filename, QtCore.Qt.ItemDataRole.UserRole) item.setFont(QFont(AppFontName())) if APP().usesForcedDarkTheme(): @@ -149,7 +160,16 @@ class XrayAssetListWidget(Mixins.ThemeAware, AppQListWidget): else: item.setIcon(bootstrapIcon('file-earmark.svg')) - self.addItem(item) + self.assetModel.appendRow(item) + + def filenameAt(self, row: int) -> str: + """Return the filesystem name represented by one model row.""" + item = self.assetModel.item(row) + + if item is None: + return '' + + return str(item.data(QtCore.Qt.ItemDataRole.UserRole) or '') def flushItem(self): """Refresh item.""" @@ -224,13 +244,19 @@ class XrayAssetListWidget(Mixins.ThemeAware, AppQListWidget): # Nothing selected return - def handleResultCode(_indexes, code): + filenames = [self.filenameAt(index) for index in indexes] + filenames = [filename for filename in filenames if filename] + + if not filenames: + return + + def handleResultCode(_filenames, code): """Handle result code.""" if code == PySide6Legacy.enumValueWrapper( AppQMessageBox.StandardButton.Yes ): - for index in _indexes: - os.remove(XRAY_ASSET_DIR / self.item(index).text()) + for filename in _filenames: + os.remove(XRAY_ASSET_DIR / filename) self.flushItem() else: @@ -247,23 +273,23 @@ class XrayAssetListWidget(Mixins.ThemeAware, AppQListWidget): ) mbox.setWindowModality(QtCore.Qt.WindowModality.WindowModal) - mbox.isMulti = bool(len(indexes) > 1) - mbox.possibleRemark = f'{self.item(indexes[0]).text()}' + mbox.isMulti = bool(len(filenames) > 1) + mbox.possibleRemark = filenames[0] mbox.setText(mbox.customText()) - mbox.finished.connect(functools.partial(handleResultCode, indexes)) + mbox.finished.connect(functools.partial(handleResultCode, filenames)) # Show the MessageBox asynchronously mbox.open() def keyPressEvent(self, event): - """Handle a key press for the Xray asset viewer Qt list widget.""" + """Handle a key press for the Xray asset viewer list.""" if event.key() == QtCore.Qt.Key.Key_Delete: self.deleteSelectedItem() else: super().keyPressEvent(event) def themeChangedCallback(self, theme): - """Update the Xray asset viewer Qt list widget for a theme change.""" + """Update the Xray asset viewer list for a theme change.""" if PLATFORM == 'Linux' and SystemRuntime.ubuntuRelease() == '20.04': # Ubuntu 20.04 system dark theme does not # change menu color. Do nothing diff --git a/Furious/Backends/Xray/RoutingWindow.py b/Furious/Backends/Xray/RoutingWindow.py index d3c6bd8..b7bb11b 100644 --- a/Furious/Backends/Xray/RoutingWindow.py +++ b/Furious/Backends/Xray/RoutingWindow.py @@ -667,31 +667,34 @@ class RoutingProfileEditDialog(AppQTransientDialog): } -class RoutingRulesQListWidget(AppQListWidget): - """Provide the routing rules Qt list widget.""" +class RoutingRulesListView(AppQListView): + """Provide the model-based routing rules list.""" editRequested = QtCore.Signal() def __init__(self, routing: dict, parent=None): - """Initialize the RoutingRulesQListWidget.""" + """Initialize the routing rules list view.""" super().__init__(parent) self.routing = routing + self.rulesModel = QtCore.QStringListModel(parent=self) + self.setModel(self.rulesModel) self.setAlternatingRowColors(True) - self.setSelectionBehavior(AppQListWidget.SelectionBehavior.SelectRows) - self.setSelectionMode(AppQListWidget.SelectionMode.ExtendedSelection) - self.itemDoubleClicked.connect(self._requestEdit) + self.setSelectionBehavior(AppQListView.SelectionBehavior.SelectRows) + self.setSelectionMode(AppQListView.SelectionMode.ExtendedSelection) + self.setEditTriggers(AppQListView.EditTrigger.NoEditTriggers) + self.doubleClicked.connect(self._requestEdit) self.flushAll() - @QtCore.Slot(QListWidgetItem) - def _requestEdit(self, _item): + @QtCore.Slot(QtCore.QModelIndex) + def _requestEdit(self, _index): """Forward a row double-click without retaining a Python closure.""" self.editRequested.emit() def rules(self): - """Return the rules value used by the routing rules Qt list widget.""" + """Return the rules represented by the routing list.""" rules = self.routing.setdefault('rules', list()) if not isinstance(rules, list): @@ -700,11 +703,11 @@ class RoutingRulesQListWidget(AppQListWidget): return rules def ruleAt(self, index: int): - """Return the rule at value used by the routing rules Qt list widget.""" + """Return the rule represented by one list row.""" return self.rules()[index] def ruleText(self, rule: dict) -> str: - """Return the rule text value used by the routing rules Qt list widget.""" + """Return the display text for one routing rule.""" name, outbound, domains, ips = ( rule.get('ruleTag', '') or 'Untitled Rule', rule.get('outboundTag', 'proxy'), @@ -742,10 +745,7 @@ class RoutingRulesQListWidget(AppQListWidget): def flushAll(self): """Refresh all.""" - self.clear() - - for rule in self.rules(): - self.addItem(self.ruleText(rule)) + self.rulesModel.setStringList([self.ruleText(rule) for rule in self.rules()]) class RoutingRulesDialog(AppQTransientDialog): @@ -759,8 +759,8 @@ class RoutingRulesDialog(AppQTransientDialog): self.setWindowTitle(_('Routing Rules')) self.setWindowModality(QtCore.Qt.WindowModality.WindowModal) - self.listWidget = RoutingRulesQListWidget(self.routing, parent=self) - self.listWidget.editRequested.connect(self.editRule) + self.listView = RoutingRulesListView(self.routing, parent=self) + self.listView.editRequested.connect(self.editRule) self.addButton = AppQPushButton( _('Add'), @@ -800,7 +800,7 @@ class RoutingRulesDialog(AppQTransientDialog): layout.setContentsMargins(20, 18, 20, 20) layout.setSpacing(14) layout.addLayout(actionLayout) - layout.addWidget(self.listWidget) + layout.addWidget(self.listView) self.setLayout(layout) @@ -819,32 +819,32 @@ class RoutingRulesDialog(AppQTransientDialog): def handleResultCode(code): """Handle result code.""" if code == PySide6Legacy.enumValueWrapper(AppQDialog.DialogCode.Accepted): - self.listWidget.appendRule(dialog.routingRule()) + self.listView.appendRule(dialog.routingRule()) dialog.finished.connect(handleResultCode) dialog.open() def editRule(self): """Handle edit rule for the routing rules dialog.""" - indexes = self.listWidget.selectedIndex + indexes = self.listView.selectedIndex if len(indexes) != 1: return index = indexes[0] - dialog = RoutingRuleEditDialog(self.listWidget.ruleAt(index), parent=self) + dialog = RoutingRuleEditDialog(self.listView.ruleAt(index), parent=self) def handleResultCode(_index, code): """Handle result code.""" if code == PySide6Legacy.enumValueWrapper(AppQDialog.DialogCode.Accepted): - self.listWidget.setRule(_index, dialog.routingRule()) + self.listView.setRule(_index, dialog.routingRule()) dialog.finished.connect(functools.partial(handleResultCode, index)) dialog.open() def deleteRule(self): """Delete rule.""" - indexes = self.listWidget.selectedIndex + indexes = self.listView.selectedIndex if len(indexes) == 0: return @@ -854,7 +854,7 @@ class RoutingRulesDialog(AppQTransientDialog): if code == PySide6Legacy.enumValueWrapper( AppQMessageBox.StandardButton.Yes ): - self.listWidget.deleteRules(_indexes) + self.listView.deleteRules(_indexes) else: # Do not delete pass @@ -871,7 +871,7 @@ class RoutingRulesDialog(AppQTransientDialog): mbox.setWindowModality(QtCore.Qt.WindowModality.WindowModal) mbox.isMulti = bool(len(indexes) > 1) - mbox.possibleRemark = self.listWidget.selectedRuleText() + mbox.possibleRemark = self.listView.selectedRuleText() mbox.setText(mbox.customText()) mbox.finished.connect(functools.partial(handleResultCode, indexes)) diff --git a/Furious/Qt/QtWidgets.py b/Furious/Qt/QtWidgets.py index 9ddf45e..4c93659 100644 --- a/Furious/Qt/QtWidgets.py +++ b/Furious/Qt/QtWidgets.py @@ -46,7 +46,7 @@ __all__ = [ 'AppQHeaderView', 'AppQLabel', 'AppQLineEdit', - 'AppQListWidget', + 'AppQListView', 'AppQMainWindow', 'AppQMenu', 'AppQMenuBar', @@ -559,13 +559,99 @@ class AppQLineEdit(Mixins.QTranslatable, QLineEdit): self.setPlaceholderText(_(self.placeholderText())) -class AppQListWidget(QListWidget): - """Provide the app Qt list widget.""" +class _AppQItemViewSelectionDelegate(QStyledItemDelegate): + """Paint a Fluent-style rounded item selection.""" + + HorizontalInset = 4 + VerticalInset = 2 + Radius = 5 + + def _selectionRect(self, option): + """Return the rounded selection rectangle for one list item.""" + return QtCore.QRectF(option.rect).adjusted( + self.HorizontalInset, + self.VerticalInset, + -self.HorizontalInset, + -self.VerticalInset, + ) + + @staticmethod + def _selectionColor(option): + """Return the active or inactive theme-aware selection color.""" + colorName = ( + 'selection' if option.state & QStyle.StateFlag.State_Active else 'raised' + ) + + try: + return QColor(AppStyleSheet.paletteForTheme(APP().theme())[colorName]) + except (AttributeError, RuntimeError): + return option.palette.color(QPalette.ColorRole.Highlight) + + def paint(self, painter, option, index): + """Paint the rounded selection before native item contents.""" + if option.state & QStyle.StateFlag.State_Selected: + selectionRect = self._selectionRect(option) + + if not selectionRect.isEmpty(): + painter.save() + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + painter.setClipRect(option.rect) + painter.setPen(QtCore.Qt.PenStyle.NoPen) + painter.setBrush(self._selectionColor(option)) + painter.drawRoundedRect(selectionRect, self.Radius, self.Radius) + painter.restore() + + super().paint(painter, option, index) + + +class _AppQTableSelectionDelegate(_AppQItemViewSelectionDelegate): + """Paint one rounded background across a selected table row.""" + + def _selectionRect(self, option): + """Return the full visible-column span for the option's row.""" + table = self.parent() + header = table.horizontalHeader() + sections = [ + logical + for logical in range(header.count()) + if not header.isSectionHidden(logical) + ] + + if not sections: + return QtCore.QRectF() + + left, right = ( + min(header.sectionViewportPosition(logical) for logical in sections), + max( + header.sectionViewportPosition(logical) + header.sectionSize(logical) + for logical in sections + ), + ) + + return QtCore.QRectF( + left + self.HorizontalInset, + option.rect.top() + self.VerticalInset, + max(0, right - left - (2 * self.HorizontalInset)), + max(0, option.rect.height() - (2 * self.VerticalInset)), + ) + + +def _installRoundedSelectionDelegate(view, delegateType): + """Install one persistent, view-owned rounded selection delegate.""" + view.setProperty('selectionShape', 'rounded') + view.selectionDelegate = delegateType(parent=view) + view.setItemDelegate(view.selectionDelegate) + + +class AppQListView(QListView): + """Provide a model-based app Qt list view.""" def __init__(self, *args, **kwargs): - """Initialize the AppQListWidget.""" + """Initialize the AppQListView.""" super().__init__(*args, **kwargs) + _installRoundedSelectionDelegate(self, _AppQItemViewSelectionDelegate) + @property def selectedIndex(self): """Return the selected index value.""" @@ -1941,6 +2027,8 @@ class AppQTableView(QTableView): """Initialize the AppQTableView.""" super().__init__(*args, **kwargs) + _installRoundedSelectionDelegate(self, _AppQTableSelectionDelegate) + self.setWordWrap(False) self.setAlternatingRowColors(True) diff --git a/Furious/Qt/StyleSheets/DataViews.py b/Furious/Qt/StyleSheets/DataViews.py index 2078431..fce639c 100644 --- a/Furious/Qt/StyleSheets/DataViews.py +++ b/Furious/Qt/StyleSheets/DataViews.py @@ -26,8 +26,7 @@ def dataViewStyleSheet(palette, progressBarStyleSheet, checkIcon): QTableView, QTableWidget, QTreeView, - QListView, - QListWidget {{ + QListView {{ border: 1px solid {palette['border']}; border-radius: 8px; background-color: {palette['panel']}; @@ -41,8 +40,7 @@ def dataViewStyleSheet(palette, progressBarStyleSheet, checkIcon): QTableView::item, QTableWidget::item, QTreeView::item, - QListView::item, - QListWidget::item {{ + QListView::item {{ min-height: 24px; padding: 4px 7px; border: none; @@ -51,8 +49,7 @@ def dataViewStyleSheet(palette, progressBarStyleSheet, checkIcon): QTableView::item:hover, QTableWidget::item:hover, QTreeView::item:hover, - QListView::item:hover, - QListWidget::item:hover {{ + QListView::item:hover {{ background-color: {palette['hover']}; color: {palette['text_strong']}; }} @@ -60,8 +57,7 @@ def dataViewStyleSheet(palette, progressBarStyleSheet, checkIcon): QTableView::item:selected, QTableWidget::item:selected, QTreeView::item:selected, - QListView::item:selected, - QListWidget::item:selected {{ + QListView::item:selected {{ background-color: {palette['selection']}; color: {palette['selection_text']}; }} @@ -69,12 +65,23 @@ def dataViewStyleSheet(palette, progressBarStyleSheet, checkIcon): QTableView::item:selected:!active, QTableWidget::item:selected:!active, QTreeView::item:selected:!active, - QListView::item:selected:!active, - QListWidget::item:selected:!active {{ + QListView::item:selected:!active {{ background-color: {palette['raised']}; color: {palette['text']}; }} + QTableView[selectionShape="rounded"], + QListView[selectionShape="rounded"] {{ + selection-background-color: transparent; + }} + + QTableView[selectionShape="rounded"]::item:selected, + QTableView[selectionShape="rounded"]::item:selected:!active, + QListView[selectionShape="rounded"]::item:selected, + QListView[selectionShape="rounded"]::item:selected:!active {{ + background-color: transparent; + }} + QHeaderView {{ border: none; background-color: {palette['panel_alt']}; diff --git a/Furious/Qt/__init__.py b/Furious/Qt/__init__.py index cd096cc..0fdbde4 100644 --- a/Furious/Qt/__init__.py +++ b/Furious/Qt/__init__.py @@ -61,7 +61,7 @@ from .QtWidgets import ( AppQIconTextPushButton, AppQLabel, AppQLineEdit, - AppQListWidget, + AppQListView, AppQMainWindow, AppQMenu, AppQMenuBar, @@ -114,7 +114,7 @@ __all__ = [ 'AppQIcon', 'AppQLabel', 'AppQLineEdit', - 'AppQListWidget', + 'AppQListView', 'AppQMainWindow', 'AppQMenu', 'AppQMenuBar',