WIP V3: implement searching with QSortFilterProxyModel (ooo!)

This commit is contained in:
Keith Edmunds 2023-11-26 15:22:01 +00:00
parent 6f5c371510
commit 480c832852
5 changed files with 279 additions and 100 deletions

View File

@ -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"""

View File

@ -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()

View File

@ -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())
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()

View File

@ -785,8 +785,6 @@ padding-left: 8px;</string>
<string>&amp;Search</string>
</property>
<addaction name="actionSearch"/>
<addaction name="actionFind_next"/>
<addaction name="actionFind_previous"/>
<addaction name="separator"/>
<addaction name="actionSelect_next_track"/>
<addaction name="actionSelect_previous_track"/>

View File

@ -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)