Merge branch 'dev'

This commit is contained in:
Keith Edmunds 2024-12-13 21:37:11 +00:00
commit ac18773ebd
6 changed files with 162 additions and 59 deletions

View File

@ -107,7 +107,11 @@ class AudacityController:
f"and pipes exist at {self.pipe_to} and {self.pipe_from}." f"and pipes exist at {self.pipe_to} and {self.pipe_from}."
) )
# Send test command to Audacity def _test_connectivity(self) -> None:
"""
Send test command to Audacity
"""
response = self._send_command(Config.AUDACITY_TEST_COMMAND) response = self._send_command(Config.AUDACITY_TEST_COMMAND)
if response != Config.AUDACITY_TEST_RESPONSE: if response != Config.AUDACITY_TEST_RESPONSE:
raise ApplicationError( raise ApplicationError(

View File

@ -95,6 +95,8 @@ class Config(object):
ROOT = os.environ.get("ROOT") or "/home/kae/music" ROOT = os.environ.get("ROOT") or "/home/kae/music"
ROWS_FROM_ZERO = True ROWS_FROM_ZERO = True
SCROLL_TOP_MARGIN = 3 SCROLL_TOP_MARGIN = 3
SECTION_ENDINGS = ("-", "+-", "-+")
SECTION_STARTS = ("+", "+-", "-+")
SONGFACTS_ON_NEXT = False SONGFACTS_ON_NEXT = False
START_GAP_WARNING_THRESHOLD = 300 START_GAP_WARNING_THRESHOLD = 300
TEXT_NO_TRACK_NO_NOTE = "[Section header]" TEXT_NO_TRACK_NO_NOTE = "[Section header]"

View File

@ -567,7 +567,7 @@ class PlaylistRows(dbtables.PlaylistRowsTable):
session: Session, session: Session,
playlist_id: int, playlist_id: int,
sqla_map: List[dict[str, int]], sqla_map: List[dict[str, int]],
dummy_for_profiling=None, dummy_for_profiling: Optional[int] = None,
) -> None: ) -> None:
""" """
Take a {plrid: row_number} dictionary and update the row numbers accordingly Take a {plrid: row_number} dictionary and update the row numbers accordingly

View File

@ -1066,7 +1066,7 @@ class Window(QMainWindow, Ui_MainWindow):
webbrowser.get("browser").open_new_tab(url) webbrowser.get("browser").open_new_tab(url)
@line_profiler.profile @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. Paste earlier cut rows.
""" """

View File

@ -377,7 +377,7 @@ class PlaylistModel(QAbstractTableModel):
else: else:
return QVariant(self.header_text(rat)) return QVariant(self.header_text(rat))
else: else:
return QVariant() return QVariant("")
if column == Col.START_TIME.value: if column == Col.START_TIME.value:
start_time = rat.forecast_start_time start_time = rat.forecast_start_time
@ -395,7 +395,7 @@ class PlaylistModel(QAbstractTableModel):
if rat.intro: if rat.intro:
return QVariant(f"{rat.intro / 1000:{Config.INTRO_SECONDS_FORMAT}}") return QVariant(f"{rat.intro / 1000:{Config.INTRO_SECONDS_FORMAT}}")
else: else:
return QVariant() return QVariant("")
dispatch_table = { dispatch_table = {
Col.ARTIST.value: QVariant(rat.artist), Col.ARTIST.value: QVariant(rat.artist),
@ -618,7 +618,7 @@ class PlaylistModel(QAbstractTableModel):
Process possible section timing directives embeded in header Process possible section timing directives embeded in header
""" """
if rat.note.endswith("+"): if rat.note.endswith(Config.SECTION_STARTS):
return self.start_of_timed_section_header(rat) return self.start_of_timed_section_header(rat)
elif rat.note.endswith("="): elif rat.note.endswith("="):
@ -748,7 +748,10 @@ class PlaylistModel(QAbstractTableModel):
@line_profiler.profile @line_profiler.profile
def move_rows( 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: ) -> None:
""" """
Move the playlist rows given to to_row and below. Move the playlist rows given to to_row and below.
@ -820,7 +823,7 @@ class PlaylistModel(QAbstractTableModel):
from_rows: list[int], from_rows: list[int],
to_row_number: int, to_row_number: int,
to_playlist_id: int, to_playlist_id: int,
dummy_for_profiling=None, dummy_for_profiling: Optional[int] = None,
) -> None: ) -> None:
""" """
Move the playlist rows given to to_row and below of to_playlist. Move the playlist rows given to to_row and below of to_playlist.
@ -1005,7 +1008,6 @@ class PlaylistModel(QAbstractTableModel):
refresh_row(). refresh_row().
""" """
# Note where each playlist_id is # Note where each playlist_id is
plid_to_row: dict[int, int] = {} plid_to_row: dict[int, int] = {}
for oldrow in self.playlist_rows: for oldrow in self.playlist_rows:
@ -1024,7 +1026,12 @@ class PlaylistModel(QAbstractTableModel):
# Copy to self.playlist_rows # Copy to self.playlist_rows
self.playlist_rows = new_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""" """Populate self.playlist_rows with playlist data"""
# Same as refresh data, but only used when creating playslit. # Same as refresh data, but only used when creating playslit.
@ -1465,7 +1472,7 @@ class PlaylistModel(QAbstractTableModel):
for row_number in range(rat.row_number + 1, len(self.playlist_rows)): for row_number in range(rat.row_number + 1, len(self.playlist_rows)):
row_rat = self.playlist_rows[row_number] row_rat = self.playlist_rows[row_number]
if self.is_header_row(row_number): if self.is_header_row(row_number):
if row_rat.note.endswith("-"): if row_rat.note.endswith(Config.SECTION_ENDINGS):
return ( return (
f"{rat.note[:-1].strip()} " f"{rat.note[:-1].strip()} "
f"[{count} tracks, {ms_to_mmss(duration)} unplayed]" f"[{count} tracks, {ms_to_mmss(duration)} unplayed]"

View File

@ -1,5 +1,5 @@
# Standard library imports # Standard library imports
from typing import Callable, cast, List, Optional, TYPE_CHECKING from typing import Any, Callable, cast, List, Optional, TYPE_CHECKING
# PyQt imports # PyQt imports
from PyQt6.QtCore import ( from PyQt6.QtCore import (
@ -8,19 +8,19 @@ from PyQt6.QtCore import (
QModelIndex, QModelIndex,
QObject, QObject,
QItemSelection, QItemSelection,
QSize,
Qt, Qt,
QTimer, QTimer,
) )
from PyQt6.QtGui import QAction, QKeyEvent from PyQt6.QtGui import QAction, QDropEvent, QKeyEvent, QTextDocument
from PyQt6.QtWidgets import ( from PyQt6.QtWidgets import (
QAbstractItemDelegate, QAbstractItemDelegate,
QAbstractItemView, QAbstractItemView,
QApplication, QApplication,
QDoubleSpinBox, QDoubleSpinBox,
QHeaderView, QFrame,
QMenu, QMenu,
QMessageBox, QMessageBox,
QPlainTextEdit,
QProxyStyle, QProxyStyle,
QStyle, QStyle,
QStyledItemDelegate, QStyledItemDelegate,
@ -28,10 +28,12 @@ from PyQt6.QtWidgets import (
QStyleOptionViewItem, QStyleOptionViewItem,
QTableView, QTableView,
QTableWidgetItem, QTableWidgetItem,
QTextEdit,
QWidget, QWidget,
) )
# Third party imports # Third party imports
import line_profiler
# App imports # App imports
from audacity_controller import AudacityController from audacity_controller import AudacityController
@ -52,67 +54,100 @@ if TYPE_CHECKING:
from musicmuster import Window 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 - closes the edit on control-return
- checks with user before abandoning edit on Escape - checks with user before abandoning edit on Escape
- positions cursor where double-click occurs - 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: def __init__(self, parent: QWidget, source_model: PlaylistModel) -> None:
super().__init__(parent) super().__init__(parent)
self.source_model = source_model self.source_model = source_model
self.signals = MusicMusterSignals() self.signals = MusicMusterSignals()
self.click_position = None # Store the mouse click position self.click_position = None
self.current_editor: Optional[Any] = None
def createEditor( def createEditor(
self, self,
parent: Optional[QWidget], parent: Optional[QWidget],
option: QStyleOptionViewItem, option: QStyleOptionViewItem,
index: QModelIndex, index: QModelIndex,
) -> Optional[QDoubleSpinBox | QPlainTextEdit]: ) -> Optional[QDoubleSpinBox | QTextEdit]:
""" """
Intercept createEditor call and make row just a little bit taller 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 = MusicMusterSignals()
self.signals.enable_escape_signal.emit(False) self.signals.enable_escape_signal.emit(False)
if isinstance(self.parent(), PlaylistTab): if self.current_editor:
p = cast(PlaylistTab, self.parent()) editor = self.current_editor
else:
if index.column() == Col.INTRO.value: if index.column() == Col.INTRO.value:
editor = QDoubleSpinBox(parent) editor = QDoubleSpinBox(parent)
editor.setDecimals(1) editor.setDecimals(1)
editor.setSingleStep(0.1) editor.setSingleStep(0.1)
return editor return editor
elif isinstance(index.data(), str): elif isinstance(index.data(), str):
editor = QPlainTextEdit(parent) editor = Editor(parent)
editor.setGeometry(option.rect) # Match the cell geometry editor.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
row = index.row() editor.setFrameShape(QFrame.Shape.NoFrame)
row_height = p.rowHeight(row) self.current_editor = editor
p.setRowHeight(row, row_height + Config.MINIMUM_ROW_HEIGHT) PlaylistDelegate.EditorDocument(editor)
return editor return editor
return super().createEditor(parent, option, index)
def destroyEditor(self, editor: Optional[QWidget], index: QModelIndex) -> None: def destroyEditor(self, editor: Optional[QWidget], index: QModelIndex) -> None:
""" """
Intercept editor destroyment 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) self.signals.enable_escape_signal.emit(True)
return super().destroyEditor(editor, index)
def editorEvent( def editorEvent(
self, event: QEvent, model: QAbstractItemModel, option, index: QModelIndex self,
event: Optional[QEvent],
model: Optional[QAbstractItemModel],
option: QStyleOptionViewItem,
index: QModelIndex,
) -> bool: ) -> bool:
"""Capture mouse click position.""" """Capture mouse click position."""
if event.type() == QEvent.Type.MouseButtonPress: if event and event.type() == QEvent.Type.MouseButtonPress:
if hasattr(event, "pos"):
self.click_position = event.pos() self.click_position = event.pos()
return super().editorEvent(event, model, option, index) return super().editorEvent(event, model, option, index)
@ -120,10 +155,10 @@ class EscapeDelegate(QStyledItemDelegate):
"""By default, QPlainTextEdit doesn't handle enter or return""" """By default, QPlainTextEdit doesn't handle enter or return"""
if editor is None or event is None: if editor is None or event is None:
return super().eventFilter(editor, event) return False
if event.type() == QEvent.Type.Show: 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 # Map click position to editor's local space
local_click_position = editor.mapFromParent(self.click_position) local_click_position = editor.mapFromParent(self.click_position)
@ -134,24 +169,27 @@ class EscapeDelegate(QStyledItemDelegate):
# Reset click position # Reset click position
self.click_position = None self.click_position = None
return super().eventFilter(editor, event) return False
elif event.type() == QEvent.Type.KeyPress: elif event.type() == QEvent.Type.KeyPress:
key_event = cast(QKeyEvent, event) 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): if key_event.modifiers() == (Qt.KeyboardModifier.ControlModifier):
self.commitData.emit(editor) self.commitData.emit(editor)
self.closeEditor.emit(editor) self.closeEditor.emit(editor)
return True return True
elif key_event.key() == Qt.Key.Key_Escape: elif key == Qt.Key.Key_Escape:
# Close editor if no changes have been made # Close editor if no changes have been made
data_modified = False data_modified = False
if isinstance(editor, QPlainTextEdit): if isinstance(editor, QTextEdit):
data_modified = self.original_model_data != editor.toPlainText() data_modified = (
self.original_model_data.value() != editor.toPlainText()
)
elif isinstance(editor, QDoubleSpinBox): elif isinstance(editor, QDoubleSpinBox):
data_modified = ( data_modified = (
self.original_model_data != int(editor.value()) * 1000 self.original_model_data.value() != int(editor.value()) * 1000
) )
if not data_modified: if not data_modified:
self.closeEditor.emit(editor) self.closeEditor.emit(editor)
@ -166,6 +204,36 @@ class EscapeDelegate(QStyledItemDelegate):
return False 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)
# For debugging +++
# Calculate sizes
# document_size = doc.documentLayout().documentSize()
# ideal_width = doc.idealWidth()
# height = document_size.height()
# rect_width = option.rect.width()
# text = option.text
# # Debug output
# print(f"Index: {index.row()}, {index.column()}")
# print(f"Text: {text}")
# print(f"Option.rect width: {rect_width}")
# print(f"Document idealWidth: {ideal_width}")
# print(f"Document height: {height}")
# print(f"---")
# --- For debugging
return QSize(int(doc.idealWidth()), int(doc.size().height()))
def setEditorData(self, editor, index): def setEditorData(self, editor, index):
proxy_model = index.model() proxy_model = index.model()
edit_index = proxy_model.mapToSource(index) edit_index = proxy_model.mapToSource(index)
@ -174,6 +242,7 @@ class EscapeDelegate(QStyledItemDelegate):
edit_index, Qt.ItemDataRole.EditRole edit_index, Qt.ItemDataRole.EditRole
) )
if index.column() == Col.INTRO.value: if index.column() == Col.INTRO.value:
if self.original_model_data.value():
editor.setValue(self.original_model_data.value() / 1000) editor.setValue(self.original_model_data.value() / 1000)
else: else:
editor.setPlainText(self.original_model_data.value()) editor.setPlainText(self.original_model_data.value())
@ -182,7 +251,7 @@ class EscapeDelegate(QStyledItemDelegate):
proxy_model = index.model() proxy_model = index.model()
edit_index = proxy_model.mapToSource(index) edit_index = proxy_model.mapToSource(index)
if isinstance(editor, QPlainTextEdit): if isinstance(editor, QTextEdit):
value = editor.toPlainText().strip() value = editor.toPlainText().strip()
elif isinstance(editor, QDoubleSpinBox): elif isinstance(editor, QDoubleSpinBox):
value = editor.value() value = editor.value()
@ -230,7 +299,7 @@ class PlaylistTab(QTableView):
# Set up widget # Set up widget
self.source_model = PlaylistModel(playlist_id) self.source_model = PlaylistModel(playlist_id)
self.proxy_model = PlaylistProxyModel(self.source_model) 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.setAlternatingRowColors(True)
self.setVerticalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel) self.setVerticalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel)
self.setDragDropMode(QAbstractItemView.DragDropMode.InternalMove) self.setDragDropMode(QAbstractItemView.DragDropMode.InternalMove)
@ -264,7 +333,7 @@ class PlaylistTab(QTableView):
# Set up for Audacity # Set up for Audacity
try: try:
self.ac = AudacityController() self.ac: Optional[AudacityController] = AudacityController()
except ApplicationError as e: except ApplicationError as e:
self.ac = None self.ac = None
show_warning(self.musicmuster, "Audacity error", str(e)) show_warning(self.musicmuster, "Audacity error", str(e))
@ -272,9 +341,15 @@ class PlaylistTab(QTableView):
# Stretch last column *after* setting column widths which is # Stretch last column *after* setting column widths which is
# *much* faster # *much* faster
h_header = self.horizontalHeader() h_header = self.horizontalHeader()
if isinstance(h_header, QHeaderView): if h_header:
h_header.sectionResized.connect(self._column_resize) h_header.sectionResized.connect(self._column_resize)
h_header.setStretchLastSection(True) 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 # Setting ResizeToContents causes screen flash on load
self.resize_rows() self.resize_rows()
@ -302,10 +377,15 @@ class PlaylistTab(QTableView):
# Deselect edited line # Deselect edited line
self.clear_selection() self.clear_selection()
def dropEvent(self, event): @line_profiler.profile
def dropEvent(
self, event: Optional[QDropEvent], dummy_for_profiling: Optional[int] = None
) -> None:
if not event:
return
if event.source() is not self or ( if event.source() is not self or (
event.dropAction() != Qt.DropAction.MoveAction event.dropAction() != Qt.DropAction.MoveAction
and self.dragDropMode() != QAbstractItemView.InternalMove and self.dragDropMode() != QAbstractItemView.DragDropMode.InternalMove
): ):
super().dropEvent(event) super().dropEvent(event)
@ -370,6 +450,16 @@ class PlaylistTab(QTableView):
self.reset() self.reset()
super().mouseReleaseEvent(event) 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( def selectionChanged(
self, selected: QItemSelection, deselected: QItemSelection self, selected: QItemSelection, deselected: QItemSelection
) -> None: ) -> None:
@ -483,9 +573,7 @@ class PlaylistTab(QTableView):
self._add_context_menu( self._add_context_menu(
"Rescan track", lambda: self._rescan(model_row_number) "Rescan track", lambda: self._rescan(model_row_number)
) )
self._add_context_menu( self._add_context_menu("Mark for moving", lambda: self._mark_for_moving())
"Mark for moving", lambda: self._mark_for_moving()
)
if self.musicmuster.move_source_rows: if self.musicmuster.move_source_rows:
self._add_context_menu( self._add_context_menu(
"Move selected rows here", lambda: self._move_selected_rows() "Move selected rows here", lambda: self._move_selected_rows()
@ -718,6 +806,8 @@ class PlaylistTab(QTableView):
Import current Audacity track to passed row Import current Audacity track to passed row
""" """
if not self.ac:
return
try: try:
self.ac.export() self.ac.export()
self._rescan(row_number) self._rescan(row_number)