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 69553fe64a..c2d391960e 100644 --- a/feeluown/gui/page_containers/table.py +++ b/feeluown/gui/page_containers/table.py @@ -18,15 +18,14 @@ 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, + VideoCardListModel, VideoCardListView, VideoFilterProxyModel, VideoCardListDelegate, + PlaylistCardListModel, PlaylistCardListView, PlaylistFilterProxyModel, + PlaylistCardListDelegate, ArtistCardListDelegate, +) from feeluown.gui.widgets.songs import ColumnsMode, SongsTableModel, SongsTableView, \ SongFilterProxyModel from feeluown.gui.widgets.comment_list import CommentListView, CommentListModel @@ -93,25 +92,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 +256,16 @@ 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) @@ -306,6 +311,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) @@ -360,7 +366,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..ec9cb7c427 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,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, ImgListView): + delegate = table.itemDelegate() + if isinstance(delegate, ImgCardListDelegate): table._fixed_row_count = 2 - table.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/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/img_card_list.py b/feeluown/gui/widgets/img_card_list.py new file mode 100644 index 0000000000..0072cd1c86 --- /dev/null +++ b/feeluown/gui/widgets/img_card_list.py @@ -0,0 +1,501 @@ +""" +ImgList model and delegate + +- 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 +resized, the cover width and the margin should make a few adjustment. +""" + +# pylint: disable=unused-argument +import logging +import random +from typing import TypeVar, Optional, List, cast, Union + +from PyQt5.QtCore import ( + QAbstractListModel, QModelIndex, Qt, QObject, QEvent, + QRectF, QRect, QSize, QSortFilterProxyModel, pyqtSignal +) +from PyQt5.QtGui import ( + QImage, QColor, QResizeEvent, + QBrush, QPainter, QTextOption, QFontMetrics +) +from PyQt5.QtWidgets import ( + QAbstractItemDelegate, QListView, QFrame, +) + +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 ( + ItemViewNoScrollMixin, resize_font, ReaderFetchMoreMixin, painter_save, + secondary_text_color +) + +logger = logging.getLogger(__name__) +T = TypeVar("T") + + +COLORS = { + 'yellow': '#b58900', + 'orange': '#cb4b16', + 'red': '#dc322f', + 'magenta': '#d33682', + 'violet': '#6c71c4', + 'blue': '#268bd2', + 'cyan': '#2aa198', + 'green': '#859900', +} + + +class ImgCardListModel(QAbstractListModel, ReaderFetchMoreMixin[T]): + def __init__(self, reader, fetch_image, source_name_map=None, parent=None): + """ + + :param reader: objects in reader should have `name` property + :param fetch_image: func(item, cb, uid) + :type reader: Iterable + """ + super().__init__(parent) + + self.reader = self._reader = wrap(reader) + self._fetch_more_step = 10 + self._items: List[T] = [] + self._is_fetching = False + + self.source_name_map = source_name_map or {} + self.fetch_image = fetch_image + self.colors = [] + self.images = {} # {uri: QImage} + + def rowCount(self, _=QModelIndex()): + return len(self._items) + + def _fetch_more_cb(self, items: Optional[List[T]]): + self._is_fetching = False + # None means an error occured. + if items is None: + return + if items is not None and not items: + self.no_more_item.emit() + return + items_len = len(items) + colors = [random.choice(list(COLORS.values())) for _ in range(0, items_len)] + self.colors.extend(colors) + self.on_items_fetched(items) + for item in items: + aio.create_task(self.fetch_image(item, self._fetch_image_callback(item))) + + def _fetch_image_callback(self, item): + def cb(content): + uri = reverse(item) + if content is None: + self.images[uri] = None + return + + img = QImage() + img.loadFromData(content) + self.images[uri] = img + row = self._items.index(item) + top_left = self.createIndex(row, 0) + bottom_right = self.createIndex(row, 0) + self.dataChanged.emit(top_left, bottom_right) + return cb + + def data(self, index, role): + offset = index.row() + if not index.isValid() or offset >= len(self._items): + return None + item = self._items[offset] + if role == Qt.DecorationRole: + uri = reverse(item) + image = self.images.get(uri) + if image is not None: + return image + color_str = self.colors[offset] + color = QColor(color_str) + color.setAlphaF(0.8) + return color + elif role == Qt.DisplayRole: + return item.name_display + elif role == Qt.UserRole: + return item + elif role == Qt.WhatsThisRole: + return self.source_name_map.get(item.source, item.source) + return None + + +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, + card_min_width=150, card_spacing=20, card_text_height=40, + **_): + super().__init__(parent) + + self.view: 'ImgCardListView' = parent + self.view.installEventFilter(self) + self.as_circle = True + self.w_h_ratio = 1.0 + + self.card_min_width = card_min_width + self.card_spacing = card_spacing + self.card_text_height = card_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 + + # These variables are calculated in on_view_resized(). + self._card_width = self._card_height = 0 + self._view_width = 0 + + 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): + obj: Optional[Union[QImage, QColor]] = index.data(Qt.DecorationRole) + if obj is None: + return + + 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(border_color) + painter.setPen(pen) + if isinstance(obj, QColor): + color = obj + brush = QBrush(color) + painter.setBrush(brush) + else: + if obj.height() < obj.width(): + img = obj.scaledToHeight(height, Qt.SmoothTransformation) + else: + img = obj.scaledToWidth(draw_width, Qt.SmoothTransformation) + brush = QBrush(img) + painter.setBrush(brush) + border_radius = 3 + if self.as_circle: + border_radius = draw_width // 2 + cover_rect = QRect(0, 0, draw_width, height) + painter.drawRoundedRect(cover_rect, border_radius, border_radius) + + def draw_title(self, painter, index, text_rect): + text_option = QTextOption() + if self.as_circle: + text_option.setAlignment(Qt.AlignHCenter | Qt.AlignVCenter) + else: + 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, text_option) + + 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) + pen = painter.pen() + font = painter.font() + resize_font(font, -2) + painter.setFont(font) + pen.setColor(non_text_color) + painter.setPen(non_text_color) + painter.drawText(whats_this_rect, whats_this, source_option) + + def sizeHint(self, option, index): + 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.v_spacing) + return super().sizeHint(option, index) + + def on_view_resized(self, size: QSize, _: QSize): + self._view_width = size.width() + self.re_calc_all() + + def re_calc_all(self): + # HELP: CardListView needs about 20 spacing left on macOS + width = max(0, self._view_width - 20) + card_spacing = self.card_spacing + + # according to our algorithm, when the widget width is: + # 2(card_min_width + card_spacing) + card_spacing - 1, + # the cover width can take the maximum width, it will be: + # CoverMaxWidth = 2 * card_min_width + card_spacing - 1 + + # calculate max column count + count = (width + card_spacing) // (self.card_min_width + card_spacing) + count = max(count, 1) + # calculate img_width when column count is the max + 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: + event = cast(QResizeEvent, event) + self.on_view_resized(event.size(), event.oldSize()) + return False + + +class ImgFilterProxyModel(QSortFilterProxyModel): + def __init__(self, parent=None, types=None): + super().__init__(parent) + + self.text = '' + + def filter_by_text(self, text): + if text == self.text: + return + self.text = text + self.invalidateFilter() + + def filterAcceptsRow(self, source_row, source_parent): + accepted = True + source_model = cast(ImgCardListModel, self.sourceModel()) + index = source_model.index(source_row, parent=source_parent) + artist = index.data(Qt.UserRole) + if self.text: + accepted = self.text.lower() in artist.name_display.lower() + return accepted + + +class ImgCardListView(ItemViewNoScrollMixin, QListView): + """ + .. versionchanged:: 3.9 + The *card_min_width*, *card_spacing*, *card_text_height* parameter were removed. + """ + def __init__(self, parent=None, **kwargs): + super().__init__(parent=parent, **kwargs) + + # 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 = 0 + + self.setViewMode(QListView.IconMode) + self.setResizeMode(QListView.Adjust) + self.setWrapping(True) + self.setFrameShape(QFrame.NoFrame) + self.initialize() + + self.activated.connect(self.on_activated) + + def on_activated(self, _: QModelIndex): + """ + Subclass can implement this method if needed. + """ + pass + + +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) + + +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/imglist.py b/feeluown/gui/widgets/imglist.py deleted file mode 100644 index 52d6664c1a..0000000000 --- a/feeluown/gui/widgets/imglist.py +++ /dev/null @@ -1,295 +0,0 @@ -""" -ImgList model and delegate - -- ImgListDelegate -- ImgListModel - -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 -resized, the cover width and the margin should make a few adjustment. -""" - -# pylint: disable=unused-argument -import logging -import random -from typing import TypeVar, Optional, List, cast - -from PyQt5.QtCore import ( - QAbstractListModel, QModelIndex, Qt, - QRectF, QRect, QSize, QSortFilterProxyModel -) -from PyQt5.QtGui import ( - QImage, QColor, - QBrush, QPainter, QTextOption, QFontMetrics, QPalette -) -from PyQt5.QtWidgets import ( - QAbstractItemDelegate, QListView, QFrame, -) - -from feeluown.utils import aio -from feeluown.utils.reader import wrap -from feeluown.models.uri import reverse -from feeluown.gui.helpers import ( - ItemViewNoScrollMixin, resize_font, ReaderFetchMoreMixin, -) - -logger = logging.getLogger(__name__) -T = TypeVar("T") - - -COLORS = { - 'yellow': '#b58900', - 'orange': '#cb4b16', - 'red': '#dc322f', - 'magenta': '#d33682', - 'violet': '#6c71c4', - 'blue': '#268bd2', - 'cyan': '#2aa198', - 'green': '#859900', -} - - -class ImgListModel(QAbstractListModel, ReaderFetchMoreMixin[T]): - def __init__(self, reader, fetch_image, source_name_map=None, parent=None): - """ - - :param reader: objects in reader should have `name` property - :param fetch_image: func(item, cb, uid) - :type reader: Iterable - """ - super().__init__(parent) - - self.reader = self._reader = wrap(reader) - self._fetch_more_step = 10 - self._items: List[T] = [] - self._is_fetching = False - - self.source_name_map = source_name_map or {} - self.fetch_image = fetch_image - self.colors = [] - self.images = {} # {uri: QImage} - - def rowCount(self, _=QModelIndex()): - return len(self._items) - - def _fetch_more_cb(self, items: Optional[List[T]]): - self._is_fetching = False - # None means an error occured. - if items is None: - return - if items is not None and not items: - self.no_more_item.emit() - return - items_len = len(items) - colors = [random.choice(list(COLORS.values())) for _ in range(0, items_len)] - self.colors.extend(colors) - self.on_items_fetched(items) - for item in items: - aio.create_task(self.fetch_image(item, self._fetch_image_callback(item))) - - def _fetch_image_callback(self, item): - def cb(content): - uri = reverse(item) - if content is None: - self.images[uri] = None - return - - img = QImage() - img.loadFromData(content) - self.images[uri] = img - row = self._items.index(item) - top_left = self.createIndex(row, 0) - bottom_right = self.createIndex(row, 0) - self.dataChanged.emit(top_left, bottom_right) - return cb - - def data(self, index, role): - offset = index.row() - if not index.isValid() or offset >= len(self._items): - return None - item = self._items[offset] - if role == Qt.DecorationRole: - uri = reverse(item) - image = self.images.get(uri) - if image is not None: - return image - color_str = self.colors[offset] - color = QColor(color_str) - color.setAlphaF(0.8) - return color - elif role == Qt.DisplayRole: - return item.name_display - elif role == Qt.UserRole: - return item - elif role == Qt.WhatsThisRole: - return self.source_name_map.get(item.source, item.source) - return None - - -class ImgListDelegate(QAbstractItemDelegate): - def __init__(self, parent=None): - super().__init__(parent) - - self.view = parent - # TODO: move as_circle/w_h_ratio attribute to view - self.as_circle = True - self.w_h_ratio = 1.0 - - 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 - ) - obj = index.data(Qt.DecorationRole) - if obj is None: - painter.restore() - return - - 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() - 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) - else: - img = obj.scaledToWidth(cover_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) - painter.drawRoundedRect(cover_rect, border_radius, border_radius) - painter.restore() - option = QTextOption() - source_option = QTextOption() - if self.as_circle: - option.setAlignment(Qt.AlignHCenter | Qt.AlignVCenter) - source_option.setAlignment(Qt.AlignHCenter | Qt.AlignTop) - else: - option.setAlignment(Qt.AlignLeft | Qt.AlignVCenter) - source_option.setAlignment(Qt.AlignLeft | Qt.AlignTop) - 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() - - # Draw WhatsThis. - whats_this = index.data(Qt.WhatsThisRole) - painter.save() - pen = painter.pen() - font = painter.font() - resize_font(font, -2) - painter.setFont(font) - 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): - width = self.view.img_sizehint()[0] - if index.isValid(): - return QSize(width, int(width / self.w_h_ratio) + self.view.img_text_height) - return super().sizeHint(option, index) - - -class ImgFilterProxyModel(QSortFilterProxyModel): - def __init__(self, parent=None, types=None): - super().__init__(parent) - - self.text = '' - - def filter_by_text(self, text): - if text == self.text: - return - self.text = text - self.invalidateFilter() - - def filterAcceptsRow(self, source_row, source_parent): - accepted = True - source_model = cast(ImgListModel, self.sourceModel()) - index = source_model.index(source_row, parent=source_parent) - artist = index.data(Qt.UserRole) - if self.text: - accepted = self.text.lower() in artist.name_display.lower() - return accepted - - -class ImgListView(ItemViewNoScrollMixin, QListView): - """ - .. versionadded:: 3.7.7 - The *img_min_width*, *img_spacing*, *img_text_height* parameter were added. - """ - def __init__(self, parent=None, - img_min_width=150, img_spacing=20, img_text_height=40, - **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 - self._least_row_count = 1 - self._row_height = img_min_width + img_spacing + img_text_height - - self.setViewMode(QListView.IconMode) - self.setResizeMode(QListView.Adjust) - self.setWrapping(True) - self.setFrameShape(QFrame.NoFrame) - self.setSpacing(self.img_spacing) - 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() - - def img_sizehint(self) -> tuple: - """ - - .. versionadded:: 3.7.7 - """ - # HELP: listview needs about 20 spacing left on macOS - width = self.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) - img_height = img_width = (width - ((count + 1) * img_spacing)) // count - img_height = img_height + img_text_height - return img_width, img_height 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/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) 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)