diff --git a/app/musicmuster.py b/app/musicmuster.py index fee5d00..8711bf6 100755 --- a/app/musicmuster.py +++ b/app/musicmuster.py @@ -16,7 +16,7 @@ from PyQt5.QtWidgets import ( QFileDialog, QInputDialog, QLabel, - # QLineEdit, + QLineEdit, QListWidgetItem, QMainWindow, # QMessageBox, @@ -77,9 +77,9 @@ class Window(QMainWindow, Ui_MainWindow): self.set_main_window_size() self.lblSumPlaytime = QLabel("") self.statusbar.addPermanentWidget(self.lblSumPlaytime) -# self.txtSearch = QLineEdit() -# self.statusbar.addWidget(self.txtSearch) -# self.txtSearch.setHidden(True) + self.txtSearch = QLineEdit() + self.statusbar.addWidget(self.txtSearch) + self.txtSearch.setHidden(True) self.hide_played_tracks = False self.visible_playlist_tab: Callable[[], PlaylistTab] = \ @@ -101,8 +101,12 @@ class Window(QMainWindow, Ui_MainWindow): def clear_selection(self) -> None: """ Clear selected row""" + # Unselect any selected rows if self.visible_playlist_tab(): self.visible_playlist_tab().clear_selection() + # Clear the search bar + self.search_playlist_clear() + def closeEvent(self, event: QEvent) -> None: """Handle attempt to close main window""" @@ -148,6 +152,10 @@ class Window(QMainWindow, Ui_MainWindow): event.accept() def connect_signals_slots(self) -> None: + self.actionFind_next.triggered.connect( + lambda: self.tabPlaylist.currentWidget().search_next()) + self.actionFind_previous.triggered.connect( + lambda: self.tabPlaylist.currentWidget().search_previous()) self.actionInsertSectionHeader.triggered.connect(self.insert_header) self.action_Clear_selection.triggered.connect(self.clear_selection) self.actionClosePlaylist.triggered.connect(self.close_playlist_tab) @@ -162,7 +170,7 @@ class Window(QMainWindow, Ui_MainWindow): self.actionNewPlaylist.triggered.connect(self.create_playlist) self.actionOpenPlaylist.triggered.connect(self.open_playlist) self.actionPlay_next.triggered.connect(self.play_next) -# self.actionSearch.triggered.connect(self.search_playlist) + self.actionSearch.triggered.connect(self.search_playlist) self.actionInsertTrack.triggered.connect(self.insert_track) self.actionSelect_next_track.triggered.connect(self.select_next_row) # self.actionSelect_played_tracks.triggered.connect(self.select_played) @@ -179,9 +187,8 @@ class Window(QMainWindow, Ui_MainWindow): self.btnFade.clicked.connect(self.fade) self.btnStop.clicked.connect(self.stop) self.tabPlaylist.tabCloseRequested.connect(self.close_tab) -# self.txtSearch.returnPressed.connect(self.search_playlist_return) -# self.txtSearch.textChanged.connect(self.search_playlist_update) -# + self.txtSearch.returnPressed.connect(self.search_playlist_return) + self.timer.timeout.connect(self.tick) def close_playlist_tab(self) -> None: @@ -623,26 +630,35 @@ class Window(QMainWindow, Ui_MainWindow): with Session() as session: dlg = DbDialog(self, session) dlg.exec() -# -# def search_playlist(self): -# """Show text box to search playlist""" -# -# self.disable_play_next_controls() -# self.txtSearch.setHidden(False) -# self.txtSearch.setFocus() -# -# def search_playlist_return(self): -# """Close off search box when return pressed""" -# -# self.txtSearch.setText("") -# self.txtSearch.setHidden(True) -# self.enable_play_next_controls() -# self.visible_playlist_tab().set_filter("") -# -# def search_playlist_update(self): -# """Update search when search string changes""" -# -# self.visible_playlist_tab().set_filter(self.txtSearch.text()) + + def search_playlist(self) -> None: + """Show text box to search playlist""" + + # Disable play controls so that 'return' in search box doesn't + # play next track + self.disable_play_next_controls() + self.txtSearch.setHidden(False) + self.txtSearch.setFocus() + + def search_playlist_clear(self) -> None: + """Tidy up and reset search bar""" + + # Clear the search text + self.visible_playlist_tab().search("") + # Clean up search bar + self.txtSearch.setText("") + self.txtSearch.setHidden(True) + + def search_playlist_return(self) -> None: + """Initiate search when return pressed""" + + self.visible_playlist_tab().search(self.txtSearch.text()) + self.enable_play_next_controls() + + # def search_playlist_update(self): + # """Update search when search string changes""" + + # self.visible_playlist_tab().set_filter(self.txtSearch.text()) def open_playlist(self): with Session() as session: diff --git a/app/playlists.py b/app/playlists.py index 8c0c5bc..e5d8bae 100644 --- a/app/playlists.py +++ b/app/playlists.py @@ -146,7 +146,7 @@ class PlaylistTab(QTableWidget): self.itemSelectionChanged.connect(self._select_event) - self.row_filter: Optional[str] = None + self.search_text: str = "" self.edit_cell_type = None self.selecting_in_progress = False # Connect signals @@ -775,78 +775,109 @@ class PlaylistTab(QTableWidget): session.commit() PlaylistRows.delete_higher_rows(session, self.playlist_id, row) -# def save_playlist(self, session) -> None: -# """ -# Save playlist to database. -# -# For notes: check the database entry is correct and update it if -# necessary. Playlists:Note is one:many, so each note may only appear -# in one playlist. -# -# For tracks: erase the playlist tracks and recreate. This is much -# simpler than trying to implement any Playlists:Tracks many:many -# changes. -# """ -# -# playlist = Playlists.get_by_id(session, self.playlist_id) -# -# # Notes first -# # Create dictionaries indexed by note_id -# playlist_notes: Dict[int, Notes] = {} -# database_notes: Dict[int, Notes] = {} -# notes_rows: List[int] = self._get_notes_rows() -# -# # PlaylistTab -# for row in notes_rows: -# note: Notes = self._get_row_notes_object(row, session) -# session.add(note) -# playlist_notes[note.id] = note -# if row != note.row: -# log.debug(f"Updating: {playlist.name=}, {row=}, {note.row=}") -# note.update(session=session, row=row) -# -# # Database -# for note in playlist.notes: -# database_notes[note.id] = note -# -# # We don't need to check for notes to add to the database as -# # they can't exist in the playlist without being in the database -# # and pointing at this playlist. -# -# # Notes to remove from database -# for note_id in set(database_notes.keys()) - set(playlist_notes.keys()): -# log.debug( -# "_save_playlist(): " -# f"Delete {note_id=} from {self=} in database" -# ) -# database_notes[note_id].delete_note(session) -# -# # Note rows to update in playlist database -# for note_id in set(playlist_notes.keys()) & set(database_notes.keys()): -# if playlist_notes[note_id].row != database_notes[note_id].row: -# log.debug( -# f"_save_playlist(): Update notes row in database " -# f"from {database_notes[note_id]=} " -# f"to {playlist_notes[note_id]=}" -# ) -# database_notes[note_id].update( -# session, row=playlist_notes[note_id].row) -# -# # Tracks -# # Remove all tracks from this playlist -# playlist.remove_all_tracks(session) -# # Iterate on-screen playlist and add tracks back in -# for row in range(self.rowCount()): -# if row in notes_rows: -# continue -# track_id: int = self.item( -# row, FIXUP.COL_USERDATA).data(self.CONTENT_OBJECT) -# playlist.add_track(session, track_id, row) -# session.commit() + def search(self, text: str) -> None: + """Set search text and find first match""" + + self.search_text = text + if not text: + # Search string has been reset + return + self.search_next() + + def search_next(self) -> None: + """ + Select next row containg self.search_string. Start from + top selected row if there is one, else from top. + + Wrap at last row. + """ + + if not self.search_text: + return + + selected_row = self._get_selected_row() + if selected_row and selected_row < self.rowCount() - 1: + starting_row = selected_row + 1 + else: + starting_row = 0 + + wrapped = False + match_row = None + row = starting_row + needle = self.search_text.lower() + while True: + # Check for match in title, artist or notes + title = self._get_row_title(row) + if title and needle in title.lower(): + match_row = row + break + artist = self._get_row_title(row) + if artist and needle in artist.lower(): + match_row = row + break + note = self._get_row_note(row) + if note and needle in note.lower(): + match_row = row + break + row += 1 + if wrapped and row >= starting_row: + break + if row >= self.rowCount(): + row = 0 + wrapped = True + + if match_row: + self.selectRow(row) + + def search_previous(self) -> None: + """ + Select previous row containg self.search_string. Start from + top selected row if there is one, else from top. + + Wrap at last row. + """ + + if not self.search_text: + return + + selected_row = self._get_selected_row() + if selected_row and selected_row > 0: + starting_row = selected_row - 1 + else: + starting_row = self.rowCount() - 1 + + wrapped = False + match_row = None + row = starting_row + needle = self.search_text.lower() + while True: + # Check for match in title, artist or notes + title = self._get_row_title(row) + if title and needle in title.lower(): + match_row = row + break + artist = self._get_row_title(row) + if artist and needle in artist.lower(): + match_row = row + break + note = self._get_row_note(row) + if note and needle in note.lower(): + match_row = row + break + row -= 1 + if wrapped and row <= starting_row: + break + if row < 0: + row = self.rowCount() - 1 + wrapped = True + + if match_row: + self.selectRow(row) def select_next_row(self) -> None: """ Select next or first row. Don't select section headers. + Wrap at last row. """ @@ -938,13 +969,12 @@ class PlaylistTab(QTableWidget): # finally: # self.selecting_in_progress = False # self._select_event() -# -# def set_filter(self, text: Optional[str]) -> None: -# """Filter rows to only show those containing text""" -# -# self.row_filter = text -# with Session() as session: -# self.update_display(session) + + def set_searchtext(self, text: Optional[str]) -> None: + """Set the search text and find first match""" + + self.search_text = text + self._find_next_match() def set_selected_as_next(self) -> None: """Sets the select track as next to play""" @@ -980,10 +1010,6 @@ class PlaylistTab(QTableWidget): ] unreadable: List[int] = self._get_unreadable_track_rows() - if self.row_filter: - filter_text = self.row_filter.lower() - else: - filter_text = None next_start_time = None section_start_plr = None section_time = 0 @@ -1023,22 +1049,6 @@ class PlaylistTab(QTableWidget): if section_start_plr is not None: section_time += track.duration - # If filtering, only show matching tracks - if filter_text: - try: - if (track.title - and filter_text not in track.title.lower() - and track.artist - and filter_text not in track.artist.lower()): - self.hideRow(row) - continue - else: - self.showRow(row) - except TypeError: - print(f"TypeError: {track=}") - else: - self.showRow(row) - # Colour any note if note_text: (self.item(row, columns['row_notes'].idx) @@ -1119,14 +1129,6 @@ class PlaylistTab(QTableWidget): continue # No track associated, so this row is a section header - if filter_text: - if filter_text not in note_text.lower(): - self.hideRow(row) - continue - else: - self.showRow(row) - else: - self.showRow(row) # Does the note have a start time? row_time = self._get_note_text_time(note_text) if row_time: @@ -1264,11 +1266,18 @@ class PlaylistTab(QTableWidget): return (index.row() + 1 if self._is_below(event.pos(), index) else index.row()) -# def _get_notes_rows(self) -> List[int]: -# """Return rows marked as notes, or None""" -# -# return self._meta_search(RowMeta.NOTE, one=False) -# + def _find_next_match(self) -> None: + """ + Find next match of search_text. Start at first highlighted row + if there is one, else from top of playlist. + """ + + start_row = self._get_selected_row() + if start_row is None: + start_row = 0 + + + def _find_next_track_row(self, session: Session, starting_row: int = None) -> Optional[int]: @@ -1340,6 +1349,16 @@ class PlaylistTab(QTableWidget): return playlistrow_id + def _get_row_artist(self, row: int) -> Optional[str]: + """Return artist on this row or None if none""" + + track_id = self._get_row_track_id(row) + if not track_id: + return None + + item_artist = self.item(row, columns['artist'].idx) + return item_artist.text() + def _get_row_duration(self, row: int) -> int: """Return duration associated with this row""" @@ -1350,46 +1369,15 @@ class PlaylistTab(QTableWidget): else: return 0 - def _get_row_track_id(self, row: int) -> int: - """Return the track_id associated with this row or None""" + def _get_row_note(self, row: int) -> Optional[str]: + """Return note on this row or None if none""" - track_id = (self.item(row, columns['userdata'].idx) - .data(self.ROW_TRACK_ID)) - - return track_id - -# -# def _get_row_end_time(self, row) -> Optional[datetime]: -# """ -# Return row end time as string -# """ -# -# try: -# if self.item(row, FIXUP.COL_END_TIME): -# return datetime.strptime(self.item( -# row, FIXUP.COL_END_TIME).text(), -# Config.NOTE_TIME_FORMAT -# ) -# else: -# return None -# except ValueError: -# return None -# -# def _get_row_notes_object(self, row: int, session: Session) \ -# -> Optional[Notes]: -# """Return note associated with this row""" -# -# note_id = self.item(row, FIXUP.COL_USERDATA).data(self.CONTENT_OBJECT) -# note = Notes.get_by_id(session, note_id) -# return note -# -# def _get_unplayed_track_rows(self) -> Optional[List[int]]: -# """Return rows marked as unplayed, or None""" -# -# unplayed_rows: Set[int] = set(self._meta_notset(RowMeta.PLAYED)) -# notes_rows: Set[int] = set(self._get_notes_rows()) -# -# return list(unplayed_rows - notes_rows) + track_id = self._get_row_track_id(row) + if track_id: + item_note = self.item(row, columns['row_notes'].idx) + else: + item_note = self.item(row, 1) + return item_note.text() def _get_row_start_time(self, row: int) -> Optional[datetime]: try: @@ -1403,6 +1391,35 @@ class PlaylistTab(QTableWidget): except ValueError: return None + def _get_row_title(self, row: int) -> Optional[str]: + """Return title on this row or None if none""" + + track_id = self._get_row_track_id(row) + if not track_id: + return None + + item_title = self.item(row, columns['title'].idx) + return item_title.text() + + def _get_row_track_id(self, row: int) -> int: + """Return the track_id associated with this row or None""" + + try: + track_id = (self.item(row, columns['userdata'].idx) + .data(self.ROW_TRACK_ID)) + except AttributeError: + return None + + return track_id + +# def _get_unplayed_track_rows(self) -> Optional[List[int]]: +# """Return rows marked as unplayed, or None""" +# +# unplayed_rows: Set[int] = set(self._meta_notset(RowMeta.PLAYED)) +# notes_rows: Set[int] = set(self._get_notes_rows()) +# +# return list(unplayed_rows - notes_rows) + def _get_selected_row(self) -> Optional[int]: """Return row number of first selected row, or None if none selected""" @@ -1410,19 +1427,6 @@ class PlaylistTab(QTableWidget): return None else: return self.selectionModel().selectedRows()[0].row() -# -# def _get_row_track_object(self, row: int, session: Session) \ -# -> Optional[Tracks]: -# """Return track associated with this row""" -# -# track_id = self.item(row, FIXUP.COL_USERDATA).data(self.CONTENT_OBJECT) -# track = Tracks.get_by_id(session, track_id) -# return track -# -# def _get_track_rows(self) -> List[int]: -# """Return rows marked as tracks, or None""" -# -# return self._meta_notset(RowMeta.NOTE) def _get_unreadable_track_rows(self) -> List[int]: """Return rows marked as unreadable, or None""" diff --git a/app/ui/main_window.ui b/app/ui/main_window.ui index 130ac5c..e9a86e3 100644 --- a/app/ui/main_window.ui +++ b/app/ui/main_window.ui @@ -784,17 +784,26 @@ border: 1px solid rgb(85, 87, 83); + + + + + + Searc&h + + + + - - + @@ -1043,6 +1052,22 @@ border: 1px solid rgb(85, 87, 83); &Remove track + + + Find next + + + N + + + + + Find previous + + + P + + diff --git a/app/ui/main_window_ui.py b/app/ui/main_window_ui.py index 1b19c02..b827b64 100644 --- a/app/ui/main_window_ui.py +++ b/app/ui/main_window_ui.py @@ -350,6 +350,8 @@ class Ui_MainWindow(object): self.menuFile.setObjectName("menuFile") self.menuPlaylist = QtWidgets.QMenu(self.menubar) self.menuPlaylist.setObjectName("menuPlaylist") + self.menuSearc_h = QtWidgets.QMenu(self.menubar) + self.menuSearc_h.setObjectName("menuSearc_h") MainWindow.setMenuBar(self.menubar) self.statusbar = QtWidgets.QStatusBar(MainWindow) self.statusbar.setEnabled(True) @@ -444,6 +446,10 @@ class Ui_MainWindow(object): self.actionInsertSectionHeader.setObjectName("actionInsertSectionHeader") self.actionRemove = QtWidgets.QAction(MainWindow) self.actionRemove.setObjectName("actionRemove") + self.actionFind_next = QtWidgets.QAction(MainWindow) + self.actionFind_next.setObjectName("actionFind_next") + self.actionFind_previous = QtWidgets.QAction(MainWindow) + self.actionFind_previous.setObjectName("actionFind_previous") self.menuFile.addAction(self.actionNewPlaylist) self.menuFile.addAction(self.actionOpenPlaylist) self.menuFile.addAction(self.actionClosePlaylist) @@ -468,16 +474,20 @@ class Ui_MainWindow(object): self.menuPlaylist.addAction(self.actionInsertTrack) self.menuPlaylist.addAction(self.actionRemove) self.menuPlaylist.addAction(self.actionImport) + self.menuPlaylist.addSeparator() self.menuPlaylist.addAction(self.actionSetNext) self.menuPlaylist.addAction(self.action_Clear_selection) self.menuPlaylist.addSeparator() - self.menuPlaylist.addAction(self.actionSearch) - self.menuPlaylist.addAction(self.actionSelect_next_track) - self.menuPlaylist.addAction(self.actionSelect_previous_track) - self.menuPlaylist.addSeparator() self.menuPlaylist.addAction(self.actionEnable_controls) + 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) self.menubar.addAction(self.menuFile.menuAction()) self.menubar.addAction(self.menuPlaylist.menuAction()) + self.menubar.addAction(self.menuSearc_h.menuAction()) self.retranslateUi(MainWindow) self.tabPlaylist.setCurrentIndex(-1) @@ -514,6 +524,7 @@ class Ui_MainWindow(object): self.btnHidePlayed.setText(_translate("MainWindow", "Hide played")) self.menuFile.setTitle(_translate("MainWindow", "P&laylists")) self.menuPlaylist.setTitle(_translate("MainWindow", "Sho&wtime")) + self.menuSearc_h.setTitle(_translate("MainWindow", "Searc&h")) self.actionPlay_next.setText(_translate("MainWindow", "&Play next")) self.actionPlay_next.setShortcut(_translate("MainWindow", "Return")) self.actionSkipToNext.setText(_translate("MainWindow", "Skip to &next")) @@ -560,5 +571,9 @@ class Ui_MainWindow(object): self.actionInsertSectionHeader.setText(_translate("MainWindow", "Insert §ion header...")) self.actionInsertSectionHeader.setShortcut(_translate("MainWindow", "Ctrl+H")) self.actionRemove.setText(_translate("MainWindow", "&Remove track")) + self.actionFind_next.setText(_translate("MainWindow", "Find next")) + self.actionFind_next.setShortcut(_translate("MainWindow", "N")) + self.actionFind_previous.setText(_translate("MainWindow", "Find previous")) + self.actionFind_previous.setShortcut(_translate("MainWindow", "P")) from infotabs import InfoTabs import icons_rc