diff --git a/app/models.py b/app/models.py index 7cf73fc..d46e5d5 100644 --- a/app/models.py +++ b/app/models.py @@ -567,7 +567,7 @@ class PlaylistRows(dbtables.PlaylistRowsTable): session: Session, playlist_id: int, sqla_map: List[dict[str, int]], - dummy_for_profiling=None, + dummy_for_profiling: Optional[int] = None, ) -> None: """ Take a {plrid: row_number} dictionary and update the row numbers accordingly diff --git a/app/musicmuster.py b/app/musicmuster.py index 24556e7..e169d9d 100755 --- a/app/musicmuster.py +++ b/app/musicmuster.py @@ -1066,7 +1066,7 @@ class Window(QMainWindow, Ui_MainWindow): webbrowser.get("browser").open_new_tab(url) @line_profiler.profile - def paste_rows(self, dummy_for_profiling=None) -> None: + def paste_rows(self, dummy_for_profiling: Optional[int] = None) -> None: """ Paste earlier cut rows. """ diff --git a/app/playlistmodel.py b/app/playlistmodel.py index 9f6db70..b98bf8b 100644 --- a/app/playlistmodel.py +++ b/app/playlistmodel.py @@ -395,7 +395,7 @@ class PlaylistModel(QAbstractTableModel): if rat.intro: return QVariant(f"{rat.intro / 1000:{Config.INTRO_SECONDS_FORMAT}}") else: - return QVariant() + return QVariant("") dispatch_table = { Col.ARTIST.value: QVariant(rat.artist), @@ -748,7 +748,10 @@ class PlaylistModel(QAbstractTableModel): @line_profiler.profile def move_rows( - self, from_rows: list[int], to_row_number: int, dummy_for_profiling=None + self, + from_rows: list[int], + to_row_number: int, + dummy_for_profiling: Optional[int] = None, ) -> None: """ Move the playlist rows given to to_row and below. @@ -820,7 +823,7 @@ class PlaylistModel(QAbstractTableModel): from_rows: list[int], to_row_number: int, to_playlist_id: int, - dummy_for_profiling=None, + dummy_for_profiling: Optional[int] = None, ) -> None: """ Move the playlist rows given to to_row and below of to_playlist. @@ -1005,7 +1008,6 @@ class PlaylistModel(QAbstractTableModel): refresh_row(). """ - # Note where each playlist_id is plid_to_row: dict[int, int] = {} for oldrow in self.playlist_rows: @@ -1024,7 +1026,12 @@ class PlaylistModel(QAbstractTableModel): # Copy to self.playlist_rows self.playlist_rows = new_playlist_rows - def load_data(self, session: db.session, dummy_for_profiling=None) -> None: + # Same as refresh data, but only used when creating playslit. + # Distinguishes profile time between initial load and other + # refreshes. + def load_data( + self, session: db.session, dummy_for_profiling: Optional[int] = None + ) -> None: """Populate self.playlist_rows with playlist data""" # Same as refresh data, but only used when creating playslit. diff --git a/app/playlists.py b/app/playlists.py index bd1a566..a0baa5b 100644 --- a/app/playlists.py +++ b/app/playlists.py @@ -1,5 +1,5 @@ # Standard library imports -from typing import Callable, cast, List, Optional, TYPE_CHECKING +from typing import Any, Callable, cast, List, Optional, TYPE_CHECKING # PyQt imports from PyQt6.QtCore import ( @@ -8,19 +8,19 @@ from PyQt6.QtCore import ( QModelIndex, QObject, QItemSelection, + QSize, Qt, QTimer, ) -from PyQt6.QtGui import QAction, QKeyEvent +from PyQt6.QtGui import QAction, QDropEvent, QKeyEvent, QTextDocument from PyQt6.QtWidgets import ( QAbstractItemDelegate, QAbstractItemView, QApplication, QDoubleSpinBox, - QHeaderView, + QFrame, QMenu, QMessageBox, - QPlainTextEdit, QProxyStyle, QStyle, QStyledItemDelegate, @@ -28,6 +28,7 @@ from PyQt6.QtWidgets import ( QStyleOptionViewItem, QTableView, QTableWidgetItem, + QTextEdit, QWidget, ) @@ -52,78 +53,111 @@ if TYPE_CHECKING: from musicmuster import Window -class EscapeDelegate(QStyledItemDelegate): +class PlaylistDelegate(QStyledItemDelegate): """ - - increases the height of a row when editing to make editing easier - closes the edit on control-return - checks with user before abandoning edit on Escape - positions cursor where double-click occurs + - expands edit box and parent table row as text is added + + Parts inspired by https://stackoverflow.com/questions/69113867/ + make-row-of-qtableview-expand-as-editor-grows-in-height """ + class EditorDocument(QTextDocument): + def __init__(self, parent): + super().__init__(parent) + self.setDocumentMargin(0) + self.contentsChange.connect(self.contents_change) + self.height = None + parent.setDocument(self) + + def contents_change(self, position, chars_removed, chars_added): + def resize_func(): + if self.size().height() != self.height: + doc_size = self.size() + self.parent().resize(int(doc_size.width()), int(doc_size.height())) + + QTimer.singleShot(0, resize_func) + def __init__(self, parent: QWidget, source_model: PlaylistModel) -> None: super().__init__(parent) self.source_model = source_model self.signals = MusicMusterSignals() - self.click_position = None # Store the mouse click position + self.click_position = None + self.current_editor: Optional[Any] = None def createEditor( self, parent: Optional[QWidget], option: QStyleOptionViewItem, index: QModelIndex, - ) -> Optional[QDoubleSpinBox | QPlainTextEdit]: + ) -> Optional[QDoubleSpinBox | QTextEdit]: """ Intercept createEditor call and make row just a little bit taller """ - editor: QDoubleSpinBox | QPlainTextEdit + editor: QDoubleSpinBox | QTextEdit + + class Editor(QTextEdit): + def resizeEvent(self, event): + super().resizeEvent(event) + parent.parent().resizeRowToContents(index.row()) self.signals = MusicMusterSignals() self.signals.enable_escape_signal.emit(False) - if isinstance(self.parent(), PlaylistTab): - p = cast(PlaylistTab, self.parent()) - - if index.column() == Col.INTRO.value: - editor = QDoubleSpinBox(parent) - editor.setDecimals(1) - editor.setSingleStep(0.1) - return editor - elif isinstance(index.data(), str): - editor = QPlainTextEdit(parent) - editor.setGeometry(option.rect) # Match the cell geometry - row = index.row() - row_height = p.rowHeight(row) - p.setRowHeight(row, row_height + Config.MINIMUM_ROW_HEIGHT) - return editor - - return super().createEditor(parent, option, index) + if self.current_editor: + editor = self.current_editor + else: + if index.column() == Col.INTRO.value: + editor = QDoubleSpinBox(parent) + editor.setDecimals(1) + editor.setSingleStep(0.1) + return editor + elif isinstance(index.data(), str): + editor = Editor(parent) + editor.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + editor.setFrameShape(QFrame.Shape.NoFrame) + self.current_editor = editor + PlaylistDelegate.EditorDocument(editor) + return editor def destroyEditor(self, editor: Optional[QWidget], index: QModelIndex) -> None: """ Intercept editor destroyment """ + super().destroyEditor(editor, index) + self.current_editor = None + # Funky mypy dancing: + parent = self.parent() + if parent and hasattr(parent, "resizeRowToContents"): + parent.resizeRowToContents(index.row()) self.signals.enable_escape_signal.emit(True) - return super().destroyEditor(editor, index) def editorEvent( - self, event: QEvent, model: QAbstractItemModel, option, index: QModelIndex + self, + event: Optional[QEvent], + model: Optional[QAbstractItemModel], + option: QStyleOptionViewItem, + index: QModelIndex, ) -> bool: """Capture mouse click position.""" - if event.type() == QEvent.Type.MouseButtonPress: - self.click_position = event.pos() + if event and event.type() == QEvent.Type.MouseButtonPress: + if hasattr(event, "pos"): + self.click_position = event.pos() return super().editorEvent(event, model, option, index) def eventFilter(self, editor: Optional[QObject], event: Optional[QEvent]) -> bool: """By default, QPlainTextEdit doesn't handle enter or return""" if editor is None or event is None: - return super().eventFilter(editor, event) + return False if event.type() == QEvent.Type.Show: - if self.click_position and isinstance(editor, QPlainTextEdit): + if self.click_position and isinstance(editor, QTextEdit): # Map click position to editor's local space local_click_position = editor.mapFromParent(self.click_position) @@ -134,24 +168,25 @@ class EscapeDelegate(QStyledItemDelegate): # Reset click position self.click_position = None - return super().eventFilter(editor, event) + return False elif event.type() == QEvent.Type.KeyPress: key_event = cast(QKeyEvent, event) - if key_event.key() == Qt.Key.Key_Return: + key = key_event.key() + if key == Qt.Key.Key_Return: if key_event.modifiers() == (Qt.KeyboardModifier.ControlModifier): self.commitData.emit(editor) self.closeEditor.emit(editor) return True - elif key_event.key() == Qt.Key.Key_Escape: + elif key == Qt.Key.Key_Escape: # Close editor if no changes have been made data_modified = False - if isinstance(editor, QPlainTextEdit): - data_modified = self.original_model_data != editor.toPlainText() + if isinstance(editor, QTextEdit): + data_modified = self.original_model_data.value() != editor.toPlainText() elif isinstance(editor, QDoubleSpinBox): data_modified = ( - self.original_model_data != int(editor.value()) * 1000 + self.original_model_data.value() != int(editor.value()) * 1000 ) if not data_modified: self.closeEditor.emit(editor) @@ -166,6 +201,18 @@ class EscapeDelegate(QStyledItemDelegate): return False + def sizeHint(self, option, index): + self.initStyleOption(option, index) + if self.current_editor: + doc = self.current_editor.document() + else: + doc = QTextDocument() + doc.setTextWidth(option.rect.width()) + doc.setDefaultFont(option.font) + doc.setDocumentMargin(0) + doc.setHtml(option.text) + return QSize(int(doc.idealWidth()), int(doc.size().height())) + def setEditorData(self, editor, index): proxy_model = index.model() edit_index = proxy_model.mapToSource(index) @@ -174,7 +221,8 @@ class EscapeDelegate(QStyledItemDelegate): edit_index, Qt.ItemDataRole.EditRole ) if index.column() == Col.INTRO.value: - editor.setValue(self.original_model_data.value() / 1000) + if self.original_model_data.value(): + editor.setValue(self.original_model_data.value() / 1000) else: editor.setPlainText(self.original_model_data.value()) @@ -182,7 +230,7 @@ class EscapeDelegate(QStyledItemDelegate): proxy_model = index.model() edit_index = proxy_model.mapToSource(index) - if isinstance(editor, QPlainTextEdit): + if isinstance(editor, QTextEdit): value = editor.toPlainText().strip() elif isinstance(editor, QDoubleSpinBox): value = editor.value() @@ -230,7 +278,7 @@ class PlaylistTab(QTableView): # Set up widget self.source_model = PlaylistModel(playlist_id) self.proxy_model = PlaylistProxyModel(self.source_model) - self.setItemDelegate(EscapeDelegate(self, self.source_model)) + self.setItemDelegate(PlaylistDelegate(self, self.source_model)) self.setAlternatingRowColors(True) self.setVerticalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel) self.setDragDropMode(QAbstractItemView.DragDropMode.InternalMove) @@ -264,7 +312,7 @@ class PlaylistTab(QTableView): # Set up for Audacity try: - self.ac = AudacityController() + self.ac: Optional[AudacityController] = AudacityController() except ApplicationError as e: self.ac = None show_warning(self.musicmuster, "Audacity error", str(e)) @@ -272,9 +320,15 @@ class PlaylistTab(QTableView): # Stretch last column *after* setting column widths which is # *much* faster h_header = self.horizontalHeader() - if isinstance(h_header, QHeaderView): + if h_header: h_header.sectionResized.connect(self._column_resize) h_header.setStretchLastSection(True) + # Resize on vertical header click + v_header = self.verticalHeader() + if v_header: + v_header.setMinimumSectionSize(5) + v_header.sectionHandleDoubleClicked.disconnect() + v_header.sectionHandleDoubleClicked.connect(self.resizeRowToContents) # Setting ResizeToContents causes screen flash on load self.resize_rows() @@ -302,10 +356,12 @@ class PlaylistTab(QTableView): # Deselect edited line self.clear_selection() - def dropEvent(self, event): + def dropEvent(self, event: Optional[QDropEvent]) -> None: + if not event: + return if event.source() is not self or ( event.dropAction() != Qt.DropAction.MoveAction - and self.dragDropMode() != QAbstractItemView.InternalMove + and self.dragDropMode() != QAbstractItemView.DragDropMode.InternalMove ): super().dropEvent(event) @@ -370,6 +426,16 @@ class PlaylistTab(QTableView): self.reset() super().mouseReleaseEvent(event) + def resizeRowToContents(self, row): + super().resizeRowToContents(row) + self.verticalHeader().resizeSection(row, self.sizeHintForRow(row)) + + def resizeRowsToContents(self): + header = self.verticalHeader() + for row in range(self.model().rowCount()): + hint = self.sizeHintForRow(row) + header.resizeSection(row, hint) + def selectionChanged( self, selected: QItemSelection, deselected: QItemSelection ) -> None: @@ -718,6 +784,8 @@ class PlaylistTab(QTableView): Import current Audacity track to passed row """ + if not self.ac: + return try: self.ac.export() self._rescan(row_number)