Modernize Fluent item views

Signed-off-by: Loren Eteval <loren.eteval@proton.me>
This commit is contained in:
Loren Eteval
2026-08-20 16:55:48 +08:00
parent 093a7c2cbd
commit f4ecd5ce1b
5 changed files with 179 additions and 58 deletions
+43 -17
View File
@@ -15,7 +15,7 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
"""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
+25 -25
View File
@@ -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))
+92 -4
View File
@@ -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)
+17 -10
View File
@@ -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']};
+2 -2
View File
@@ -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',