From 2bc3501e510d7ff9c7568b8c565090744badc160 Mon Sep 17 00:00:00 2001 From: cosven Date: Fri, 22 Dec 2023 02:28:04 +0800 Subject: [PATCH 1/3] gui: redesign ImgListView --- feeluown/gui/page_containers/table.py | 1 + feeluown/gui/widgets/imglist.py | 118 ++++++++++++++++++-------- feeluown/gui/widgets/table_toolbar.py | 4 +- 3 files changed, 86 insertions(+), 37 deletions(-) diff --git a/feeluown/gui/page_containers/table.py b/feeluown/gui/page_containers/table.py index 69553fe64a..90bdd42cd6 100644 --- a/feeluown/gui/page_containers/table.py +++ b/feeluown/gui/page_containers/table.py @@ -306,6 +306,7 @@ def _setup_ui(self): self._v_layout = QVBoxLayout() self._v_layout.addWidget(self.meta_widget) + self._v_layout.addSpacing(15) self._v_layout.addWidget(self.toolbar) self._v_layout.addWidget(self.desc_widget) diff --git a/feeluown/gui/widgets/imglist.py b/feeluown/gui/widgets/imglist.py index 52d6664c1a..6f91b5138c 100644 --- a/feeluown/gui/widgets/imglist.py +++ b/feeluown/gui/widgets/imglist.py @@ -130,76 +130,125 @@ class ImgListDelegate(QAbstractItemDelegate): def __init__(self, parent=None): super().__init__(parent) - self.view = parent + self.view: 'ImgListView' = parent # TODO: move as_circle/w_h_ratio attribute to view self.as_circle = True self.w_h_ratio = 1.0 + self.spacing = self.view.img_spacing + self.half_spacing = self.spacing // 2 + self.text_height = self.view.img_text_height + + def column_count(self): + return ((self.view.width() + self.view.img_spacing) // + (self.view.img_sizehint()[0] + self.view.img_spacing)) + + def is_left_first(self, index): + if self.view.isWrapping(): + return index.row() % self.column_count() == 0 + return index.row() == 0 + + def is_right_last(self, index): + if self.view.isWrapping(): + column_count = self.column_count() + return index.row() % column_count == column_count - 1 + return False # FIXME: implement this + + def get_spacing(self, index): + if self.is_left_first(index) or self.is_right_last(index): + return self.half_spacing + return self.spacing + def paint(self, painter, option, index): painter.save() painter.setRenderHint(QPainter.Antialiasing) + rect = option.rect - text_rect_height = 30 - img_text_height = self.view.img_text_height - source_rect_height = img_text_height - text_rect_height - text_y = rect.y() + rect.height() - img_text_height - cover_height = rect.height() - img_text_height - cover_width = rect.width() - text_rect = QRectF(rect.x(), text_y, rect.width(), text_rect_height) - whats_this_rect = QRectF( - rect.x(), text_y + text_rect_height - 5, - rect.width(), source_rect_height + 5 - ) + painter.translate(rect.x(), rect.y()) + if not self.is_left_first(index): + painter.translate(self.half_spacing, 0) + obj = index.data(Qt.DecorationRole) if obj is None: painter.restore() return + text_title_height = 30 + text_source_height = self.text_height - text_title_height + text_source_color = non_text_color = self.get_non_text_color(option) + spacing = self.get_spacing(index) + draw_width = rect.width() - spacing + + # Draw cover or color. + cover_height = rect.height() - self.text_height - self.spacing + painter.save() + self.draw_cover_or_color(painter, non_text_color, obj, draw_width, cover_height) + painter.restore() + + # Draw text(album name / artist name / playlist name). + painter.translate(0, cover_height) + text_rect = QRectF(0, 0, draw_width, text_title_height) + painter.save() + self.draw_title(painter, index, text_rect) + painter.restore() + + # Draw source. + painter.save() + painter.translate(0, text_title_height - 5) + whats_this_rect = QRectF(0, 0, draw_width, text_source_height + 5) + self.draw_whats_this(painter, index, text_source_color, whats_this_rect) + painter.restore() + + painter.restore() + + def get_non_text_color(self, option): text_color = option.palette.color(QPalette.Text) if text_color.lightness() > 150: non_text_color = text_color.darker(140) else: non_text_color = text_color.lighter(150) non_text_color.setAlpha(100) - painter.save() + return non_text_color + + def draw_cover_or_color(self, painter, non_text_color, obj, draw_width, height): pen = painter.pen() pen.setColor(non_text_color) painter.setPen(pen) - painter.translate(rect.x(), rect.y()) if isinstance(obj, QColor): color = obj brush = QBrush(color) painter.setBrush(brush) else: if obj.height() < obj.width(): - img = obj.scaledToHeight(cover_height, Qt.SmoothTransformation) + img = obj.scaledToHeight(height, Qt.SmoothTransformation) else: - img = obj.scaledToWidth(cover_width, Qt.SmoothTransformation) + img = obj.scaledToWidth(draw_width, Qt.SmoothTransformation) brush = QBrush(img) painter.setBrush(brush) border_radius = 3 if self.as_circle: - border_radius = cover_width // 2 - cover_rect = QRect(0, 0, cover_width, cover_height) + border_radius = draw_width // 2 + cover_rect = QRect(0, 0, draw_width, height) painter.drawRoundedRect(cover_rect, border_radius, border_radius) - painter.restore() - option = QTextOption() - source_option = QTextOption() + + def draw_title(self, painter, index, text_rect): + text_option = QTextOption() if self.as_circle: - option.setAlignment(Qt.AlignHCenter | Qt.AlignVCenter) - source_option.setAlignment(Qt.AlignHCenter | Qt.AlignTop) + text_option.setAlignment(Qt.AlignHCenter | Qt.AlignVCenter) else: - option.setAlignment(Qt.AlignLeft | Qt.AlignVCenter) - source_option.setAlignment(Qt.AlignLeft | Qt.AlignTop) + text_option.setAlignment(Qt.AlignLeft | Qt.AlignVCenter) name = index.data(Qt.DisplayRole) fm = QFontMetrics(painter.font()) elided_name = fm.elidedText(name, Qt.ElideRight, int(text_rect.width())) - painter.drawText(text_rect, elided_name, option) - painter.restore() + painter.drawText(text_rect, elided_name, text_option) - # Draw WhatsThis. + def draw_whats_this(self, painter, index, non_text_color, whats_this_rect): + source_option = QTextOption() + if self.as_circle: + source_option.setAlignment(Qt.AlignHCenter | Qt.AlignTop) + else: + source_option.setAlignment(Qt.AlignLeft | Qt.AlignTop) whats_this = index.data(Qt.WhatsThisRole) - painter.save() pen = painter.pen() font = painter.font() resize_font(font, -2) @@ -207,12 +256,13 @@ def paint(self, painter, option, index): pen.setColor(non_text_color) painter.setPen(non_text_color) painter.drawText(whats_this_rect, whats_this, source_option) - painter.restore() def sizeHint(self, option, index): + spacing = self.get_spacing(index) width = self.view.img_sizehint()[0] if index.isValid(): - return QSize(width, int(width / self.w_h_ratio) + self.view.img_text_height) + height = int(width / self.w_h_ratio) + self.text_height + return QSize(width + spacing, height + self.spacing) return super().sizeHint(option, index) @@ -260,7 +310,6 @@ def __init__(self, parent=None, self.setResizeMode(QListView.Adjust) self.setWrapping(True) self.setFrameShape(QFrame.NoFrame) - self.setSpacing(self.img_spacing) self.initialize() def resizeEvent(self, e): @@ -288,8 +337,9 @@ def img_sizehint(self) -> tuple: # CoverMaxWidth = 2 * img_min_width + img_spacing - 1 # calculate max column count - count = (width - img_spacing) // (img_min_width + img_spacing) + count = (width + img_spacing) // (img_min_width + img_spacing) count = max(count, 1) - img_height = img_width = (width - ((count + 1) * img_spacing)) // count + # calculate img_width when column count is the max + img_height = img_width = (width + img_spacing) // count - img_spacing img_height = img_height + img_text_height return img_width, img_height diff --git a/feeluown/gui/widgets/table_toolbar.py b/feeluown/gui/widgets/table_toolbar.py index dc2f57611a..72a06540c8 100644 --- a/feeluown/gui/widgets/table_toolbar.py +++ b/feeluown/gui/widgets/table_toolbar.py @@ -73,9 +73,7 @@ def add_tmp_button(self, button): def _setup_ui(self): self._layout = QHBoxLayout(self) - # left margin of meta widget is 30, we align with it - # bottom margin of meta widget is 15, we should be larger than that - self._layout.setContentsMargins(0, 15, 30, 10) + self._layout.setContentsMargins(0, 0, 0, 10) self._layout.addWidget(self.play_all_btn) self._layout.addStretch(0) self._layout.addWidget(self.filter_albums_combobox) From 95ed687bb65529b48b763f4c485cb85ae3f34c78 Mon Sep 17 00:00:00 2001 From: cosven Date: Sat, 23 Dec 2023 13:28:26 +0800 Subject: [PATCH 2/3] refine code 1/n --- feeluown/gui/page_containers/table.py | 39 +-- feeluown/gui/pages/search.py | 6 +- feeluown/gui/widgets/album.py | 71 ----- feeluown/gui/widgets/artist.py | 37 --- .../widgets/{imglist.py => img_card_list.py} | 252 ++++++++++++++---- feeluown/gui/widgets/playlist.py | 37 --- feeluown/gui/widgets/video_list.py | 45 ---- 7 files changed, 223 insertions(+), 264 deletions(-) delete mode 100644 feeluown/gui/widgets/album.py delete mode 100644 feeluown/gui/widgets/artist.py rename feeluown/gui/widgets/{imglist.py => img_card_list.py} (64%) delete mode 100644 feeluown/gui/widgets/playlist.py delete mode 100644 feeluown/gui/widgets/video_list.py diff --git a/feeluown/gui/page_containers/table.py b/feeluown/gui/page_containers/table.py index 90bdd42cd6..942e07d37e 100644 --- a/feeluown/gui/page_containers/table.py +++ b/feeluown/gui/page_containers/table.py @@ -18,15 +18,13 @@ from feeluown.gui.helpers import BgTransparentMixin, \ disconnect_slots_if_has, fetch_cover_wrapper from feeluown.gui.components import SongMenuInitializer -from feeluown.gui.widgets.imglist import ImgListView -from feeluown.gui.widgets.album import AlbumListModel, AlbumListView, \ - AlbumFilterProxyModel -from feeluown.gui.widgets.artist import ArtistListModel, ArtistListView, \ - ArtistFilterProxyModel -from feeluown.gui.widgets.video_list import VideoListModel, VideoListView, \ - VideoFilterProxyModel -from feeluown.gui.widgets.playlist import PlaylistListModel, PlaylistListView, \ - PlaylistFilterProxyModel +from feeluown.gui.widgets.img_card_list import ImgCardListView +from feeluown.gui.widgets.img_card_list import ( + AlbumCardListModel, AlbumCardListView, AlbumFilterProxyModel, AlbumCardListDelegate, + ArtistCardListModel, ArtistCardListView, ArtistFilterProxyModel, ArtistCardListDelegate, + VideoCardListModel, VideoCardListView, VideoFilterProxyModel, VideoCardListDelegate, + PlaylistCardListModel, PlaylistCardListView, PlaylistFilterProxyModel, PlaylistCardListDelegate, +) from feeluown.gui.widgets.songs import ColumnsMode, SongsTableModel, SongsTableView, \ SongFilterProxyModel from feeluown.gui.widgets.comment_list import CommentListView, CommentListModel @@ -93,25 +91,25 @@ async def show_cover(self, cover, cover_uid, as_background=False): def show_albums(self, reader): self._show_model_with_cover(reader, self.albums_table, - AlbumListModel, + AlbumCardListModel, AlbumFilterProxyModel) def show_artists(self, reader): self._show_model_with_cover(reader, self.artists_table, - ArtistListModel, + ArtistCardListModel, ArtistFilterProxyModel) def show_videos(self, reader): self._show_model_with_cover(reader, self.videos_table, - VideoListModel, + VideoCardListModel, VideoFilterProxyModel) def show_playlists(self, reader): self._show_model_with_cover(reader, self.playlists_table, - PlaylistListModel, + PlaylistCardListModel, PlaylistFilterProxyModel) def _show_model_with_cover(self, reader, table, model_cls, filter_model_cls): @@ -257,10 +255,15 @@ def __init__(self, app, parent=None): self.tabbar = TableTabBarV2() self.meta_widget = TableMetaWidget(parent=self) self.songs_table = SongsTableView(app=self._app, parent=self) - self.albums_table = AlbumListView(parent=self, img_min_width=120) - self.artists_table = ArtistListView(parent=self) - self.videos_table = VideoListView(parent=self) - self.playlists_table = PlaylistListView(parent=self) + self.albums_table = AlbumCardListView(parent=self) + self.albums_table.setItemDelegate( + AlbumCardListDelegate(self.albums_table, img_min_width=120)) + self.artists_table = ArtistCardListView(parent=self) + self.artists_table.setItemDelegate(ArtistCardListDelegate(self.artists_table)) + self.videos_table = VideoCardListView(parent=self) + self.videos_table.setItemDelegate(VideoCardListDelegate(self.videos_table)) + self.playlists_table = PlaylistCardListView(parent=self) + self.playlists_table.setItemDelegate(PlaylistCardListDelegate(self.playlists_table)) self.comments_table = CommentListView(parent=self) self.desc_widget = DescLabel(parent=self) @@ -361,7 +364,7 @@ def current_table(self, table): self.toolbar.albums_mode() if table is self.songs_table: self.toolbar.songs_mode() - if isinstance(self._table, ImgListView): + if isinstance(self._table, ImgCardListView): self._table.setModel(None) self._table = table diff --git a/feeluown/gui/pages/search.py b/feeluown/gui/pages/search.py index dc2bdf5780..4ce2c88f25 100644 --- a/feeluown/gui/pages/search.py +++ b/feeluown/gui/pages/search.py @@ -3,7 +3,7 @@ from feeluown.models import SearchType from feeluown.gui.page_containers.table import TableContainer, Renderer from feeluown.gui.page_containers.scroll_area import ScrollArea -from feeluown.gui.widgets.imglist import ImgListView +from feeluown.gui.widgets.img_card_list import ImgCardListDelegate from feeluown.gui.widgets.songs import SongsTableView, ColumnsMode from feeluown.gui.base_renderer import TabBarRendererMixin from feeluown.gui.helpers import BgTransparentMixin @@ -58,9 +58,9 @@ async def render(req, **kwargs): # pylint: disable=too-many-locals,too-many-bra # HACK: set fixed row for tables. # pylint: disable=protected-access for table in table_container._tables: - if isinstance(table, ImgListView): + if isinstance(table.itemDelegate(), ImgCardListDelegate): table._fixed_row_count = 2 - table.img_min_width = 100 + table.itemDelegate().img_min_width = 100 elif isinstance(table, SongsTableView): table._fixed_row_count = 8 table._row_height = table.verticalHeader().defaultSectionSize() diff --git a/feeluown/gui/widgets/album.py b/feeluown/gui/widgets/album.py deleted file mode 100644 index 7443dab6b5..0000000000 --- a/feeluown/gui/widgets/album.py +++ /dev/null @@ -1,71 +0,0 @@ -from PyQt5.QtCore import Qt, pyqtSignal - -from feeluown.library import AlbumModel -from feeluown.models import AlbumType -from .imglist import ( - ImgListModel, ImgListDelegate, ImgListView, - ImgFilterProxyModel -) - - -class AlbumListModel(ImgListModel): - def data(self, index, role): - offset = index.row() - if not index.isValid() or offset >= len(self._items): - return None - - album = self._items[offset] - if role == Qt.WhatsThisRole: - if isinstance(album, AlbumModel): - if album.song_count >= 0: - # Like: 1991-01-01 10首 - return f'{album.released} {album.song_count}首' - return album.released - return super().data(index, role) - - -class AlbumListDelegate(ImgListDelegate): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - self.as_circle = False - - -class AlbumFilterProxyModel(ImgFilterProxyModel): - def __init__(self, parent=None, types=None): - super().__init__(parent) - - self.types = types - - def filter_by_types(self, types): - # if types is a empty list or None, we show all albums - if not types: - types = None - self.types = types - self.invalidateFilter() - - def filterAcceptsRow(self, source_row, source_parent): - accepted = super().filterAcceptsRow(source_row, source_parent) - source_model = self.sourceModel() - assert isinstance(source_model, AlbumListModel) - index = source_model.index(source_row, parent=source_parent) - album = index.data(Qt.UserRole) - if accepted and self.types: - accepted = AlbumType(album.type_) in self.types - return accepted - - -class AlbumListView(ImgListView): - show_album_needed = pyqtSignal([object]) - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - delegate = AlbumListDelegate(self) - self.setItemDelegate(delegate) - - self.activated.connect(self._on_activated) - - def _on_activated(self, index): - album = index.data(Qt.UserRole) - self.show_album_needed.emit(album) diff --git a/feeluown/gui/widgets/artist.py b/feeluown/gui/widgets/artist.py deleted file mode 100644 index 9e1ea766d9..0000000000 --- a/feeluown/gui/widgets/artist.py +++ /dev/null @@ -1,37 +0,0 @@ -from PyQt5.QtCore import Qt, pyqtSignal - -from .imglist import ( - ImgListModel, ImgListDelegate, ImgListView, - ImgFilterProxyModel -) - - -class ArtistListModel(ImgListModel): - pass - - -class ArtistListDelegate(ImgListDelegate): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - self.as_circle = True - - -class ArtistFilterProxyModel(ImgFilterProxyModel): - pass - - -class ArtistListView(ImgListView): - show_artist_needed = pyqtSignal([object]) - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - delegate = ArtistListDelegate(self) - self.setItemDelegate(delegate) - - self.activated.connect(self._on_activated) - - def _on_activated(self, index): - artist = index.data(Qt.UserRole) - self.show_artist_needed.emit(artist) diff --git a/feeluown/gui/widgets/imglist.py b/feeluown/gui/widgets/img_card_list.py similarity index 64% rename from feeluown/gui/widgets/imglist.py rename to feeluown/gui/widgets/img_card_list.py index 6f91b5138c..2ac7223ee6 100644 --- a/feeluown/gui/widgets/imglist.py +++ b/feeluown/gui/widgets/img_card_list.py @@ -1,8 +1,8 @@ """ ImgList model and delegate -- ImgListDelegate -- ImgListModel +- ImgCardListDelegate +- ImgCardListModel By default, we think the proper width of a cover is about 160px, the margin between two cover should be about 20px. When the view is @@ -15,11 +15,11 @@ from typing import TypeVar, Optional, List, cast from PyQt5.QtCore import ( - QAbstractListModel, QModelIndex, Qt, - QRectF, QRect, QSize, QSortFilterProxyModel + QAbstractListModel, QModelIndex, Qt, QObject, QEvent, + QRectF, QRect, QSize, QSortFilterProxyModel, pyqtSignal ) from PyQt5.QtGui import ( - QImage, QColor, + QImage, QColor, QResizeEvent, QBrush, QPainter, QTextOption, QFontMetrics, QPalette ) from PyQt5.QtWidgets import ( @@ -27,6 +27,7 @@ ) from feeluown.utils import aio +from feeluown.library import AlbumModel, AlbumType from feeluown.utils.reader import wrap from feeluown.models.uri import reverse from feeluown.gui.helpers import ( @@ -49,7 +50,7 @@ } -class ImgListModel(QAbstractListModel, ReaderFetchMoreMixin[T]): +class ImgCardListModel(QAbstractListModel, ReaderFetchMoreMixin[T]): def __init__(self, reader, fetch_image, source_name_map=None, parent=None): """ @@ -126,22 +127,30 @@ def data(self, index, role): return None -class ImgListDelegate(QAbstractItemDelegate): - def __init__(self, parent=None): +class ImgCardListDelegate(QAbstractItemDelegate): + def __init__(self, parent=None, + img_min_width=150, img_spacing=20, img_text_height=40, + **_): super().__init__(parent) - self.view: 'ImgListView' = parent - # TODO: move as_circle/w_h_ratio attribute to view + self.view: 'ImgCardListView' = parent + self.view.installEventFilter(self) self.as_circle = True self.w_h_ratio = 1.0 - self.spacing = self.view.img_spacing + self.img_min_width = img_min_width + self.img_spacing = img_spacing + self.img_text_height = img_text_height + + self.spacing = self.img_spacing self.half_spacing = self.spacing // 2 - self.text_height = self.view.img_text_height + self.text_height = self.img_text_height + + self._img_width = self._img_height = 0 + self._view_width = 0 def column_count(self): - return ((self.view.width() + self.view.img_spacing) // - (self.view.img_sizehint()[0] + self.view.img_spacing)) + return (self._view_width + self.img_spacing) // (self._img_width + self.img_spacing) def is_left_first(self, index): if self.view.isWrapping(): @@ -259,12 +268,43 @@ def draw_whats_this(self, painter, index, non_text_color, whats_this_rect): def sizeHint(self, option, index): spacing = self.get_spacing(index) - width = self.view.img_sizehint()[0] + width = self._img_width if index.isValid(): height = int(width / self.w_h_ratio) + self.text_height return QSize(width + spacing, height + self.spacing) return super().sizeHint(option, index) + def on_view_resized(self, size: QSize, old_size: QSize): + self._view_width = size.width() + self._img_width, self._img_height = self.re_calc_img_size() + self.view._row_height = self._img_height + + def re_calc_img_size(self): + # HELP: CardListView needs about 20 spacing left on macOS + width = max(0, self._view_width - 20) + img_spacing = self.img_spacing + img_min_width = self.img_min_width + img_text_height = self.img_text_height + + # according to our algorithm, when the widget width is: + # 2(img_min_width + img_spacing) + img_spacing - 1, + # the cover width can take the maximum width, it will be: + # CoverMaxWidth = 2 * img_min_width + img_spacing - 1 + + # calculate max column count + count = (width + img_spacing) // (img_min_width + img_spacing) + count = max(count, 1) + # calculate img_width when column count is the max + img_height = img_width = (width + img_spacing) // count - img_spacing + img_height = img_height + img_text_height + return img_width, img_height + + def eventFilter(self, _: QObject, event: QEvent): + if event.type() == QEvent.Resize: + event = cast(QResizeEvent, event) + self.on_view_resized(event.size(), event.oldSize()) + return False + class ImgFilterProxyModel(QSortFilterProxyModel): def __init__(self, parent=None, types=None): @@ -280,7 +320,7 @@ def filter_by_text(self, text): def filterAcceptsRow(self, source_row, source_parent): accepted = True - source_model = cast(ImgListModel, self.sourceModel()) + source_model = cast(ImgCardListModel, self.sourceModel()) index = source_model.index(source_row, parent=source_parent) artist = index.data(Qt.UserRole) if self.text: @@ -288,23 +328,21 @@ def filterAcceptsRow(self, source_row, source_parent): return accepted -class ImgListView(ItemViewNoScrollMixin, QListView): +class ImgCardListView(ItemViewNoScrollMixin, QListView): """ - .. versionadded:: 3.7.7 - The *img_min_width*, *img_spacing*, *img_text_height* parameter were added. + .. versionchanged:: 3.9 + The *img_min_width*, *img_spacing*, *img_text_height* parameter were removed. """ - def __init__(self, parent=None, - img_min_width=150, img_spacing=20, img_text_height=40, - **kwargs): + def __init__(self, parent=None, **kwargs): super().__init__(parent=parent, **kwargs) - self.img_min_width = img_min_width - self.img_spacing = img_spacing - self.img_text_height = img_text_height - - # override ItemViewNoScrollMixin variables + # Override ItemViewNoScrollMixin variables. Actually, there variables are + # not important because ItemViewNoScrollMixin use QListView.sizeHint() to + # calculate the size and it works well. + # + # self._least_row_count = 1 - self._row_height = img_min_width + img_spacing + img_text_height + self._row_height = 0 self.setViewMode(QListView.IconMode) self.setResizeMode(QListView.Adjust) @@ -312,34 +350,142 @@ def __init__(self, parent=None, self.setFrameShape(QFrame.NoFrame) self.initialize() - def resizeEvent(self, e): - super().resizeEvent(e) - - if self._no_scroll_v is True: - self._row_height = self.img_sizehint()[1] + self.img_spacing - self.adjust_height() + self.activated.connect(self.on_activated) - def img_sizehint(self) -> tuple: + def on_activated(self, _: QModelIndex): """ - - .. versionadded:: 3.7.7 + Subclass can implement this method if needed. """ - # HELP: listview needs about 20 spacing left on macOS - width = self.width() - 20 + pass - img_spacing = self.img_spacing - img_min_width = self.img_min_width - img_text_height = self.img_text_height - # according to our algorithm, when the widget width is: - # 2(img_min_width + img_spacing) + img_spacing - 1, - # the cover width can take the maximum width, it will be: - # CoverMaxWidth = 2 * img_min_width + img_spacing - 1 +class VideoCardListModel(ImgCardListModel): + def data(self, index, role): + offset = index.row() + if not index.isValid() or offset >= len(self._items): + return None + video = self._items[offset] + if role == Qt.DisplayRole: + return video.title_display + return super().data(index, role) - # calculate max column count - count = (width + img_spacing) // (img_min_width + img_spacing) - count = max(count, 1) - # calculate img_width when column count is the max - img_height = img_width = (width + img_spacing) // count - img_spacing - img_height = img_height + img_text_height - return img_width, img_height + +class VideoCardListDelegate(ImgCardListDelegate): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.as_circle = False + self.w_h_ratio = 1.618 + + +class VideoFilterProxyModel(ImgFilterProxyModel): + pass + + +class VideoCardListView(ImgCardListView): + play_video_needed = pyqtSignal([object]) + + def on_activated(self, index): + video = index.data(Qt.UserRole) + self.play_video_needed.emit(video) + + +class PlaylistCardListModel(ImgCardListModel): + pass + + +class PlaylistCardListDelegate(ImgCardListDelegate): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.as_circle = False + + +class PlaylistFilterProxyModel(ImgFilterProxyModel): + pass + + +class PlaylistCardListView(ImgCardListView): + show_playlist_needed = pyqtSignal([object]) + + def on_activated(self, index): + artist = index.data(Qt.UserRole) + self.show_playlist_needed.emit(artist) + + +class ArtistCardListModel(ImgCardListModel): + pass + + +class ArtistCardListDelegate(ImgCardListDelegate): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.as_circle = True + + +class ArtistFilterProxyModel(ImgFilterProxyModel): + pass + + +class ArtistCardListView(ImgCardListView): + show_artist_needed = pyqtSignal([object]) + + def on_activated(self, index): + artist = index.data(Qt.UserRole) + self.show_artist_needed.emit(artist) + + +class AlbumCardListModel(ImgCardListModel): + def data(self, index, role): + offset = index.row() + if not index.isValid() or offset >= len(self._items): + return None + + album = self._items[offset] + if role == Qt.WhatsThisRole: + if isinstance(album, AlbumModel): + if album.song_count >= 0: + # Like: 1991-01-01 10首 + return f'{album.released} {album.song_count}首' + return album.released + return super().data(index, role) + + +class AlbumCardListDelegate(ImgCardListDelegate): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.as_circle = False + + +class AlbumFilterProxyModel(ImgFilterProxyModel): + def __init__(self, parent=None, types=None): + super().__init__(parent) + + self.types = types + + def filter_by_types(self, types): + # if types is a empty list or None, we show all albums + if not types: + types = None + self.types = types + self.invalidateFilter() + + def filterAcceptsRow(self, source_row, source_parent): + accepted = super().filterAcceptsRow(source_row, source_parent) + source_model = self.sourceModel() + assert isinstance(source_model, AlbumCardListModel) + index = source_model.index(source_row, parent=source_parent) + album = index.data(Qt.UserRole) + if accepted and self.types: + accepted = AlbumType(album.type_) in self.types + return accepted + + +class AlbumCardListView(ImgCardListView): + show_album_needed = pyqtSignal([object]) + + def on_activated(self, index): + album = index.data(Qt.UserRole) + self.show_album_needed.emit(album) diff --git a/feeluown/gui/widgets/playlist.py b/feeluown/gui/widgets/playlist.py deleted file mode 100644 index 6762b88652..0000000000 --- a/feeluown/gui/widgets/playlist.py +++ /dev/null @@ -1,37 +0,0 @@ -from PyQt5.QtCore import Qt, pyqtSignal - -from .imglist import ( - ImgListModel, ImgListDelegate, ImgListView, - ImgFilterProxyModel -) - - -class PlaylistListModel(ImgListModel): - pass - - -class PlaylistListDelegate(ImgListDelegate): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - self.as_circle = False - - -class PlaylistFilterProxyModel(ImgFilterProxyModel): - pass - - -class PlaylistListView(ImgListView): - show_playlist_needed = pyqtSignal([object]) - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - delegate = PlaylistListDelegate(self) - self.setItemDelegate(delegate) - - self.activated.connect(self._on_activated) - - def _on_activated(self, index): - artist = index.data(Qt.UserRole) - self.show_playlist_needed.emit(artist) diff --git a/feeluown/gui/widgets/video_list.py b/feeluown/gui/widgets/video_list.py deleted file mode 100644 index b5cd3ebf9d..0000000000 --- a/feeluown/gui/widgets/video_list.py +++ /dev/null @@ -1,45 +0,0 @@ -from PyQt5.QtCore import Qt, pyqtSignal - -from .imglist import ( - ImgListModel, ImgListDelegate, ImgListView, - ImgFilterProxyModel -) - - -class VideoListModel(ImgListModel): - def data(self, index, role): - offset = index.row() - if not index.isValid() or offset >= len(self._items): - return None - video = self._items[offset] - if role == Qt.DisplayRole: - return video.title_display - return super().data(index, role) - - -class VideoListDelegate(ImgListDelegate): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - self.as_circle = False - self.w_h_ratio = 1.618 - - -class VideoFilterProxyModel(ImgFilterProxyModel): - pass - - -class VideoListView(ImgListView): - play_video_needed = pyqtSignal([object]) - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - delegate = VideoListDelegate(self) - self.setItemDelegate(delegate) - - self.activated.connect(self._on_activated) - - def _on_activated(self, index): - video = index.data(Qt.UserRole) - self.play_video_needed.emit(video) From 30cbf3490f49e28a602f0499b80b461bf6746a92 Mon Sep 17 00:00:00 2001 From: cosven Date: Sat, 23 Dec 2023 18:08:17 +0800 Subject: [PATCH 3/3] refine code n/n --- feeluown/gui/helpers.py | 20 ++- feeluown/gui/page_containers/table.py | 8 +- feeluown/gui/pages/search.py | 5 +- feeluown/gui/widgets/img_card_list.py | 200 ++++++++++++++------------ 4 files changed, 132 insertions(+), 101 deletions(-) diff --git a/feeluown/gui/helpers.py b/feeluown/gui/helpers.py index db35186380..8ac4100de2 100644 --- a/feeluown/gui/helpers.py +++ b/feeluown/gui/helpers.py @@ -22,13 +22,14 @@ import random import sys import logging +from contextlib import contextmanager from typing import TypeVar, List, Optional, Generic, Union, cast, TYPE_CHECKING try: # helper module should work in no-window mode from PyQt5.QtCore import QModelIndex, QSize, Qt, pyqtSignal, QSortFilterProxyModel, \ QAbstractListModel - from PyQt5.QtGui import QPalette, QFontMetrics, QColor + from PyQt5.QtGui import QPalette, QFontMetrics, QColor, QPainter from PyQt5.QtWidgets import QApplication, QScrollArea, QWidget except ImportError: pass @@ -557,6 +558,23 @@ def random_solarized_color(): return QColor(random.choice(list(SOLARIZED_COLORS.values()))) +@contextmanager +def painter_save(painter: QPainter): + painter.save() + yield + painter.restore() + + +def secondary_text_color(palette: QPalette): + text_color: QColor = palette.color(QPalette.Text) + if text_color.lightness() > 150: + non_text_color = text_color.darker(140) + else: + non_text_color = text_color.lighter(150) + non_text_color.setAlpha(100) + return non_text_color + + # https://ethanschoonover.com/solarized/ SOLARIZED_COLORS = { 'yellow': '#b58900', diff --git a/feeluown/gui/page_containers/table.py b/feeluown/gui/page_containers/table.py index 942e07d37e..c2d391960e 100644 --- a/feeluown/gui/page_containers/table.py +++ b/feeluown/gui/page_containers/table.py @@ -21,9 +21,10 @@ from feeluown.gui.widgets.img_card_list import ImgCardListView from feeluown.gui.widgets.img_card_list import ( AlbumCardListModel, AlbumCardListView, AlbumFilterProxyModel, AlbumCardListDelegate, - ArtistCardListModel, ArtistCardListView, ArtistFilterProxyModel, ArtistCardListDelegate, + ArtistCardListModel, ArtistCardListView, ArtistFilterProxyModel, VideoCardListModel, VideoCardListView, VideoFilterProxyModel, VideoCardListDelegate, - PlaylistCardListModel, PlaylistCardListView, PlaylistFilterProxyModel, PlaylistCardListDelegate, + PlaylistCardListModel, PlaylistCardListView, PlaylistFilterProxyModel, + PlaylistCardListDelegate, ArtistCardListDelegate, ) from feeluown.gui.widgets.songs import ColumnsMode, SongsTableModel, SongsTableView, \ SongFilterProxyModel @@ -263,7 +264,8 @@ def __init__(self, app, parent=None): self.videos_table = VideoCardListView(parent=self) self.videos_table.setItemDelegate(VideoCardListDelegate(self.videos_table)) self.playlists_table = PlaylistCardListView(parent=self) - self.playlists_table.setItemDelegate(PlaylistCardListDelegate(self.playlists_table)) + self.playlists_table.setItemDelegate( + PlaylistCardListDelegate(self.playlists_table)) self.comments_table = CommentListView(parent=self) self.desc_widget = DescLabel(parent=self) diff --git a/feeluown/gui/pages/search.py b/feeluown/gui/pages/search.py index 4ce2c88f25..ec9cb7c427 100644 --- a/feeluown/gui/pages/search.py +++ b/feeluown/gui/pages/search.py @@ -58,9 +58,10 @@ async def render(req, **kwargs): # pylint: disable=too-many-locals,too-many-bra # HACK: set fixed row for tables. # pylint: disable=protected-access for table in table_container._tables: - if isinstance(table.itemDelegate(), ImgCardListDelegate): + delegate = table.itemDelegate() + if isinstance(delegate, ImgCardListDelegate): table._fixed_row_count = 2 - table.itemDelegate().img_min_width = 100 + delegate.update_settings("card_min_width", 100) elif isinstance(table, SongsTableView): table._fixed_row_count = 8 table._row_height = table.verticalHeader().defaultSectionSize() diff --git a/feeluown/gui/widgets/img_card_list.py b/feeluown/gui/widgets/img_card_list.py index 2ac7223ee6..0072cd1c86 100644 --- a/feeluown/gui/widgets/img_card_list.py +++ b/feeluown/gui/widgets/img_card_list.py @@ -12,7 +12,7 @@ # pylint: disable=unused-argument import logging import random -from typing import TypeVar, Optional, List, cast +from typing import TypeVar, Optional, List, cast, Union from PyQt5.QtCore import ( QAbstractListModel, QModelIndex, Qt, QObject, QEvent, @@ -20,7 +20,7 @@ ) from PyQt5.QtGui import ( QImage, QColor, QResizeEvent, - QBrush, QPainter, QTextOption, QFontMetrics, QPalette + QBrush, QPainter, QTextOption, QFontMetrics ) from PyQt5.QtWidgets import ( QAbstractItemDelegate, QListView, QFrame, @@ -31,7 +31,8 @@ from feeluown.utils.reader import wrap from feeluown.models.uri import reverse from feeluown.gui.helpers import ( - ItemViewNoScrollMixin, resize_font, ReaderFetchMoreMixin, + ItemViewNoScrollMixin, resize_font, ReaderFetchMoreMixin, painter_save, + secondary_text_color ) logger = logging.getLogger(__name__) @@ -128,8 +129,20 @@ def data(self, index, role): class ImgCardListDelegate(QAbstractItemDelegate): + """ + Card layout should be like the following:: + + |card0 card1 card2| + <- vertical spacing + |card3 card4 card5| + <- vertical spacing + + The leftmost cards should have a half_h_spacing on the right side, + and the rightmost cards should have a half_h_spacing on the left side. + Middle cards should have a half_h_spacing on both sides. + """ def __init__(self, parent=None, - img_min_width=150, img_spacing=20, img_text_height=40, + card_min_width=150, card_spacing=20, card_text_height=40, **_): super().__init__(parent) @@ -138,90 +151,63 @@ def __init__(self, parent=None, self.as_circle = True self.w_h_ratio = 1.0 - self.img_min_width = img_min_width - self.img_spacing = img_spacing - self.img_text_height = img_text_height + self.card_min_width = card_min_width + self.card_spacing = card_spacing + self.card_text_height = card_text_height - self.spacing = self.img_spacing - self.half_spacing = self.spacing // 2 - self.text_height = self.img_text_height + self.h_spacing = self.card_spacing + self.v_spacing = self.half_h_spacing = self.h_spacing // 2 + self.text_height = self.card_text_height - self._img_width = self._img_height = 0 + # These variables are calculated in on_view_resized(). + self._card_width = self._card_height = 0 self._view_width = 0 - def column_count(self): - return (self._view_width + self.img_spacing) // (self._img_width + self.img_spacing) - - def is_left_first(self, index): - if self.view.isWrapping(): - return index.row() % self.column_count() == 0 - return index.row() == 0 - - def is_right_last(self, index): - if self.view.isWrapping(): - column_count = self.column_count() - return index.row() % column_count == column_count - 1 - return False # FIXME: implement this - - def get_spacing(self, index): - if self.is_left_first(index) or self.is_right_last(index): - return self.half_spacing - return self.spacing + def update_settings(self, name, value): + assert hasattr(self, name), f"no such setting: {name}" + setattr(self, name, value) + self.re_calc_all() + self.view.update() def paint(self, painter, option, index): - painter.save() - painter.setRenderHint(QPainter.Antialiasing) - - rect = option.rect - painter.translate(rect.x(), rect.y()) - if not self.is_left_first(index): - painter.translate(self.half_spacing, 0) - - obj = index.data(Qt.DecorationRole) + obj: Optional[Union[QImage, QColor]] = index.data(Qt.DecorationRole) if obj is None: - painter.restore() return - text_title_height = 30 - text_source_height = self.text_height - text_title_height - text_source_color = non_text_color = self.get_non_text_color(option) - spacing = self.get_spacing(index) - draw_width = rect.width() - spacing - - # Draw cover or color. - cover_height = rect.height() - self.text_height - self.spacing - painter.save() - self.draw_cover_or_color(painter, non_text_color, obj, draw_width, cover_height) - painter.restore() - - # Draw text(album name / artist name / playlist name). - painter.translate(0, cover_height) - text_rect = QRectF(0, 0, draw_width, text_title_height) - painter.save() - self.draw_title(painter, index, text_rect) - painter.restore() - - # Draw source. - painter.save() - painter.translate(0, text_title_height - 5) - whats_this_rect = QRectF(0, 0, draw_width, text_source_height + 5) - self.draw_whats_this(painter, index, text_source_color, whats_this_rect) - painter.restore() - - painter.restore() - - def get_non_text_color(self, option): - text_color = option.palette.color(QPalette.Text) - if text_color.lightness() > 150: - non_text_color = text_color.darker(140) - else: - non_text_color = text_color.lighter(150) - non_text_color.setAlpha(100) - return non_text_color - - def draw_cover_or_color(self, painter, non_text_color, obj, draw_width, height): + with painter_save(painter): + painter.setRenderHint(QPainter.Antialiasing) + painter.translate(option.rect.x(), option.rect.y()) + + if not self.is_leftmost(index): + painter.translate(self.half_h_spacing, 0) + + spacing = self.get_card_h_spacing(index) + draw_width = option.rect.width() - spacing + + secondary_color = border_color = secondary_text_color(option.palette) + # Draw cover or color. + img_height = int(draw_width * self.w_h_ratio) + with painter_save(painter): + self.draw_img_or_color( + painter, border_color, obj, draw_width, img_height) + + # Draw text(album name / artist name / playlist name), and draw source. + text_title_height = 30 + text_source_height = self.text_height - text_title_height + painter.translate(0, img_height) + text_rect = QRectF(0, 0, draw_width, text_title_height) + with painter_save(painter): + self.draw_title(painter, index, text_rect) + painter.translate(0, text_title_height - 5) + with painter_save(painter): + self.draw_whats_this(painter, + index, + secondary_color, + QRectF(0, 0, draw_width, text_source_height + 5)) + + def draw_img_or_color(self, painter, border_color, obj, draw_width, height): pen = painter.pen() - pen.setColor(non_text_color) + pen.setColor(border_color) painter.setPen(pen) if isinstance(obj, QColor): color = obj @@ -267,37 +253,61 @@ def draw_whats_this(self, painter, index, non_text_color, whats_this_rect): painter.drawText(whats_this_rect, whats_this, source_option) def sizeHint(self, option, index): - spacing = self.get_spacing(index) - width = self._img_width + spacing = self.get_card_h_spacing(index) + width = self._card_width if index.isValid(): height = int(width / self.w_h_ratio) + self.text_height - return QSize(width + spacing, height + self.spacing) + return QSize(width + spacing, height + self.v_spacing) return super().sizeHint(option, index) - def on_view_resized(self, size: QSize, old_size: QSize): + def on_view_resized(self, size: QSize, _: QSize): self._view_width = size.width() - self._img_width, self._img_height = self.re_calc_img_size() - self.view._row_height = self._img_height + self.re_calc_all() - def re_calc_img_size(self): + def re_calc_all(self): # HELP: CardListView needs about 20 spacing left on macOS width = max(0, self._view_width - 20) - img_spacing = self.img_spacing - img_min_width = self.img_min_width - img_text_height = self.img_text_height + card_spacing = self.card_spacing # according to our algorithm, when the widget width is: - # 2(img_min_width + img_spacing) + img_spacing - 1, + # 2(card_min_width + card_spacing) + card_spacing - 1, # the cover width can take the maximum width, it will be: - # CoverMaxWidth = 2 * img_min_width + img_spacing - 1 + # CoverMaxWidth = 2 * card_min_width + card_spacing - 1 # calculate max column count - count = (width + img_spacing) // (img_min_width + img_spacing) + count = (width + card_spacing) // (self.card_min_width + card_spacing) count = max(count, 1) # calculate img_width when column count is the max - img_height = img_width = (width + img_spacing) // count - img_spacing - img_height = img_height + img_text_height - return img_width, img_height + self._card_width = (width + card_spacing) // count - card_spacing + self._card_height = int(self._card_width * self.w_h_ratio) + self.text_height + self.view._row_height = self._card_height + self.v_spacing + + def column_count(self): + return (self._view_width + self.card_spacing) // \ + (self._card_width + self.card_spacing) + + def which_column(self, index: QModelIndex): + if not self.view.isWrapping(): + return index.row() + return index.row() % self.column_count() + + def which_row(self, index: QModelIndex): + if not self.view.isWrapping(): + return 0 + return index.row() // self.column_count() + + def is_leftmost(self, index): + return self.which_column(index) == 0 + + def is_rightmost(self, index): + if self.view.isWrapping(): + return self.which_column(index) == self.column_count() - 1 + return False # HELP: no way to check if it is the rightmost. + + def get_card_h_spacing(self, index): + if self.is_leftmost(index) or self.is_rightmost(index): + return self.half_h_spacing + return self.h_spacing def eventFilter(self, _: QObject, event: QEvent): if event.type() == QEvent.Resize: @@ -331,7 +341,7 @@ def filterAcceptsRow(self, source_row, source_parent): class ImgCardListView(ItemViewNoScrollMixin, QListView): """ .. versionchanged:: 3.9 - The *img_min_width*, *img_spacing*, *img_text_height* parameter were removed. + The *card_min_width*, *card_spacing*, *card_text_height* parameter were removed. """ def __init__(self, parent=None, **kwargs): super().__init__(parent=parent, **kwargs)