diff --git a/tidal_dl_ng/gui.py b/tidal_dl_ng/gui.py index aaeaed6..d8c2b4c 100644 --- a/tidal_dl_ng/gui.py +++ b/tidal_dl_ng/gui.py @@ -1,19 +1,21 @@ import math import sys import time -from collections.abc import Callable +from collections.abc import Callable, Sequence +from PySide6.QtGui import QStandardItem from requests.exceptions import HTTPError from tidal_dl_ng import __version__, update_available from tidal_dl_ng.dialog import DialogLogin, DialogPreferences, DialogVersion from tidal_dl_ng.helper.gui import ( + FilterHeader, + HumanProxyModel, get_queue_download_media, get_queue_download_quality, get_results_media_item, get_user_list_media_item, set_queue_download_media, - set_results_media, set_user_list_media, ) from tidal_dl_ng.helper.path import get_format_template, resource_path @@ -64,6 +66,8 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): tray: QtWidgets.QSystemTrayIcon spinner: QtWaitingSpinner cover_url_current: str = "" + model_tr_results: QtGui.QStandardItemModel = QtGui.QStandardItemModel() + proxy_tr_results: HumanProxyModel s_spinner_start: QtCore.Signal = QtCore.Signal(QtWidgets.QWidget) s_spinner_stop: QtCore.Signal = QtCore.Signal() pb_item: QtWidgets.QProgressBar @@ -98,7 +102,8 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self.settings = Settings() self._init_threads() - self._init_tree_results(self.tr_results) + self._init_tree_results_model(self.model_tr_results) + self._init_tree_results(self.tr_results, self.model_tr_results) self._init_tree_lists(self.tr_lists_user) self._init_tree_queue(self.tr_queue_download) self._init_info() @@ -213,7 +218,30 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self.cb_search_type.setCurrentIndex(2) - def _init_tree_results(self, tree: QtWidgets.QTableWidget): + def handle_filter_activated(self, tree: QtWidgets.QTreeView): + header = tree.header() + filters = [] + + for i in range(header.count()): + text: str = header.filterText(i) + + if text: + filters.append((i, text)) + + proxy = tree.model() + proxy.filters = filters + + def _init_tree_results(self, tree: QtWidgets.QTreeView, model: QtGui.QStandardItemModel) -> None: + header = FilterHeader(tree) + self.proxy_tr_results = HumanProxyModel(self) + + tree.setHeader(header) + tree.setModel(model) + self.proxy_tr_results.setSourceModel(model) + tree.setModel(self.proxy_tr_results) + header.set_filter_boxes(model.columnCount()) + header.filter_activated.connect(lambda: self.handle_filter_activated(tree)) + ## tree.sortByColumn(0, QtCore.Qt.SortOrder.AscendingOrder) tree.setColumnHidden(1, True) tree.setColumnWidth(2, 150) @@ -224,6 +252,11 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): header.setSectionResizeMode(0, QtWidgets.QHeaderView.ResizeToContents) + def _init_tree_results_model(self, model: QtGui.QStandardItemModel) -> None: + model.setColumnCount(7) + model.setRowCount(0) + model.setHorizontalHeaderLabels(["#", "obj", "Artist", "Title", "Album", "Duration", "Quality"]) + def _init_tree_queue(self, tree: QtWidgets.QTableWidget): tree.setColumnHidden(1, True) tree.setColumnWidth(2, 200) @@ -357,7 +390,7 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self.queue_download_media(queue_dl_item) def search_populate_results(self, query: str, type_media: SearchTypes): - self.tr_results.clear() + self.model_tr_results.removeRows(0, self.model_tr_results.rowCount()) results: [ResultItem] = self.search(query, [type_media]) @@ -365,20 +398,22 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): def populate_tree_results(self, results: [ResultItem], parent: QtWidgets.QTreeWidgetItem = None): if not parent: - self.tr_results.clear() + self.model_tr_results.removeRows(0, self.model_tr_results.rowCount()) # Count how many digits the list length has, count_digits: int = int(math.log10(len(results) if results else 1)) + 1 for item in results: - child = self.populate_tree_result_child(item=item, index_count_digits=count_digits) + child: tuple = self.populate_tree_result_child(item=item, index_count_digits=count_digits) if parent: parent.addChild(child) else: self.s_tr_results_add_top_level_item.emit(child) - def populate_tree_result_child(self, item: [Track | Video | Mix | Album | Playlist], index_count_digits: int): + def populate_tree_result_child( + self, item: [Track | Video | Mix | Album | Playlist], index_count_digits: int + ) -> Sequence[QStandardItem]: duration: str = "" # TODO: Duration needs to be calculated later to properly fill with zeros. @@ -391,26 +426,30 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): index: str = str(item.position + 1).zfill(index_count_digits) # Populate child - child: QtWidgets.QTreeWidgetItem = QtWidgets.QTreeWidgetItem() - child.setText(0, index) - set_results_media(child, item.obj) - child.setText(2, item.artist) - child.setText(3, item.title) - child.setText(4, item.album) - child.setText(5, duration) - child.setText(6, item.quality) + child_index: QtGui.QStandardItem = QtGui.QStandardItem(index) + # TODO: Move to own method + child_obj: QtGui.QStandardItem = QtGui.QStandardItem() + + child_obj.setData(item.obj, QtCore.Qt.ItemDataRole.UserRole) + # set_results_media(child, item.obj) + + child_artist: QtGui.QStandardItem = QtGui.QStandardItem(item.artist) + child_title: QtGui.QStandardItem = QtGui.QStandardItem(item.title) + child_album: QtGui.QStandardItem = QtGui.QStandardItem(item.album) + child_duration: QtGui.QStandardItem = QtGui.QStandardItem(duration) + child_quality: QtGui.QStandardItem = QtGui.QStandardItem(item.quality) if isinstance(item.obj, Mix | Playlist | Album | Artist): # Add a disabled dummy child, so expansion arrow will appear. This Child will be replaced on expansion. - child_dummy: QtWidgets.QTreeWidgetItem = QtWidgets.QTreeWidgetItem() + child_dummy: QtGui.QStandardItem = QtGui.QStandardItem() - child_dummy.setDisabled(True) - child.addChild(child_dummy) + child_dummy.setEnabled(False) + child_index.appendRow(child_dummy) - return child + return child_index, child_obj, child_artist, child_title, child_album, child_duration, child_quality - def on_tr_results_add_top_level_item(self, widget_item: QtWidgets.QTreeWidgetItem): - self.tr_results.addTopLevelItem(widget_item) + def on_tr_results_add_top_level_item(self, item_child: Sequence[QtGui.QStandardItem]): + self.model_tr_results.appendRow(item_child) def on_settings_save(self): self.settings.save() @@ -625,11 +664,11 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self.a_version.triggered.connect(self.on_version) self.a_preferences.triggered.connect(self.on_preferences) self.a_logout.triggered.connect(self.on_logout) - self.a_updates_check.triggered.connect(lambda x: self.on_update_check(False)) + self.a_updates_check.triggered.connect(lambda: self.on_update_check(False)) # Results - self.tr_results.itemExpanded.connect(self.on_tr_results_expanded) - self.tr_results.itemClicked.connect(self.on_result_item_clicked) + self.tr_results.expanded.connect(self.on_tr_results_expanded) + self.tr_results.clicked.connect(self.on_result_item_clicked) # Download Queue self.tr_queue_download.itemClicked.connect(self.on_queue_download_item_clicked) @@ -678,8 +717,10 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self.list_items_show_result(media_list) self.cover_show(media_list) - def on_result_item_clicked(self, item: QtWidgets.QTreeWidgetItem, column: int) -> None: - media: Track | Video | Album | Artist = get_results_media_item(item) + def on_result_item_clicked(self, index: QtCore.QModelIndex) -> None: + media: Track | Video | Album | Artist = get_results_media_item( + index, self.proxy_tr_results, self.model_tr_results + ) self.cover_show(media) diff --git a/tidal_dl_ng/helper/gui.py b/tidal_dl_ng/helper/gui.py index 610be80..834d9b6 100644 --- a/tidal_dl_ng/helper/gui.py +++ b/tidal_dl_ng/helper/gui.py @@ -1,8 +1,12 @@ -from PySide6 import QtCore, QtWidgets +import re + +from PySide6 import QtCore, QtGui, QtWidgets from tidalapi import Album, Mix, Playlist, Track, UserPlaylist, Video from tidalapi.artist import Artist from tidalapi.media import Quality +from tidal_dl_ng.helper.gui import HumanProxyModel + def get_table_data( item: QtWidgets.QTreeWidgetItem, column: int @@ -18,8 +22,12 @@ def get_table_text(item: QtWidgets.QTreeWidgetItem, column: int) -> str: return result -def get_results_media_item(item: QtWidgets.QTreeWidgetItem) -> Track | Video | Album | Artist | Playlist | Mix: - result: Track | Video | Album | Artist | Playlist | Mix = get_table_data(item, 1) +def get_results_media_item( + index: QtCore.QModelIndex, proxy: HumanProxyModel, model: QtGui.QStandardItemModel +) -> Track | Video | Album | Artist | Playlist | Mix: + # Switch column to "obj" column and map proxy data to our model. + item: QtGui.QStandardItem = model.itemFromIndex(proxy.mapToSource(index.siblingAtColumn(1))) + result: Track | Video | Album | Artist = item.data(QtCore.Qt.ItemDataRole.UserRole) return result @@ -66,3 +74,113 @@ def set_queue_download_media( item: QtWidgets.QTreeWidgetItem, media: Mix | Playlist | UserPlaylist | Track | Video | Album | Artist ): set_table_data(item, media, 1) + + +class FilterHeader(QtWidgets.QHeaderView): + filter_activated = QtCore.Signal() + + def __init__(self, parent): + super().__init__(QtCore.Qt.Horizontal, parent) + self._editors = [] + self._padding = 4 + self.setStretchLastSection(True) + self.setSectionResizeMode(QtWidgets.QHeaderView.Stretch) + self.setDefaultAlignment(QtCore.Qt.AlignLeft | QtCore.Qt.AlignVCenter) + self.setSortIndicatorShown(False) + self.setSectionsMovable(True) + self.sectionResized.connect(self.adjust_positions) + parent.horizontalScrollBar().valueChanged.connect(self.adjust_positions) + + def set_filter_boxes(self, count): + while self._editors: + editor = self._editors.pop() + editor.deleteLater() + + for _ in range(count): + editor = QtWidgets.QLineEdit(self.parent()) + editor.setPlaceholderText("Filter") + editor.setClearButtonEnabled(True) + editor.returnPressed.connect(self.filter_activated.emit) + self._editors.append(editor) + + self.adjust_positions() + + def sizeHint(self): + size = super().sizeHint() + if self._editors: + height = self._editors[0].sizeHint().height() + size.setHeight(size.height() + height + self._padding) + + return size + + def updateGeometries(self): + if self._editors: + height = self._editors[0].sizeHint().height() + self.setViewportMargins(0, 0, 0, height + self._padding) + else: + self.setViewportMargins(0, 0, 0, 0) + + super().updateGeometries() + self.adjust_positions() + + def adjust_positions(self): + for index, editor in enumerate(self._editors): + height = editor.sizeHint().height() + + editor.move(self.sectionPosition(index) - self.offset() + 2, height + (self._padding // 2)) + editor.resize(self.sectionSize(index), height) + + def filter_text(self, index) -> str: + if 0 <= index < len(self._editors): + return self._editors[index].text() + + return "" + + def set_filter_text(self, index, text): + if 0 <= index < len(self._editors): + self._editors[index].setText(text) + + def clear_filters(self): + for editor in self._editors: + editor.clear() + + +class HumanProxyModel(QtCore.QSortFilterProxyModel): + def _human_key(self, key): + parts = re.split(r"(\d*\.\d+|\d+)", key) + + return tuple((e.swapcase() if i % 2 == 0 else float(e)) for i, e in enumerate(parts)) + + def lessThan(self, source_left, source_right): + data_left = source_left.data() + data_right = source_right.data() + + if isinstance(data_left, str) and isinstance(data_right, str): + return self._human_key(data_left) < self._human_key(data_right) + + return super().lessThan(source_left, source_right) + + @property + def filters(self): + if not hasattr(self, "_filters"): + self._filters = [] + + return self._filters + + @filters.setter + def filters(self, filters): + print("filters() called.") + + self._filters = filters + + self.invalidateFilter() + + def filterAcceptsRow(self, sourceRow, sourceParent) -> bool: + for i, text in self.filters: + if 0 <= i < self.columnCount(): + ix = self.sourceModel().index(sourceRow, i, sourceParent) + data = ix.data() + if str(data) in text: + return False + + return True diff --git a/tidal_dl_ng/ui/main.py b/tidal_dl_ng/ui/main.py index 0a999c8..9c1525d 100644 --- a/tidal_dl_ng/ui/main.py +++ b/tidal_dl_ng/ui/main.py @@ -1,7 +1,7 @@ ################################################################################ ## Form generated from reading UI file 'main.ui' ## -## Created by: Qt User Interface Compiler version 6.6.2 +## Created by: Qt User Interface Compiler version 6.7.0 ## ## WARNING! All changes made in this file will be lost when recompiling UI file! ################################################################################ @@ -21,6 +21,7 @@ from PySide6.QtWidgets import ( QPushButton, QSizePolicy, QStatusBar, + QTreeView, QTreeWidget, QTreeWidgetItem, QVBoxLayout, @@ -223,7 +224,7 @@ class Ui_MainWindow: self.lv_search_result.addLayout(self.lh_search) - self.tr_results = QTreeWidget(self.w_central) + self.tr_results = QTreeView(self.w_central) self.tr_results.setObjectName("tr_results") self.tr_results.setEditTriggers(QAbstractItemView.NoEditTriggers) self.tr_results.setProperty("showDropIndicator", False) @@ -232,8 +233,6 @@ class Ui_MainWindow: self.tr_results.setSelectionMode(QAbstractItemView.ExtendedSelection) self.tr_results.setIndentation(10) self.tr_results.setSortingEnabled(True) - self.tr_results.header().setProperty("showSortIndicator", True) - self.tr_results.header().setStretchLastSection(False) self.lv_search_result.addWidget(self.tr_results) @@ -572,22 +571,14 @@ class Ui_MainWindow: self.pb_reload_user_lists.setText(QCoreApplication.translate("MainWindow", "Reload", None)) self.pb_download_list.setText(QCoreApplication.translate("MainWindow", "Download List", None)) - ___qtreewidgetitem4 = self.tr_results.headerItem() - ___qtreewidgetitem4.setText(6, QCoreApplication.translate("MainWindow", "Quality", None)) - ___qtreewidgetitem4.setText(5, QCoreApplication.translate("MainWindow", "Duration", None)) - ___qtreewidgetitem4.setText(4, QCoreApplication.translate("MainWindow", "Album", None)) - ___qtreewidgetitem4.setText(3, QCoreApplication.translate("MainWindow", "Title", None)) - ___qtreewidgetitem4.setText(2, QCoreApplication.translate("MainWindow", "Artist", None)) - ___qtreewidgetitem4.setText(1, QCoreApplication.translate("MainWindow", "obj", None)) - ___qtreewidgetitem4.setText(0, QCoreApplication.translate("MainWindow", "#", None)) self.te_debug.setPlaceholderText(QCoreApplication.translate("MainWindow", "Logs...", None)) self.l_pm_cover.setText("") self.l_h_queue_download.setText(QCoreApplication.translate("MainWindow", "Download Queue", None)) - ___qtreewidgetitem5 = self.tr_queue_download.headerItem() - ___qtreewidgetitem5.setText(4, QCoreApplication.translate("MainWindow", "Quality", None)) - ___qtreewidgetitem5.setText(3, QCoreApplication.translate("MainWindow", "Type", None)) - ___qtreewidgetitem5.setText(2, QCoreApplication.translate("MainWindow", "Name", None)) - ___qtreewidgetitem5.setText(1, QCoreApplication.translate("MainWindow", "obj", None)) + ___qtreewidgetitem4 = self.tr_queue_download.headerItem() + ___qtreewidgetitem4.setText(4, QCoreApplication.translate("MainWindow", "Quality", None)) + ___qtreewidgetitem4.setText(3, QCoreApplication.translate("MainWindow", "Type", None)) + ___qtreewidgetitem4.setText(2, QCoreApplication.translate("MainWindow", "Name", None)) + ___qtreewidgetitem4.setText(1, QCoreApplication.translate("MainWindow", "obj", None)) self.pb_queue_download_remove.setText(QCoreApplication.translate("MainWindow", "Remove", None)) self.pb_queue_download_clear_finished.setText(QCoreApplication.translate("MainWindow", "Clear Finished", None)) self.pb_queue_download_clear_all.setText(QCoreApplication.translate("MainWindow", "Clear All", None)) diff --git a/tidal_dl_ng/ui/main.ui b/tidal_dl_ng/ui/main.ui index a62d77b..0ad3693 100644 --- a/tidal_dl_ng/ui/main.ui +++ b/tidal_dl_ng/ui/main.ui @@ -294,7 +294,7 @@ - + QAbstractItemView::NoEditTriggers @@ -316,47 +316,6 @@ true - - true - - - false - - - - # - - - - - obj - - - - - Artist - - - - - Title - - - - - Album - - - - - Duration - - - - - Quality - -