From 480c8328528379ac4658f44c6a82083de5313c15 Mon Sep 17 00:00:00 2001 From: Keith Edmunds Date: Sun, 26 Nov 2023 15:22:01 +0000 Subject: [PATCH] WIP V3: implement searching with QSortFilterProxyModel (ooo!) --- app/musicmuster.py | 19 ++-- app/playlistmodel.py | 149 ++++++++++++++++++++++++---- app/playlists.py | 205 +++++++++++++++++++++++++++------------ app/ui/main_window.ui | 2 - app/ui/main_window_ui.py | 4 +- 5 files changed, 279 insertions(+), 100 deletions(-) diff --git a/app/musicmuster.py b/app/musicmuster.py index 89cd847..c7296f6 100755 --- a/app/musicmuster.py +++ b/app/musicmuster.py @@ -6,7 +6,6 @@ from typing import ( cast, List, Optional, - Sequence, ) from os.path import basename @@ -49,7 +48,6 @@ from PyQt6.QtWidgets import ( QProgressBar, QPushButton, ) -from sqlalchemy import text import stackprinter # type: ignore from classes import ( @@ -511,12 +509,6 @@ class Window(QMainWindow, Ui_MainWindow): self.actionEnable_controls.triggered.connect(self.enable_play_next_controls) self.actionExport_playlist.triggered.connect(self.export_playlist_tab) self.actionFade.triggered.connect(self.fade) - self.actionFind_next.triggered.connect( - lambda: self.tabPlaylist.currentWidget().search_next() - ) - self.actionFind_previous.triggered.connect( - lambda: self.tabPlaylist.currentWidget().search_previous() - ) self.actionImport.triggered.connect(self.import_track) self.actionInsertSectionHeader.triggered.connect(self.insert_header) self.actionInsertTrack.triggered.connect(self.insert_track) @@ -555,7 +547,7 @@ class Window(QMainWindow, Ui_MainWindow): self.hdrNextTrack.clicked.connect(self.show_next) self.tabPlaylist.tabCloseRequested.connect(self.close_tab) self.tabBar = self.tabPlaylist.tabBar() - self.txtSearch.returnPressed.connect(self.search_playlist_return) + self.txtSearch.textChanged.connect(self.search_playlist_text_changed) self.signals.enable_escape_signal.connect(self.enable_escape) self.signals.next_track_changed_signal.connect(self.update_headers) @@ -870,7 +862,7 @@ class Window(QMainWindow, Ui_MainWindow): ok = dlg.exec() if ok: model.insert_row( - proposed_row_number=self.active_tab().get_selected_row_number(), + proposed_row_number=self.active_tab().selected_model_row_number(), note=dlg.textValue(), ) @@ -1249,6 +1241,13 @@ class Window(QMainWindow, Ui_MainWindow): self.active_tab().set_search(self.txtSearch.text()) self.enable_play_next_controls() + def search_playlist_text_changed(self) -> None: + """ + Incremental search of playlist + """ + + self.active_model().set_incremental_search(self.txtSearch.text()) + def select_next_row(self) -> None: """Select next or first row in playlist""" diff --git a/app/playlistmodel.py b/app/playlistmodel.py index c38aaa5..7fc535f 100644 --- a/app/playlistmodel.py +++ b/app/playlistmodel.py @@ -3,11 +3,12 @@ from datetime import datetime, timedelta from enum import auto, Enum from operator import attrgetter from pprint import pprint -from typing import List, Optional +from typing import cast, List, Optional from PyQt6.QtCore import ( QAbstractTableModel, QModelIndex, + QRegularExpression, QSortFilterProxyModel, Qt, QVariant, @@ -24,10 +25,8 @@ from dbconfig import scoped_session, Session from helpers import ( file_is_unreadable, get_embedded_time, - get_file_metadata, get_relative_date, open_in_audacity, - normalise_track, ms_to_mmss, set_track_metadata, ) @@ -95,23 +94,6 @@ class StartEndTimes: end_time: Optional[datetime] = None -class PlaylistProxyModel(QSortFilterProxyModel): - """ - For searching and filtering - """ - - def __init__( - self, - playlist_id: int, - *args, - **kwargs, - ): - self.playlist_id = playlist_id - super().__init__(*args, **kwargs) - - self.setSourceModel(PlaylistModel(playlist_id)) - - class PlaylistModel(QAbstractTableModel): """ The Playlist Model @@ -1002,7 +984,9 @@ class PlaylistModel(QAbstractTableModel): if now_plr: track_sequence.now.plr_rownum = now_plr.plr_rownum if track_sequence.previous.plr_rownum: - previous_plr = session.get(PlaylistRows, track_sequence.previous.plr_rownum) + previous_plr = session.get( + PlaylistRows, track_sequence.previous.plr_rownum + ) if previous_plr: track_sequence.previous.plr_rownum = previous_plr.plr_rownum @@ -1243,3 +1227,126 @@ class PlaylistModel(QAbstractTableModel): self.index(updated_row, Col.START_TIME.value), self.index(updated_row, Col.END_TIME.value), ) + + +class PlaylistProxyModel(QSortFilterProxyModel): + """ + For searching and filtering + """ + + def __init__( + self, + playlist_model: PlaylistModel, + *args, + **kwargs, + ): + self.playlist_model = playlist_model + super().__init__(*args, **kwargs) + + self.setSourceModel(playlist_model) + # Search all columns + self.setFilterKeyColumn(-1) + + def set_incremental_search(self, search_string: str) -> None: + """ + Update search pattern + """ + + self.setFilterRegularExpression( + QRegularExpression( + search_string, QRegularExpression.PatternOption.CaseInsensitiveOption + ) + ) + + # ###################################### + # Forward functions not handled in proxy + # ###################################### + + def delete_rows(self, row_numbers: List[int]) -> None: + model = cast(PlaylistModel, self.sourceModel()) + return model.delete_rows(row_numbers) + + def get_duplicate_rows(self) -> List[int]: + model = cast(PlaylistModel, self.sourceModel()) + return model.get_duplicate_rows() + + def get_rows_duration(self, row_numbers: List[int]) -> int: + model = cast(PlaylistModel, self.sourceModel()) + return model.get_rows_duration(row_numbers) + + def get_row_info(self, row_number: int) -> PlaylistRowData: + model = cast(PlaylistModel, self.sourceModel()) + return model.get_row_info(row_number) + + def get_row_track_path(self, row_number: int) -> str: + model = cast(PlaylistModel, self.sourceModel()) + return model.get_row_track_path(row_number) + + def insert_row( + self, + proposed_row_number: Optional[int], + track_id: Optional[int] = None, + note: Optional[str] = None, + ) -> None: + model = cast(PlaylistModel, self.sourceModel()) + return model.insert_row(proposed_row_number, track_id, note) + + def is_header_row(self, row_number: int) -> bool: + model = cast(PlaylistModel, self.sourceModel()) + return model.is_header_row(row_number) + + def is_unplayed_row(self, row_number: int) -> bool: + model = cast(PlaylistModel, self.sourceModel()) + return model.is_unplayed_row(row_number) + + def mark_unplayed(self, row_numbers: List[int]) -> None: + model = cast(PlaylistModel, self.sourceModel()) + return model.mark_unplayed(row_numbers) + + def move_rows(self, from_rows: List[int], to_row_number: int) -> None: + model = cast(PlaylistModel, self.sourceModel()) + return model.move_rows(from_rows, to_row_number) + + def move_rows_between_playlists( + self, from_rows: List[int], to_row_number: int, to_playlist_id: int + ) -> None: + model = cast(PlaylistModel, self.sourceModel()) + return model.move_rows_between_playlists( + from_rows, to_row_number, to_playlist_id + ) + + def open_in_audacity(self, row_number: int) -> None: + model = cast(PlaylistModel, self.sourceModel()) + return model.open_in_audacity(row_number) + + def remove_track(self, row_number: int) -> None: + model = cast(PlaylistModel, self.sourceModel()) + return model.remove_track(row_number) + + def rescan_track(self, row_number: int) -> None: + model = cast(PlaylistModel, self.sourceModel()) + return model.rescan_track(row_number) + + def set_next_row(self, row_number: Optional[int]) -> None: + model = cast(PlaylistModel, self.sourceModel()) + return model.set_next_row(row_number) + + def sort_by_artist(self, row_numbers: List[int]) -> None: + model = cast(PlaylistModel, self.sourceModel()) + return model.sort_by_artist(row_numbers) + + def sort_by_duration(self, row_numbers: List[int]) -> None: + model = cast(PlaylistModel, self.sourceModel()) + return model.sort_by_duration(row_numbers) + + def sort_by_lastplayed(self, row_numbers: List[int]) -> None: + model = cast(PlaylistModel, self.sourceModel()) + return model.sort_by_lastplayed(row_numbers) + + def sort_by_title(self, row_numbers: List[int]) -> None: + model = cast(PlaylistModel, self.sourceModel()) + return model.sort_by_title(row_numbers) + + def update_track_times(self) -> None: + model = cast(PlaylistModel, self.sourceModel()) + return model.update_track_times() diff --git a/app/playlists.py b/app/playlists.py index cccc1c5..4aa11f9 100644 --- a/app/playlists.py +++ b/app/playlists.py @@ -1,24 +1,21 @@ -import os -import re -import stackprinter # type: ignore import subprocess -import threading import obsws_python as obs # type: ignore from datetime import datetime, timedelta from pprint import pprint -from typing import Any, Callable, cast, List, Optional, Tuple, TYPE_CHECKING +from typing import Callable, cast, List, Optional, TYPE_CHECKING from PyQt6.QtCore import ( QEvent, QModelIndex, QObject, QItemSelection, + QItemSelectionModel, Qt, # QTimer, ) -from PyQt6.QtGui import QAction, QBrush, QColor, QFont, QDropEvent, QKeyEvent +from PyQt6.QtGui import QAction, QDropEvent, QKeyEvent from PyQt6.QtWidgets import ( QAbstractItemDelegate, QAbstractItemView, @@ -49,6 +46,7 @@ from helpers import ( open_in_audacity, send_mail, set_track_metadata, + show_warning, ) from log import log from models import PlaylistRows, Settings, Tracks, NoteColours @@ -67,8 +65,9 @@ class EscapeDelegate(QStyledItemDelegate): - checks with user before abandoning edit on Escape """ - def __init__(self, parent) -> None: + def __init__(self, parent, playlist_model: PlaylistModel) -> None: super().__init__(parent) + self.playlist_model = playlist_model self.signals = MusicMusterSignals() def createEditor( @@ -123,12 +122,24 @@ class EscapeDelegate(QStyledItemDelegate): return False def setEditorData(self, editor, index): - value = index.model().data(index, Qt.ItemDataRole.EditRole) + model = index.model() + if hasattr(model, "mapToSource"): + edit_index = model.mapToSource(index) + else: + edit_index = index + + value = self.playlist_model.data(edit_index, Qt.ItemDataRole.EditRole) editor.setPlainText(value.value()) def setModelData(self, editor, model, index): + model = index.model() + if hasattr(model, "mapToSource"): + edit_index = model.mapToSource(index) + else: + edit_index = index + value = editor.toPlainText() - model.setData(index, value, Qt.ItemDataRole.EditRole) + self.playlist_model.setData(edit_index, value, Qt.ItemDataRole.EditRole) def updateEditorGeometry(self, editor, option, index): editor.setGeometry(option.rect) @@ -165,10 +176,10 @@ class PlaylistTab(QTableView): self.playlist_id = playlist_id # Set up widget - self.setItemDelegate(EscapeDelegate(self)) + self.playlist_model = PlaylistModel(playlist_id) + self.proxy_model = PlaylistProxyModel(self.playlist_model) + self.setItemDelegate(EscapeDelegate(self, self.playlist_model)) self.setAlternatingRowColors(True) - self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) - self.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) # self.setEditTriggers(QAbstractItemView.EditTrigger.DoubleClicked) self.setVerticalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel) self.setDragDropMode(QAbstractItemView.DragDropMode.InternalMove) @@ -202,8 +213,12 @@ class PlaylistTab(QTableView): self.sort_undo: List[int] = [] # self.edit_cell_type: Optional[int] + # Selection model + self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) + self.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) + # Load playlist rows - self.setModel(PlaylistProxyModel(playlist_id)) + self.setModel(self.proxy_model) self._set_column_widths() def closeEditor( @@ -224,8 +239,7 @@ class PlaylistTab(QTableView): # Update start times in case a start time in a note has been # edited - model = cast(PlaylistModel, self.model()) - model.update_track_times() + self.playlist_model.update_track_times() def dropEvent(self, event): if event.source() is not self or ( @@ -234,7 +248,7 @@ class PlaylistTab(QTableView): ): super().dropEvent(event) - from_rows = list(set([a.row() for a in self.selectedIndexes()])) + from_rows = self.selected_model_row_numbers() to_row = self.indexAt(event.position().toPoint()).row() if ( 0 <= min(from_rows) <= self.model().rowCount() @@ -311,17 +325,76 @@ class PlaylistTab(QTableView): self.clearSelection() self.setDragEnabled(False) - def get_selected_row_number(self) -> Optional[int]: + def selected_display_row_number(self): """ Return the selected row number or None if none selected. """ + row_index = self._selected_row_index() + if row_index: + return row_index.row() + else: + return None + return row_index.row() + + def selected_display_row_numbers(self): + """ + Return a list of the selected row numbers + """ + + indexes = self._selected_row_indexes() + + return [a.row() for a in indexes] + + def selected_model_row_number(self) -> Optional[int]: + """ + Return the model row number corresponding to the selected row or None + """ + + selected_index = self._selected_row_index() + if selected_index is None: + return None + if hasattr(self.proxy_model, "mapToSource"): + return self.proxy_model.mapToSource(selected_index).row() + return selected_index.row() + + def selected_model_row_numbers(self) -> Optional[List[int]]: + """ + Return a list of model row numbers corresponding to the selected rows or + an empty list. + """ + + selected_indexes = self._selected_row_indexes() + if selected_indexes is None: + return None + if hasattr(self.proxy_model, "mapToSource"): + return [self.proxy_model.mapToSource(a).row() for a in selected_indexes] + return [a.row() for a in selected_indexes] + + def _selected_row_index(self) -> Optional[QModelIndex]: + """ + Return the selected row index or None if none selected. + """ + + row_indexes = self._selected_row_indexes() + + if len(row_indexes) != 1: + show_warning( + self.musicmuster, "No or multiple rows selected", "Select only one row" + ) + return None + + return row_indexes[0] + + def _selected_row_indexes(self) -> List[QModelIndex]: + """ + Return a list of indexes of column 1 of selected rows + """ + sm = self.selectionModel() if sm and sm.hasSelection(): - index = sm.currentIndex() - if index.isValid(): - return index.row() - return None + return sm.selectedRows() + return [] def get_selected_row_track_path(self) -> str: """ @@ -329,13 +402,10 @@ class PlaylistTab(QTableView): row does not have a track, return empty string. """ - sm = self.selectionModel() - if sm and sm.hasSelection(): - index = sm.currentIndex() - if index.isValid(): - model = cast(PlaylistModel, self.model()) - return model.get_row_track_path(index.row()) - return "" + model_row_number = self.selected_model_row_number() + if model_row_number is None: + return "" + return self.playlist_model.get_row_track_path(model_row_number) # def lookup_row_in_songfacts(self) -> None: # """ @@ -473,11 +543,10 @@ class PlaylistTab(QTableView): Set selected row as next track """ - selected_row = self.get_selected_row_number() - if selected_row is None: + model_row_number = self.selected_model_row_number() + if model_row_number is None: return - model = cast(PlaylistModel, self.model()) - model.set_next_row(selected_row) + self.playlist_model.set_next_row(model_row_number) self.clearSelection() # # # ########## Internally called functions ########## @@ -485,14 +554,14 @@ class PlaylistTab(QTableView): def _add_track(self) -> None: """Add a track to a section header making it a normal track row""" - row_number = self.get_selected_row_number() - if not row_number: + model_row_number = self.selected_model_row_number() + if model_row_number is None: return with Session() as session: dlg = TrackSelectDialog( session=session, - new_row_number=row_number, + new_row_number=model_row_number, playlist_id=self.playlist_id, add_to_header=True, ) @@ -502,25 +571,31 @@ class PlaylistTab(QTableView): """Used to process context (right-click) menu, which is defined here""" self.menu.clear() - model = cast(PlaylistModel, self.model()) - if not model: - return + model = self.proxy_model - row_number = item.row() - header_row = model.is_header_row(row_number) + display_row_number = item.row() + if hasattr(model, "mapToSource"): + index = model.index(item.row(), item.column()) + model_row_number = model.mapToSource(index).row() + else: + model_row_number = display_row_number + + header_row = model.is_header_row(model_row_number) track_row = not header_row - current_row = row_number == track_sequence.now.plr_rownum - next_row = row_number == track_sequence.next.plr_rownum + current_row = model_row_number == track_sequence.now.plr_rownum + next_row = model_row_number == track_sequence.next.plr_rownum # Open in Audacity if track_row and not current_row: self._add_context_menu( - "Open in Audacity", lambda: model.open_in_audacity(row_number) + "Open in Audacity", lambda: model.open_in_audacity(model_row_number) ) # Rescan if track_row and not current_row: - self._add_context_menu("Rescan track", lambda: self._rescan(row_number)) + self._add_context_menu( + "Rescan track", lambda: self._rescan(model_row_number) + ) # ---------------------- self.menu.addSeparator() @@ -532,7 +607,7 @@ class PlaylistTab(QTableView): # Remove track from row if track_row and not current_row and not next_row: self._add_context_menu( - "Remove track from row", lambda: model.remove_track(row_number) + "Remove track from row", lambda: model.remove_track(model_row_number) ) # Add track to section header (ie, make this a track row) @@ -543,7 +618,7 @@ class PlaylistTab(QTableView): self.menu.addSeparator() # Mark unplayed - if track_row and model.is_unplayed_row(row_number): + if track_row and model.is_unplayed_row(model_row_number): self._add_context_menu( "Mark unplayed", lambda: self._mark_as_unplayed(self.get_selected_rows()), @@ -583,12 +658,12 @@ class PlaylistTab(QTableView): # Info if track_row: - self._add_context_menu("Info", lambda: self._info_row(row_number)) + self._add_context_menu("Info", lambda: self._info_row(model_row_number)) # Track path TODO if track_row: self._add_context_menu( - "Copy track path", lambda: self._copy_path(row_number) + "Copy track path", lambda: self._copy_path(model_row_number) ) def _calculate_end_time( @@ -631,8 +706,7 @@ class PlaylistTab(QTableView): to the clipboard. Otherwise, return None. """ - model = cast(PlaylistModel, self.model()) - track_path = model.get_row_info(row_number).path + track_path = self.playlist_model.get_row_info(row_number).path if not track_path: return @@ -669,8 +743,7 @@ class PlaylistTab(QTableView): if not ask_yes_no("Delete rows", f"Really delete {row_count} row{plural}?"): return - model = cast(PlaylistModel, self.model()) - model.delete_rows(self.get_selected_rows()) + self.playlist_model.delete_rows(self.selected_model_row_numbers()) def get_selected_rows(self) -> List[int]: """Return a list of selected row numbers sorted by row""" @@ -682,8 +755,7 @@ class PlaylistTab(QTableView): def _info_row(self, row_number: int) -> None: """Display popup with info re row""" - model = cast(PlaylistModel, self.model()) - prd = model.get_row_info(row_number) + prd = self.playlist_model.get_row_info(row_number) if prd: txt = ( f"Title: {prd.title}\n" @@ -739,8 +811,7 @@ class PlaylistTab(QTableView): def _mark_as_unplayed(self, row_numbers: List[int]) -> None: """Rescan track""" - model = cast(PlaylistModel, self.model()) - model.mark_unplayed(row_numbers) + self.playlist_model.mark_unplayed(row_numbers) self.clear_selection() def _obs_change_scene(self, current_row: int) -> None: @@ -786,8 +857,7 @@ class PlaylistTab(QTableView): def _rescan(self, row_number: int) -> None: """Rescan track""" - model = cast(PlaylistModel, self.model()) - model.rescan_track(row_number) + self.playlist_model.rescan_track(row_number) self.clear_selection() # def _reset_next(self, old_plrid: int, new_plrid: int) -> None: @@ -931,8 +1001,7 @@ class PlaylistTab(QTableView): # We need to be in MultiSelection mode self.setSelectionMode(QAbstractItemView.SelectionMode.MultiSelection) # Get the duplicate rows - model = cast(PlaylistModel, self.model()) - duplicate_rows = model.get_duplicate_rows() + duplicate_rows = self.playlist_model.get_duplicate_rows() # Select the rows for duplicate_row in duplicate_rows: self.selectRow(duplicate_row) @@ -951,8 +1020,9 @@ class PlaylistTab(QTableView): if len(selected_rows) == 0: self.musicmuster.lblSumPlaytime.setText("") else: - model = cast(PlaylistModel, self.model()) - selected_duration = model.get_rows_duration(self.get_selected_rows()) + selected_duration = self.playlist_model.get_rows_duration( + self.get_selected_rows() + ) if selected_duration > 0: self.musicmuster.lblSumPlaytime.setText( f"Selected duration: {ms_to_mmss(selected_duration)}" @@ -1011,6 +1081,14 @@ class PlaylistTab(QTableView): Implement spanning of cells, initiated by signal """ + model = self.proxy_model + if hasattr(model, "mapToSource"): + edit_index = model.mapFromSource( + self.playlist_model.createIndex(row, column) + ) + row = edit_index.row() + column = edit_index.column() + # Don't set spanning if already in place because that is seen as # a change to the view and thus it refreshes the data which # again calls us here. @@ -1025,6 +1103,5 @@ class PlaylistTab(QTableView): def _unmark_as_next(self) -> None: """Rescan track""" - model = cast(PlaylistModel, self.model()) - model.set_next_row(None) + self.playlist_model.set_next_row(None) self.clear_selection() diff --git a/app/ui/main_window.ui b/app/ui/main_window.ui index 14a028a..a19d04a 100644 --- a/app/ui/main_window.ui +++ b/app/ui/main_window.ui @@ -785,8 +785,6 @@ padding-left: 8px; &Search - - diff --git a/app/ui/main_window_ui.py b/app/ui/main_window_ui.py index 4488a3e..6198d70 100644 --- a/app/ui/main_window_ui.py +++ b/app/ui/main_window_ui.py @@ -1,6 +1,6 @@ # Form implementation generated from reading ui file 'app/ui/main_window.ui' # -# Created by: PyQt6 UI code generator 6.5.3 +# Created by: PyQt6 UI code generator 6.6.0 # # WARNING: Any manual changes made to this file will be lost when pyuic6 is # run again. Do not edit this file unless you know what you are doing. @@ -492,8 +492,6 @@ class Ui_MainWindow(object): self.menuPlaylist.addAction(self.actionMark_for_moving) self.menuPlaylist.addAction(self.actionPaste) self.menuSearc_h.addAction(self.actionSearch) - self.menuSearc_h.addAction(self.actionFind_next) - self.menuSearc_h.addAction(self.actionFind_previous) self.menuSearc_h.addSeparator() self.menuSearc_h.addAction(self.actionSelect_next_track) self.menuSearc_h.addAction(self.actionSelect_previous_track)