diff --git a/feeluown/app/gui_app.py b/feeluown/app/gui_app.py index f24dda7133..b26490fac3 100644 --- a/feeluown/app/gui_app.py +++ b/feeluown/app/gui_app.py @@ -16,7 +16,6 @@ from feeluown.gui.uimodels.provider import ProviderUiManager from feeluown.gui.uimodels.playlist import PlaylistUiManager from feeluown.gui.uimodels.my_music import MyMusicUiManager -from feeluown.gui.uimodels.collection import CollectionUiManager from feeluown.collection import CollectionManager @@ -76,7 +75,6 @@ def __init__(self, *args, **kwargs): self.pvd_uimgr = ProviderUiManager(self) self.pl_uimgr = PlaylistUiManager(self) self.mymusic_uimgr = MyMusicUiManager(self) - self.coll_uimgr = CollectionUiManager(self) self.browser = Browser(self) self.ui = Ui(self) @@ -92,7 +90,7 @@ def initialize(self): if self.config.ENABLE_TRAY: self.tray.initialize() self.tray.show() - self.coll_uimgr.initialize() + self.coll_mgr.scan() self.watch_mgr.initialize() self.browser.initialize() QApplication.instance().aboutToQuit.connect(self.about_to_exit) @@ -103,9 +101,8 @@ def run(self): def apply_state(self, state): super().apply_state(state) - coll_library = self.coll_uimgr.get_coll_library() - coll_id = self.coll_uimgr.get_coll_id(coll_library) - self.browser.goto(page=f'/colls/{coll_id}') + coll_library = self.coll_mgr.get_coll_library() + self.browser.goto(page=f'/colls/{coll_library.identifier}') gui = state.get('gui', {}) lyric = gui.get('lyric', {}) diff --git a/feeluown/collection.py b/feeluown/collection.py index 4dc4966455..76a5a8117a 100644 --- a/feeluown/collection.py +++ b/feeluown/collection.py @@ -1,15 +1,19 @@ +import base64 import itertools import logging import os from datetime import datetime from enum import Enum from pathlib import Path +from typing import Dict, Iterable, List import tomlkit +from feeluown.consts import COLLECTIONS_DIR +from feeluown.utils.dispatch import Signal from feeluown.models.uri import resolve, reverse, ResolverNotFound, \ ResolveFailed, ModelExistence -from feeluown.consts import COLLECTIONS_DIR +from feeluown.utils.utils import elfhash logger = logging.getLogger(__name__) @@ -23,6 +27,10 @@ TOML_DELIMLF = "+++\n" +class CollectionAlreadyExists(Exception): + pass + + class CollectionType(Enum): sys_library = 16 @@ -34,11 +42,14 @@ class Collection: def __init__(self, fpath): # TODO: 以后考虑添加 identifier 字段,identifier # 字段应该尽量设计成可以跨电脑使用 - self.fpath = fpath + self.fpath = str(fpath) + # TODO: 目前还没想好 collection identifier 计算方法,故添加这个函数 + # 现在把 fpath 当作 identifier 使用,但对外透明 + self.identifier = elfhash(base64.b64encode(bytes(self.fpath, 'utf-8'))) # these variables should be inited during loading self.type = None - self.name = None + self.name = None # Collection title. self.models = [] self.updated_at = None self.created_at = None @@ -100,6 +111,22 @@ def load(self): self._has_nonexistent_models = True self.models.append(model) + @classmethod + def create_empty(cls, fpath, title=''): + """Create an empty collection.""" + if os.path.exists(fpath): + raise CollectionAlreadyExists() + + doc = tomlkit.document() + if title: + doc.add('title', title) + doc.add('created', datetime.now()) + doc.add('updated', datetime.now()) + with open(fpath, 'w', encoding='utf-8') as f: + f.write(TOML_DELIMLF) + f.write(tomlkit.dumps(doc)) + f.write(TOML_DELIMLF) + def add(self, model): """add model to collection @@ -179,26 +206,91 @@ def _write_metadata_if_needed(self, f): class CollectionManager: + def __init__(self, app): self._app = app + self.scan_finished = Signal() self._library = app.library self.default_dir = COLLECTIONS_DIR - def scan(self): - """Scan collections directories for valid fuo files, yield - Collection instance for each file. - """ - default_fpaths = [] + self._id_coll_mapping: Dict[str, Collection] = {} + + def get(self, identifier): + return self._id_coll_mapping.get(int(identifier), None) + + def get_coll_library(self): + for coll in self._id_coll_mapping.values(): + if coll.type == CollectionType.sys_library: + return coll + assert False, "collection 'library' must exists." + + def create(self, fname, title) -> Collection: + first_valid_dir = '' + for d, exists in self._get_dirs(): + if exists: + first_valid_dir = d + break + + assert first_valid_dir, 'there must be a valid collection dir' + normalized_name = fname.replace(' ', '_') + fpath = os.path.join(first_valid_dir, normalized_name) + filepath = f'{fpath}.fuo' + logger.info(f'Create collection:{title} at {filepath}') + return Collection.create_empty(filepath, title) + + def remove(self, collection: Collection): + coll_id = collection.identifier + if coll_id in self._id_coll_mapping: + self._id_coll_mapping.pop(coll_id) + os.remove(collection.fpath) + + def _get_dirs(self, ): directorys = [self.default_dir] if self._app.config.COLLECTIONS_DIR: if isinstance(self._app.config.COLLECTIONS_DIR, list): directorys += self._app.config.COLLECTIONS_DIR else: directorys.append(self._app.config.COLLECTIONS_DIR) + expanded_dirs = [] for directory in directorys: directory = os.path.expanduser(directory) - if not os.path.exists(directory): - logger.warning(f'Collection Dir:{directory} does not exist.') + expanded_dirs.append((directory, os.path.exists(directory))) + return expanded_dirs + + def scan(self): + colls: List[Collection] = [] + library_coll = None + for coll in self._scan(): + if coll.type == CollectionType.sys_library: + library_coll = coll + continue + colls.append(coll) + colls.insert(0, library_coll) + for collection in colls: + coll_id = collection.identifier + assert coll_id not in self._id_coll_mapping, collection.fpath + self._id_coll_mapping[coll_id] = collection + self.scan_finished.emit() + + def refresh(self): + self.clear() + self.scan() + + def listall(self): + return self._id_coll_mapping.values() + + def clear(self): + self._id_coll_mapping.clear() + + def _scan(self) -> Iterable[Collection]: + """Scan collections directories for valid fuo files, yield + Collection instance for each file. + """ + default_fpaths = [] + valid_dirs = self._get_dirs() + for directory, exists in valid_dirs: + if not exists: + logger.warning('Collection directory %s does not exist', directory) continue for filename in os.listdir(directory): if not filename.endswith('.fuo'): diff --git a/feeluown/gui/__init__.py b/feeluown/gui/__init__.py index deec1c5e9b..eb68527eac 100644 --- a/feeluown/gui/__init__.py +++ b/feeluown/gui/__init__.py @@ -1,11 +1,9 @@ -from .uimodels.collection import CollectionUiManager from .uimodels.provider import ProviderUiManager from .uimodels.my_music import MyMusicUiManager from .uimodels.playlist import PlaylistUiManager __all__ = ( - 'CollectionUiManager', 'ProviderUiManager', 'MyMusicUiManager', 'PlaylistUiManager', diff --git a/feeluown/gui/browser.py b/feeluown/gui/browser.py index 384d8c5ba5..d75f2b09a7 100644 --- a/feeluown/gui/browser.py +++ b/feeluown/gui/browser.py @@ -171,7 +171,7 @@ def can_forward(self): # -------------- def _render_coll(self, _, identifier): - coll = self._app.coll_uimgr.get(int(identifier)) + coll = self._app.coll_mgr.get(identifier) self._app.ui.right_panel.show_collection(coll) def on_history_changed(self): diff --git a/feeluown/gui/components/__init__.py b/feeluown/gui/components/__init__.py index 177db3430e..957303c216 100644 --- a/feeluown/gui/components/__init__.py +++ b/feeluown/gui/components/__init__.py @@ -9,3 +9,4 @@ from .playlist_btn import PlaylistButton # noqa from .volume_slider import * # noqa from .song_tag import SongSourceTag # noqa +from .collections import CollectionListView # noqa diff --git a/feeluown/gui/components/btns.py b/feeluown/gui/components/btns.py index f75d3fa548..df6b008b57 100644 --- a/feeluown/gui/components/btns.py +++ b/feeluown/gui/components/btns.py @@ -92,7 +92,7 @@ def on_song_changed(self, song, media): def toggle_liked(self): song = self._app.playlist.current_song - coll_library = self._app.coll_uimgr.get_coll_library() + coll_library = self._app.coll_mgr.get_coll_library() if self.is_song_liked(song): coll_library.remove(song) self._app.show_msg('歌曲已经从“本地收藏”中移除') @@ -108,7 +108,7 @@ def on_toggled(self): self.setToolTip('从“本地收藏”中移除') def is_song_liked(self, song): - coll_library = self._app.coll_uimgr.get_coll_library() + coll_library = self._app.coll_mgr.get_coll_library() return song in coll_library.models diff --git a/feeluown/gui/widgets/collections.py b/feeluown/gui/components/collections.py similarity index 64% rename from feeluown/gui/widgets/collections.py rename to feeluown/gui/components/collections.py index 84995352fc..614a07c052 100644 --- a/feeluown/gui/widgets/collections.py +++ b/feeluown/gui/components/collections.py @@ -1,46 +1,54 @@ import logging +from typing import TYPE_CHECKING + from PyQt5.QtCore import pyqtSignal, Qt -from PyQt5.QtWidgets import QAbstractItemView +from PyQt5.QtWidgets import QAbstractItemView, QMenu -from feeluown.collection import CollectionType -from .textlist import TextlistModel, TextlistView +from feeluown.collection import CollectionType, Collection +from feeluown.gui.widgets.textlist import TextlistModel, TextlistView +if TYPE_CHECKING: + from feeluown.app.gui_app import GuiApp logger = logging.getLogger(__name__) -class CollectionsModel(TextlistModel): - def flags(self, index): - if not index.isValid(): - return 0 - flags = Qt.ItemIsSelectable | Qt.ItemIsEnabled | Qt.ItemIsDropEnabled - return flags - - def data(self, index, role=Qt.DisplayRole): - row = index.row() - item = self._items[row] - if role == Qt.DisplayRole: - icon = '◎ ' - if item.type == CollectionType.sys_library: - icon = '◉ ' - return icon + item.name - if role == Qt.ToolTipRole: - return item.fpath - return super().data(index, role) - - -class CollectionsView(TextlistView): +class CollectionListView(TextlistView): + """ + Maybe make this a component instead of a widget. + """ show_collection = pyqtSignal([object]) + remove_collection = pyqtSignal([object]) - def __init__(self, parent): - super().__init__(parent) + def __init__(self, app: 'GuiApp', **kwargs): + super().__init__(**kwargs) + self._app = app self.setDragDropMode(QAbstractItemView.DropOnly) + self.setModel(CollectionListModel(self)) + self.clicked.connect(self._on_clicked) + self._app.coll_mgr.scan_finished.connect(self.on_scan_finished) + + def on_scan_finished(self): + self.model().clear() + for coll in self._app.coll_mgr.listall(): + self.model().add(coll) def _on_clicked(self, index): collection = index.data(role=Qt.UserRole) self.show_collection.emit(collection) + def contextMenuEvent(self, event): + indexes = self.selectionModel().selectedIndexes() + if len(indexes) != 1: + return + + collection: Collection = self.model().data(indexes[0], Qt.UserRole) + menu = QMenu() + action = menu.addAction('删除此收藏集') + action.triggered.connect(lambda: self.remove_collection.emit(collection)) + menu.exec(event.globalPos()) + # dragEnterEvent -> dragMoveEvent -> dropEvent def dragEnterEvent(self, e): """ @@ -78,3 +86,23 @@ def dropEvent(self, e): self.viewport().update() self._result_timer.start(2000) e.accept() + + +class CollectionListModel(TextlistModel): + def flags(self, index): + if not index.isValid(): + return 0 + flags = Qt.ItemIsSelectable | Qt.ItemIsEnabled | Qt.ItemIsDropEnabled + return flags + + def data(self, index, role=Qt.DisplayRole): + row = index.row() + item = self._items[row] + if role == Qt.DisplayRole: + icon = '◎ ' + if item.type == CollectionType.sys_library: + icon = '◉ ' + return icon + item.name + if role == Qt.ToolTipRole: + return item.fpath + return super().data(index, role) diff --git a/feeluown/gui/drawers.py b/feeluown/gui/drawers.py index 1b67953dcf..eaac6a5ff1 100644 --- a/feeluown/gui/drawers.py +++ b/feeluown/gui/drawers.py @@ -1,5 +1,5 @@ -from PyQt5.QtCore import Qt, QRect -from PyQt5.QtGui import QPainter, QBrush, QPixmap, QImage, QColor +from PyQt5.QtCore import Qt, QRect, QPoint, QPointF +from PyQt5.QtGui import QPainter, QBrush, QPixmap, QImage, QColor, QPolygonF from PyQt5.QtWidgets import QWidget @@ -91,3 +91,96 @@ def draw(self, painter): width, height = self._length // 2, self._length // 2 painter.drawArc(x, y, width, height, 0, 60*16) painter.drawArc(x, y, width, height, 120*16, 60*16) + + +class PlusIconDrawer: + def __init__(self, length, padding): + self.top = QPoint(length//2, padding) + self.bottom = QPoint(length//2, length - padding) + self.left = QPoint(padding, length//2) + self.right = QPoint(length-padding, length//2) + + def draw(self, painter): + pen = painter.pen() + pen.setWidthF(1.5) + painter.setPen(pen) + painter.drawLine(self.top, self.bottom) + painter.drawLine(self.left, self.right) + + +class TriangleIconDrawer: + def __init__(self, length, padding, direction='up'): + self._length = length + self._padding = padding + self.set_direction(direction) + + def set_direction(self, direction): + length = self._length + padding = self._padding + + half = length / 2 + diameter = (length - 2 * padding) * 1.2 + d60 = diameter / 2 * 0.87 # sin60 + d30 = diameter / 2 / 2 # sin30 + + half_d30 = half - d30 + half_d60 = half - d60 + half_p_d60 = half + d60 + half_p_d30 = half + d30 + l_p = length - padding + + center_top = QPointF(half, padding) + center_bottom = QPointF(half, l_p) + left = QPointF(padding, half) + right = QPointF(l_p, half) + left_top = QPointF(half_d30, half_d60) + left_bottom = QPointF(half_d60, half_p_d30) + right_top = QPointF(half_p_d30, half_p_d60) + right_bottom = QPointF(half_p_d60, half_p_d30) + + if direction == 'up': + self._triangle = QPolygonF([center_top, left_bottom, right_bottom]) + elif direction == 'down': + self._triangle = QPolygonF([center_bottom, left_top, right_top]) + elif direction == 'left': + self._triangle = QPolygonF([left, right_top, right_bottom]) + elif direction == 'right': + self._triangle = QPolygonF([right, left_top, left_bottom]) + else: + raise ValueError('direction must be one of up/down/left/right') + + def draw(self, painter): + pen = painter.pen() + pen.setWidthF(1.5) + painter.setPen(pen) + painter.drawPolygon(self._triangle) + + +class HomeIconDrawer: + def __init__(self, length, padding): + icon_length = length + diff = 1 # root/body width diff + h_padding = v_padding = padding + + body_left_x = h_padding + diff*2 + body_right_x = icon_length - h_padding - diff*2 + body_top_x = icon_length // 2 + + self._roof = QPoint(icon_length // 2, v_padding) + self._root_left = QPoint(h_padding, icon_length // 2 + diff) + self._root_right = QPoint(icon_length - h_padding, icon_length // 2 + diff) + + self._body_bottom_left = QPoint(body_left_x, icon_length - v_padding) + self._body_bottom_right = QPoint(body_right_x, icon_length - v_padding) + self._body_top_left = QPoint(body_left_x, body_top_x) + self._body_top_right = QPoint(body_right_x, body_top_x) + + def paint(self, painter): + pen = painter.pen() + pen.setWidthF(1.5) + painter.setPen(pen) + painter.drawLine(self._roof, self._root_left) + painter.drawLine(self._roof, self._root_right) + painter.drawLine(self._body_bottom_left, self._body_bottom_right) + painter.drawLine(self._body_top_left, self._body_bottom_left) + painter.drawLine(self._body_top_right, self._body_bottom_right) diff --git a/feeluown/gui/pages/coll_mixed.py b/feeluown/gui/pages/coll_mixed.py index abebe2e5cd..2e809dc80f 100644 --- a/feeluown/gui/pages/coll_mixed.py +++ b/feeluown/gui/pages/coll_mixed.py @@ -1,5 +1,5 @@ from feeluown.app.gui_app import GuiApp -from feeluown.collection import CollectionType +from feeluown.collection import CollectionType, Collection from feeluown.models import ModelType from feeluown.utils.reader import wrap from feeluown.gui.page_containers.table import Renderer @@ -12,7 +12,7 @@ async def render(req, identifier, **kwargs): ui = app.ui tab_index = int(req.query.get('tab_index', 0)) - coll = app.coll_uimgr.get(int(identifier)) + coll = app.coll_mgr.get(identifier) mixed = False model_type = None @@ -47,7 +47,7 @@ class LibraryRenderer(Renderer, TabBarRendererMixin): NOTE(cosven): I think mixed collection should be rendered in single page without tab. """ - def __init__(self, app, tab_index, coll): + def __init__(self, app, tab_index, coll: Collection): self._app = app self._coll = coll self.tab_index = tab_index @@ -82,8 +82,7 @@ def remove_song(model): self.songs_table.remove_song_func = remove_song def render_by_tab_index(self, tab_index): - coll_id = self._app.coll_uimgr.get_coll_id(self._coll) - self._app.browser.goto(page=f'/colls/{coll_id}', + self._app.browser.goto(page=f'/colls/{self._coll.identifier}', query={'tab_index': tab_index}) def render_models(self): diff --git a/feeluown/gui/uimain/sidebar.py b/feeluown/gui/uimain/sidebar.py index 531369b0a4..c556062475 100644 --- a/feeluown/gui/uimain/sidebar.py +++ b/feeluown/gui/uimain/sidebar.py @@ -1,26 +1,34 @@ import sys +from typing import TYPE_CHECKING from PyQt5.QtCore import QSize, Qt from PyQt5.QtWidgets import QFrame, QLabel, QVBoxLayout, QSizePolicy, QScrollArea, \ - QHBoxLayout + QHBoxLayout, QFormLayout, QDialog, QLineEdit, QDialogButtonBox, QMessageBox -from feeluown.gui.widgets import RecentlyPlayedButton, HomeButton +from feeluown.collection import CollectionAlreadyExists +from feeluown.gui.widgets import ( + RecentlyPlayedButton, HomeButton, PlusButton, TriagleButton, +) from feeluown.gui.widgets.playlists import PlaylistsView -from feeluown.gui.widgets.collections import CollectionsView +from feeluown.gui.components import CollectionListView from feeluown.gui.widgets.my_music import MyMusicView -from feeluown.gui.widgets.textbtn import TextButton + +if TYPE_CHECKING: + from feeluown.app.gui_app import GuiApp class ListViewContainer(QFrame): - btn_text_hide = '△' - btn_text_show = '▼' def __init__(self, label, view, parent=None): super().__init__(parent) + self._btn_length = 14 self._label = label self._view = view - self._toggle_btn = TextButton(self.btn_text_hide, self) + self._toggle_btn = TriagleButton(length=self._btn_length) + self.create_btn = PlusButton(length=self._btn_length) + # Show this button when needed. + self.create_btn.hide() self._toggle_btn.clicked.connect(self.toggle_view) self.setup_ui() @@ -36,6 +44,8 @@ def setup_ui(self): self._b_h_layout = QHBoxLayout() self._t_h_layout.addWidget(self._label) self._t_h_layout.addStretch(0) + self._t_h_layout.addWidget(self.create_btn) + self._t_h_layout.addSpacing(self._btn_length // 2) self._t_h_layout.addWidget(self._toggle_btn) self._b_h_layout.addWidget(self._view) @@ -46,21 +56,15 @@ def setup_ui(self): def toggle_view(self): if self._view.isVisible(): - self.hide_view() + self._toggle_btn.set_direction('down') + self._view.hide() else: - self.show_view() - - def show_view(self): - self._toggle_btn.setText(self.btn_text_hide) - self._view.show() - - def hide_view(self): - self._toggle_btn.setText(self.btn_text_show) - self._view.hide() + self._toggle_btn.set_direction('up') + self._view.show() class LeftPanel(QScrollArea): - def __init__(self, app, parent=None): + def __init__(self, app: 'GuiApp', parent=None): super().__init__(parent) self._app = app @@ -85,7 +89,7 @@ def sizeHint(self): class _LeftPanel(QFrame): - def __init__(self, app, parent=None): + def __init__(self, app: 'GuiApp', parent=None): super().__init__(parent) self._app = app @@ -104,7 +108,7 @@ def __init__(self, app, parent=None): self.playlists_view = PlaylistsView(self) self.my_music_view = MyMusicView(self) - self.collections_view = CollectionsView(self) + self.collections_view = CollectionListView(self._app) self.collections_con = ListViewContainer( self.collections_header, self.collections_view) @@ -115,7 +119,6 @@ def __init__(self, app, parent=None): self.playlists_view.setModel(self._app.pl_uimgr.model) self.my_music_view.setModel(self._app.mymusic_uimgr.model) - self.collections_view.setModel(self._app.coll_uimgr.model) self._layout = QVBoxLayout(self) self._sub_layout = QVBoxLayout() @@ -139,6 +142,7 @@ def __init__(self, app, parent=None): self.my_music_view.setFrameShape(QFrame.NoFrame) self.collections_view.setFrameShape(QFrame.NoFrame) self.setFrameShape(QFrame.NoFrame) + self.collections_con.create_btn.show() # 让各个音乐库来决定是否显示这些组件 self.playlists_con.hide() self.my_music_con.hide() @@ -148,13 +152,49 @@ def __init__(self, app, parent=None): lambda: self._app.browser.goto(page='/recently_played')) self.playlists_view.show_playlist.connect( lambda pl: self._app.browser.goto(model=pl)) - self.collections_view.show_collection.connect(self.show_coll) + self.collections_view.show_collection.connect( + lambda coll: self._app.browser.goto(page=f'/colls/{coll.identifier}')) + self.collections_view.remove_collection.connect(self.remove_coll) + self.collections_con.create_btn.clicked.connect( + self.popup_collection_adding_dialog) + + def popup_collection_adding_dialog(self): + dialog = QDialog(self) + # Set WA_DeleteOnClose so that the dialog can be deleted (from self.children). + dialog.setAttribute(Qt.WA_DeleteOnClose) + layout = QFormLayout(dialog) + id_edit = QLineEdit(dialog) + title_edit = QLineEdit(dialog) + layout.addRow('ID', id_edit) + layout.addRow('标题', title_edit) + button_box = QDialogButtonBox(QDialogButtonBox.Cancel | QDialogButtonBox.Save) + layout.addRow('', button_box) + button_box.accepted.connect(dialog.accept) + button_box.rejected.connect(dialog.reject) + + def create_collection_and_reload(): + fname = id_edit.text() + title = title_edit.text() + try: + self._app.coll_mgr.create(fname, title) + except CollectionAlreadyExists: + QMessageBox.warning(self, '警告', f"收藏集 '{fname}' 已存在") + else: + self._app.coll_mgr.refresh() + + dialog.accepted.connect(create_collection_and_reload) + dialog.open() def show_library(self): - coll_library = self._app.coll_uimgr.get_coll_library() - coll_id = self._app.coll_uimgr.get_coll_id(coll_library) - self._app.browser.goto(page=f'/colls/{coll_id}') - - def show_coll(self, coll): - coll_id = self._app.coll_uimgr.get_coll_id(coll) - self._app.browser.goto(page='/colls/{}'.format(coll_id)) + coll_library = self._app.coll_mgr.get_coll_library() + self._app.browser.goto(page=f'/colls/{coll_library.identifier}') + + def remove_coll(self, coll): + def do(): + self._app.coll_mgr.remove(coll) + self._app.coll_mgr.refresh() + + box = QMessageBox(QMessageBox.Warning, '提示', f"确认删除收藏集 '{coll.name}' 吗?", + QMessageBox.Yes | QMessageBox.No, self) + box.accepted.connect(do) + box.open() diff --git a/feeluown/gui/uimodels/collection.py b/feeluown/gui/uimodels/collection.py deleted file mode 100644 index 9f1e21a16b..0000000000 --- a/feeluown/gui/uimodels/collection.py +++ /dev/null @@ -1,66 +0,0 @@ -""" -本地收藏管理 -~~~~~~~~~~~~~ -""" -from __future__ import annotations -import base64 -from typing import TYPE_CHECKING, Dict - -from feeluown.utils.utils import elfhash -from feeluown.gui.widgets.collections import CollectionsModel -from feeluown.collection import CollectionType, Collection - -if TYPE_CHECKING: - from feeluown.app.gui_app import GuiApp - - -class CollectionUiManager: - def __init__(self, app: GuiApp): - self._app = app - self.model = CollectionsModel(app) - self._id_coll_mapping: Dict[str, Collection] = {} - - def get(self, identifier): - return self._id_coll_mapping.get(identifier, None) - - def get_coll_id(self, coll): - # TODO: 目前还没想好 collection identifier 计算方法,故添加这个函数 - # 现在把 fpath 当作 identifier 使用,但对外透明 - return elfhash(base64.b64encode(bytes(coll.fpath, 'utf-8'))) - - def get_coll_library(self): - for coll in self._id_coll_mapping.values(): - if coll.type == CollectionType.sys_library: - return coll - assert False, "collection 'library' must exists." - - def add(self, collection): - coll_id = self.get_coll_id(collection) - assert coll_id not in self._id_coll_mapping, collection.fpath - self._id_coll_mapping[coll_id] = collection - self.model.add(collection) - - def clear(self): - self._id_coll_mapping.clear() - self.model.clear() - - def initialize(self): - self._scan() - - def refresh(self): - """重新加载本地收藏列表""" - self.model.clear() - self._id_coll_mapping.clear() - self._scan() - - def _scan(self): - colls = [] - library_coll = None - for coll in self._app.coll_mgr.scan(): - if coll.type == CollectionType.sys_library: - library_coll = coll - continue - colls.append(coll) - colls.insert(0, library_coll) - for coll in colls: - self.add(coll) diff --git a/feeluown/gui/widgets/__init__.py b/feeluown/gui/widgets/__init__.py index 573f68fa99..9579b9b97b 100644 --- a/feeluown/gui/widgets/__init__.py +++ b/feeluown/gui/widgets/__init__.py @@ -3,4 +3,5 @@ from .selfpaint_btn import ( # noqa SelfPaintAbstractSquareButton, RecentlyPlayedButton, HomeButton, LeftArrowButton, RightArrowButton, SearchButton, SettingsButton, + PlusButton, TriagleButton ) diff --git a/feeluown/gui/widgets/selfpaint_btn.py b/feeluown/gui/widgets/selfpaint_btn.py index 7cd04acba3..9f4919ef39 100644 --- a/feeluown/gui/widgets/selfpaint_btn.py +++ b/feeluown/gui/widgets/selfpaint_btn.py @@ -2,6 +2,7 @@ from PyQt5.QtWidgets import QPushButton, QStyle, QStyleOptionButton from PyQt5.QtGui import QPainter, QPalette +from feeluown.gui.drawers import HomeIconDrawer, PlusIconDrawer, TriangleIconDrawer from feeluown.gui.helpers import darker_or_lighter @@ -168,6 +169,36 @@ def paintEvent(self, _): painter.drawPoint(QPoint(x, int(self.width() * 0.7))) +class PlusButton(SelfPaintAbstractSquareButton): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.drawer = PlusIconDrawer(self.width(), self._padding) + + def paintEvent(self, _): + painter = QPainter(self) + painter.setRenderHint(QPainter.Antialiasing) + self.paint_round_bg_when_hover(painter) + self.drawer.draw(painter) + + +class TriagleButton(SelfPaintAbstractSquareButton): + def __init__(self, direction='up', *args, **kwargs): + super().__init__(*args, **kwargs) + self.drawer = TriangleIconDrawer(self.width(), + self._padding, + direction=direction) + + def set_direction(self, direction): + self.drawer.set_direction(direction) + + def paintEvent(self, _): + painter = QPainter(self) + painter.setRenderHint(QPainter.Antialiasing) + self.paint_round_bg_when_hover(painter) + self.drawer.draw(painter) + + class RecentlyPlayedButton(SelfPaintAbstractIconTextButton): def __init__(self, *args, **kwargs): super().__init__('最近播放', *args, **kwargs) @@ -194,40 +225,10 @@ def draw_icon(self, painter): painter.drawPoint(QPoint(self._padding, center)) -class HomeIcon: - def __init__(self, length, padding): - icon_length = length - diff = 1 # root/body width diff - h_padding = v_padding = padding - - body_left_x = h_padding + diff*2 - body_right_x = icon_length - h_padding - diff*2 - body_top_x = icon_length // 2 - - self._roof = QPoint(icon_length // 2, v_padding) - self._root_left = QPoint(h_padding, icon_length // 2 + diff) - self._root_right = QPoint(icon_length - h_padding, icon_length // 2 + diff) - - self._body_bottom_left = QPoint(body_left_x, icon_length - v_padding) - self._body_bottom_right = QPoint(body_right_x, icon_length - v_padding) - self._body_top_left = QPoint(body_left_x, body_top_x) - self._body_top_right = QPoint(body_right_x, body_top_x) - - def paint(self, painter): - pen = painter.pen() - pen.setWidthF(1.5) - painter.setPen(pen) - painter.drawLine(self._roof, self._root_left) - painter.drawLine(self._roof, self._root_right) - painter.drawLine(self._body_bottom_left, self._body_bottom_right) - painter.drawLine(self._body_top_left, self._body_bottom_left) - painter.drawLine(self._body_top_right, self._body_bottom_right) - - class HomeButton(SelfPaintAbstractIconTextButton): def __init__(self, *args, **kwargs): super().__init__('主页', *args, **kwargs) - self.home_icon = HomeIcon(self.height(), self._padding) + self.home_icon = HomeIconDrawer(self.height(), self._padding) def draw_icon(self, painter): self.home_icon.paint(painter) @@ -247,3 +248,5 @@ def draw_icon(self, painter): layout.addWidget(SettingsButton(length=length)) layout.addWidget(RecentlyPlayedButton(height=length)) layout.addWidget(HomeButton(height=length)) + + layout.addWidget(TriagleButton(length=length, direction='up')) diff --git a/feeluown/gui/widgets/textlist.py b/feeluown/gui/widgets/textlist.py index 47997a5c0c..73de4d4936 100644 --- a/feeluown/gui/widgets/textlist.py +++ b/feeluown/gui/widgets/textlist.py @@ -95,7 +95,7 @@ class TextlistView(QListView): A TextlistView should have scrollbar by default. """ - def __init__(self, parent): + def __init__(self, parent=None, **kwargs): super().__init__(parent) self.delegate = TextlistDelegate(self) diff --git a/feeluown/uimodels/collection.py b/feeluown/uimodels/collection.py deleted file mode 100644 index 2111d27721..0000000000 --- a/feeluown/uimodels/collection.py +++ /dev/null @@ -1,8 +0,0 @@ -import warnings - -# For backward compat. -from feeluown.gui import CollectionUiManager # noqa - - -warnings.warn('Please import CollectionUiManager from feeluown.gui', - DeprecationWarning, stacklevel=2) diff --git a/tests/app/conftest.py b/tests/app/conftest.py index 8415af32c4..bc4aa44e82 100644 --- a/tests/app/conftest.py +++ b/tests/app/conftest.py @@ -3,7 +3,7 @@ from feeluown.argparser import create_cli_parser from feeluown.app import create_config from feeluown.plugin import PluginsManager -from feeluown.gui.uimodels.collection import CollectionUiManager +from feeluown.collection import CollectionManager from feeluown.utils.dispatch import Signal @@ -40,5 +40,4 @@ def config(): def noharm(mocker): mocker.patch('feeluown.app.app.Player') mocker.patch.object(PluginsManager, 'enable_plugins') - # CollectionUiManager write library.fuo file during initialization. - mocker.patch.object(CollectionUiManager, 'initialize') + mocker.patch.object(CollectionManager, 'scan') diff --git a/tests/app/test_gui_app.py b/tests/app/test_gui_app.py index 099ebb75c0..3d333cc185 100644 --- a/tests/app/test_gui_app.py +++ b/tests/app/test_gui_app.py @@ -1,9 +1,10 @@ -from feeluown.app.gui_app import GuiApp +from feeluown.app.gui_app import GuiApp, CollectionManager def test_gui_app_initialize(qtbot, mocker, args, config, noharm): # TaskManager must be initialized with asyncio. mocker.patch('feeluown.app.app.TaskManager') + mocker.patch.object(CollectionManager, 'scan') app = GuiApp(args, config) qtbot.addWidget(app) diff --git a/tests/entry_points/test_run_app.py b/tests/entry_points/test_run_app.py index 5f536db3d7..682351ae9c 100644 --- a/tests/entry_points/test_run_app.py +++ b/tests/entry_points/test_run_app.py @@ -5,11 +5,11 @@ import pytest from feeluown.argparser import create_cli_parser +from feeluown.collection import CollectionManager from feeluown.entry_points.run_app import run_app, before_start_app, start_app from feeluown.app import App, AppMode, get_app from feeluown.app.cli_app import CliApp from feeluown.plugin import PluginsManager -from feeluown.gui.uimodels.collection import CollectionUiManager @pytest.fixture @@ -32,8 +32,8 @@ def noharm(mocker): mocker.patch('feeluown.entry_points.run_app.ensure_dirs') mocker.patch.object(App, 'dump_state') mocker.patch.object(PluginsManager, 'enable_plugins') - # CollectionUiManager write library.fuo file during initialization. - mocker.patch.object(CollectionUiManager, 'initialize') + # CollectionManager write library.fuo file during scaning. + mocker.patch.object(CollectionManager, 'scan') @pytest.fixture diff --git a/tests/test_collection.py b/tests/test_collection.py index 25a40c86c2..fb48439752 100644 --- a/tests/test_collection.py +++ b/tests/test_collection.py @@ -1,5 +1,5 @@ from feeluown.models.uri import ResolveFailed, ResolverNotFound, reverse -from feeluown.collection import Collection, CollectionManager +from feeluown.collection import Collection, CollectionManager, LIBRARY_FILENAME def test_collection_load(tmp_path, song, mocker): @@ -138,3 +138,31 @@ def test_coll_mgr_generate_library_coll(app_mock, tmp_path): with library_coll_file.open() as f: content = f.read() assert album in content + + +def test_collection_create_empty(tmp_path): + path = tmp_path / 'test.fuo' + Collection.create_empty(path, "Hello World") + + coll = Collection(path) + coll.load() + assert coll.models == [] + assert coll.name == 'Hello World' + + +def test_predefined_collection_should_on_top(tmp_path, app_mock, mocker): + def new_collection(path): + path.touch() + coll = Collection(str(path)) + coll.load() + return coll + + coll1 = new_collection(tmp_path / '1.fuo') + coll2 = new_collection(tmp_path / '2.fuo') + coll_library = new_collection(tmp_path / LIBRARY_FILENAME) + + coll_mgr = CollectionManager(app_mock) + mocker.patch.object(CollectionManager, '_scan', + return_value=[coll1, coll_library, coll2]) + coll_mgr.scan() + assert list(coll_mgr.listall()) == [coll_library, coll1, coll2] diff --git a/tests/uimodels/test_collection.py b/tests/uimodels/test_collection.py deleted file mode 100644 index da74b1e33d..0000000000 --- a/tests/uimodels/test_collection.py +++ /dev/null @@ -1,32 +0,0 @@ -from unittest import mock - -from feeluown.collection import Collection, LIBRARY_FILENAME -from feeluown.gui.uimodels.collection import CollectionUiManager -from feeluown.gui.widgets.collections import CollectionsModel - - -def new_collection(path): - path.touch() - coll = Collection(str(path)) - coll.load() - return coll - - -def test_predefined_collection_should_on_top(tmp_path, app_mock, mocker): - mock_add = mocker.patch.object(CollectionUiManager, 'add') - mock_init = mocker.patch.object(CollectionsModel, '__init__') - mock_init.return_value = None - mgr = CollectionUiManager(app_mock) - - coll1 = new_collection(tmp_path / '1.fuo') - coll2 = new_collection(tmp_path / '2.fuo') - coll_library = new_collection(tmp_path / LIBRARY_FILENAME) - - app_mock.coll_mgr.scan.return_value = [coll1, coll_library, coll2] - mgr.initialize() - - mock_add.assert_has_calls([ - mock.call(coll_library), - mock.call(coll1), - mock.call(coll2), - ])