Improve favorites presentation

Signed-off-by: Loren Eteval <loren.eteval@proton.me>
This commit is contained in:
Loren Eteval
2026-10-08 10:29:56 +08:00
parent 87aeed76a7
commit 3923f7982e
4 changed files with 444 additions and 10 deletions
+13
View File
@@ -744,6 +744,19 @@ class _AppQItemViewSelectionDelegate(QStyledItemDelegate):
painter.drawRoundedRect(selectionRect, self.Radius, self.Radius)
painter.restore()
# Optional alternative: preserve the model foreground when selected.
# Enable together with ServerTableView's property and icon-color block.
# The rounded selection background is already painted above; native
# contents can now use the profile color without selection/hover rules.
# if (
# self.parent().property('preserveForegroundOnSelection')
# and index.data(QtCore.Qt.ItemDataRole.ForegroundRole) is not None
# ):
# option = QStyleOptionViewItem(option)
# option.state &= ~(
# QStyle.StateFlag.State_Selected | QStyle.StateFlag.State_MouseOver
# )
super().paint(painter, option, index)
+82 -5
View File
@@ -466,6 +466,34 @@ def _subscriptionRemark(item: ServerProfile) -> str:
)
@functools.lru_cache(32)
def _favoriteIconWithColor(colorName, selectedColorName):
"""Tint the bundled star at common display sizes without retaining a view."""
source = bootstrapIconWhite('star.svg')
# DecorationRole must expose a native QIcon to Qt's item delegate.
icon = QIcon()
for name, modes in (
(colorName, (QIcon.Mode.Normal, QIcon.Mode.Active)),
(selectedColorName, (QIcon.Mode.Selected,)),
):
color = QColor(name)
for size in (16, 24, 32, 48, 64):
pixmap = source.pixmap(size, size)
painter = QPainter(pixmap)
painter.setCompositionMode(
QPainter.CompositionMode.CompositionMode_SourceIn
)
painter.fillRect(pixmap.rect(), color)
painter.end()
for mode in modes:
icon.addPixmap(pixmap, mode)
return icon
class UserServersTableModel(QtCore.QAbstractTableModel):
"""Expose user servers table data through a Qt item model."""
@@ -517,11 +545,46 @@ class UserServersTableModel(QtCore.QAbstractTableModel):
text = header(server)
if (
role == QtCore.Qt.ItemDataRole.DisplayRole
role == QtCore.Qt.ItemDataRole.DecorationRole
and str(header) == 'Remark'
and server.metadata.favorite
):
return '\u2605 ' + text
view = self.parent()
palette = (
view.palette() if isinstance(view, QWidget) else QApplication.palette()
)
foreground = self.data(index, QtCore.Qt.ItemDataRole.ForegroundRole)
color = (
foreground
if foreground is not None
else palette.color(QPalette.ColorRole.Text)
)
activeSelection = not isinstance(view, QWidget) or (
view.window().isActiveWindow()
or view.property('keepSelectionHighlighted')
)
# Match DataViews' selected and selected:!active text colors.
selectedColor = palette.color(
QPalette.ColorRole.HighlightedText
if activeSelection
else QPalette.ColorRole.Text
)
# Optional companion to the delegate's foreground-preserving selection.
# ForegroundRole already uses AppHue, including root/non-root colors;
# ordinary rows still use the theme's selection text color above.
# if (
# foreground is not None
# and isinstance(view, QWidget)
# and view.property('preserveForegroundOnSelection')
# ):
# selectedColor = foreground
return _favoriteIconWithColor(
color.name(QColor.NameFormat.HexArgb),
selectedColor.name(QColor.NameFormat.HexArgb),
)
if (
role == QtCore.Qt.ItemDataRole.DisplayRole
@@ -886,11 +949,19 @@ class ServerTableView(
super().__init__(*args, **kwargs)
# Optional alternative: keep the active profile's highlight when selected.
# To enable, uncomment this line and the companion blocks in
# UserServersTableModel.data and _AppQItemViewSelectionDelegate.paint.
# The selected-row rendering regression must then expect AppHue for the
# active profile, while ordinary rows retain their theme selection colors.
# self.setProperty('preserveForegroundOnSelection', True)
self.sourceModel = UserServersTableModel(self.Headers, parent=self)
self.proxyModel = UserServersSortFilterProxyModel(parent=self)
self.proxyModel.setSourceModel(self.sourceModel)
self.setModel(self.proxyModel)
self.setIconSize(QtCore.QSize(16, 16))
self._sortSelectionSnapshot = None
self.proxyModel.sortAboutToStart.connect(self._captureSortSelection)
@@ -1225,14 +1296,20 @@ class ServerTableView(
"""Update the user servers Qt table view for a disconnected state."""
self.sourceModel.emitRowChanged(
Storage.UserActivatedItemIndex(),
roles=[QtCore.Qt.ItemDataRole.ForegroundRole],
roles=[
QtCore.Qt.ItemDataRole.ForegroundRole,
QtCore.Qt.ItemDataRole.DecorationRole,
],
)
def connectedCallback(self):
"""Update the user servers Qt table view for a connected state."""
self.sourceModel.emitRowChanged(
Storage.UserActivatedItemIndex(),
roles=[QtCore.Qt.ItemDataRole.ForegroundRole],
roles=[
QtCore.Qt.ItemDataRole.ForegroundRole,
QtCore.Qt.ItemDataRole.DecorationRole,
],
)
def handleItemSelectionChanged(self, *args):
@@ -1503,7 +1580,7 @@ class ServerTableView(
self.sourceModel.dataChanged.emit(
self.sourceModel.index(rows[0], 0),
self.sourceModel.index(rows[-1], self.sourceModel.columnCount() - 1),
[QtCore.Qt.ItemDataRole.DisplayRole],
[QtCore.Qt.ItemDataRole.DecorationRole],
)
if not isValid(self):
+1 -3
View File
@@ -846,9 +846,7 @@ class HomePage(Mixins.QTranslatable, QMainWindow):
QSizePolicy.Policy.Fixed,
)
self.favoritesButton = AppQPushButton(
_('Favorites'), icon=bootstrapIcon('star.svg'), parent=self
)
self.favoritesButton = AppQPushButton(_('Favorites'), parent=self)
self.favoritesButton.setObjectName('HomeFavoritesButton')
self.favoritesButton.setCheckable(True)
self.favoritesButton.setToolTip(_('Show favorite profiles only'))
+348 -2
View File
@@ -56,7 +56,14 @@ from Furious.Window.SubscriptionPage import _SubscriptionEditorDialog
from PySide6 import QtCore, QtGui
from PySide6.QtTest import QSignalSpy, QTest
from PySide6.QtWidgets import QLineEdit, QToolButton, QVBoxLayout, QWidget
from PySide6.QtWidgets import (
QLineEdit,
QStyle,
QStyleOptionViewItem,
QToolButton,
QVBoxLayout,
QWidget,
)
from shiboken6 import isValid
@@ -332,7 +339,10 @@ class ServerTableQtInteractionTest(unittest.TestCase):
activeRow = Storage.UserActivatedItemIndex()
sourceIndex = table.sourceModel.index(activeRow, 0)
proxyIndex = table.proxyIndexFromSourceRow(activeRow)
expectedRoles = (int(QtCore.Qt.ItemDataRole.ForegroundRole),)
expectedRoles = (
int(QtCore.Qt.ItemDataRole.ForegroundRole),
int(QtCore.Qt.ItemDataRole.DecorationRole),
)
self.assertTrue(sourceIndex.isValid())
self.assertTrue(proxyIndex.isValid())
@@ -2134,6 +2144,16 @@ class ProfileMutationBatchTest(unittest.TestCase):
self.assertTrue(all(profile.metadata.favorite for profile in profiles))
self.assertEqual(metadataChanged.count(), 1)
self.assertIn(
QtCore.Qt.ItemDataRole.DecorationRole,
metadataChanged.at(0)[2],
)
remark = table.sourceModel.index(0, 0)
self.assertEqual(remark.data(), profiles[0].itemRemark)
self.assertFalse(
remark.data(QtCore.Qt.ItemDataRole.DecorationRole).isNull()
)
add.trigger()
@@ -2156,6 +2176,7 @@ class ProfileMutationBatchTest(unittest.TestCase):
)
remove.trigger()
self.assertIsNone(remark.data(QtCore.Qt.ItemDataRole.DecorationRole))
self.assertEqual(table.proxyModel.rowCount(), 0)
self.assertEqual(table._selectedProfileIds(), [])
self.assertIs(Storage.UserServers()[0], profiles[0])
@@ -2171,6 +2192,331 @@ class ProfileMutationBatchTest(unittest.TestCase):
table._visibleProfileIds(), [profiles[0].metadata.profileId]
)
def testFavoriteSVGDecorationTracksTableThemeWithoutChangingRemark(self):
with self.table(2) as (table, _controller):
profiles = Storage.UserServers()
profiles[0].metadata.favorite = True
model = table.sourceModel
remark = model.index(0, 0)
self.assertEqual(remark.data(), profiles[0].itemRemark)
self.assertEqual(
remark.data(QtCore.Qt.ItemDataRole.ToolTipRole), profiles[0].itemRemark
)
self.assertEqual(remark.data(model.SortRole), profiles[0].itemRemark)
self.assertIsNone(
model.index(1, 0).data(QtCore.Qt.ItemDataRole.DecorationRole)
)
self.assertIsNone(
model.index(0, 1).data(QtCore.Qt.ItemDataRole.DecorationRole)
)
table.show()
for theme in (
AppStyleSheet.Light,
AppStyleSheet.Dark,
AppStyleSheet.Light,
):
with self.subTest(theme=theme):
table.setStyleSheet(AppStyleSheet.forTheme(theme))
processQtEvents()
color = table.palette().color(QtGui.QPalette.ColorRole.Text)
index = table.proxyIndexFromSourceIndex(remark)
option = QStyleOptionViewItem()
option.initFrom(table)
table.itemDelegate().initStyleOption(option, index)
self.assertEqual(option.text, profiles[0].itemRemark)
self.assertFalse(option.icon.isNull())
image = option.icon.pixmap(16, 16).toImage()
colors = {
image.pixelColor(x, y).name()
for x in range(image.width())
for y in range(image.height())
if image.pixelColor(x, y).alpha() == 255
}
self.assertEqual(colors, {QtGui.QColor(color).name()})
self.assertEqual(
image.pixelColor(
image.width() // 2, image.height() // 2
).alpha(),
0,
)
def testFavoriteSVGMatchesRemarkColorAcrossConnectionStatesAndPrivileges(self):
with self.table(2) as (table, controller):
for profile in Storage.UserServers():
profile.metadata.favorite = True
table.activateItemByIndex(0, True)
changes = QSignalSpy(table.sourceModel.dataChanged)
colors = {}
with (
mock.patch(
'Furious.Qt.DynamicTheme.AppConnectionController',
return_value=controller,
),
mock.patch('Furious.Qt.DynamicTheme.SystemRuntime.isAdmin') as isAdmin,
):
table.show()
for theme in (AppStyleSheet.Light, AppStyleSheet.Dark):
table.setStyleSheet(AppStyleSheet.forTheme(theme))
for connected, admin in (
(False, False),
(True, False),
(True, True),
(False, True),
):
with self.subTest(
theme=theme, connected=connected, admin=admin
):
controller.isConnected.return_value = connected
isAdmin.return_value = admin
if connected:
table.connectedCallback()
else:
table.disconnectedCallback()
processQtEvents()
self.assertIn(
QtCore.Qt.ItemDataRole.DecorationRole,
changes.at(changes.count() - 1)[2],
)
index = table.proxyIndexFromSourceRow(0)
foreground = index.data(
QtCore.Qt.ItemDataRole.ForegroundRole
)
self.assertEqual(
foreground, QtGui.QColor(AppHue.currentColor())
)
colors[connected, admin] = foreground.name()
option = QStyleOptionViewItem()
option.initFrom(table)
table.itemDelegate().initStyleOption(option, index)
self.assertEqual(
option.palette.color(QtGui.QPalette.ColorRole.Text),
foreground,
)
for mode in (
QtGui.QIcon.Mode.Normal,
QtGui.QIcon.Mode.Active,
):
for size in (16, 32):
image = option.icon.pixmap(
size, size, mode
).toImage()
opaqueColors = {
image.pixelColor(x, y).name()
for x in range(image.width())
for y in range(image.height())
if image.pixelColor(x, y).alpha() == 255
}
self.assertEqual(opaqueColors, {foreground.name()})
self.assertNotEqual(colors[True, False], colors[True, True])
self.assertEqual(colors[False, False], colors[False, True])
table.activateItemByIndex(1, True)
processQtEvents()
oldIndex = table.proxyIndexFromSourceRow(0)
self.assertIsNone(oldIndex.data(QtCore.Qt.ItemDataRole.ForegroundRole))
image = (
oldIndex.data(QtCore.Qt.ItemDataRole.DecorationRole)
.pixmap(16, 16)
.toImage()
)
opaqueColors = {
image.pixelColor(x, y).name()
for x in range(image.width())
for y in range(image.height())
if image.pixelColor(x, y).alpha() == 255
}
self.assertEqual(
opaqueColors,
{table.palette().color(QtGui.QPalette.ColorRole.Text).name()},
)
self.assertEqual(
table.proxyIndexFromSourceRow(1).data(
QtCore.Qt.ItemDataRole.ForegroundRole
),
QtGui.QColor(AppHue.currentColor()),
)
def testSelectedFavoriteIconMatchesRenderedRemarkAndRestoresProfileColor(self):
with self.table(2) as (table, controller):
for profile in Storage.UserServers():
profile.metadata.favorite = True
table.activateItemByIndex(0, True)
table.resize(900, 220)
focusWindow = QWidget()
try:
with (
mock.patch(
'Furious.Qt.DynamicTheme.AppConnectionController',
return_value=controller,
),
mock.patch(
'Furious.Qt.DynamicTheme.SystemRuntime.isAdmin'
) as isAdmin,
):
table.show()
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
activeRow = int(admin)
table.activateItemByIndex(activeRow, True)
table.sourceModel.emitAllChanged()
for active, keepHighlight in (
(True, False),
(False, False),
(False, True),
):
table.setProperty(
'keepSelectionHighlighted', keepHighlight
)
if active:
table.activateWindow()
table.setFocus()
else:
focusWindow.show()
focusWindow.activateWindow()
focusWindow.setFocus()
processQtEvents()
self.assertEqual(table.isActiveWindow(), active)
for selected in (False, True, False):
table.clearSelection()
if selected:
table.selectAll()
processQtEvents()
backgrounds = {}
for row in (0, 1):
with self.subTest(
theme=theme,
connected=connected,
admin=admin,
active=active,
keepHighlight=keepHighlight,
selected=selected,
row=row,
):
if selected:
role = (
'selection_text'
if active or keepHighlight
else 'text'
)
color = QtGui.QColor(
AppStyleSheet.Palettes[theme][role]
)
elif row == activeRow:
color = QtGui.QColor(
AppHue.currentColor()
)
else:
color = table.palette().color(
QtGui.QPalette.ColorRole.Text
)
index = table.proxyIndexFromSourceRow(row)
cellRect = table.visualRect(index)
image = (
table.viewport()
.grab(cellRect)
.toImage()
)
if selected:
backgrounds[row] = image.pixelColor(
image.width() // 2,
round(5 * image.devicePixelRatio()),
)
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
)
if selected:
self.assertEqual(backgrounds[0], backgrounds[1])
finally:
focusWindow.close()
focusWindow.deleteLater()
table.setProperty('keepSelectionHighlighted', False)
def testSmallDuplicationPublishesOneBatchOfIndependentManualCopies(self):
with self.table(4) as (table, controller):
originals = list(Storage.UserServers())