musicmuster/app/playlists.py
2024-12-09 08:45:41 +00:00

970 lines
31 KiB
Python

# Standard library imports
from typing import Callable, cast, List, Optional, TYPE_CHECKING
# PyQt imports
from PyQt6.QtCore import (
QAbstractItemModel,
QEvent,
QModelIndex,
QObject,
QItemSelection,
Qt,
QTimer,
)
from PyQt6.QtGui import QAction, QKeyEvent
from PyQt6.QtWidgets import (
QAbstractItemDelegate,
QAbstractItemView,
QApplication,
QDoubleSpinBox,
QHeaderView,
QMenu,
QMessageBox,
QPlainTextEdit,
QProxyStyle,
QStyle,
QStyledItemDelegate,
QStyleOption,
QStyleOptionViewItem,
QTableView,
QTableWidgetItem,
QWidget,
)
# Third party imports
# App imports
from audacity_controller import AudacityController
from classes import ApplicationError, Col, MusicMusterSignals, TrackInfo, track_sequence
from config import Config
from dialogs import TrackSelectDialog
from helpers import (
ask_yes_no,
ms_to_mmss,
show_OK,
show_warning,
)
from log import log
from models import db, Settings
from playlistmodel import PlaylistModel, PlaylistProxyModel
if TYPE_CHECKING:
from musicmuster import Window
class EscapeDelegate(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
"""
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
def createEditor(
self,
parent: Optional[QWidget],
option: QStyleOptionViewItem,
index: QModelIndex,
) -> Optional[QDoubleSpinBox | QPlainTextEdit]:
"""
Intercept createEditor call and make row just a little bit taller
"""
editor: QDoubleSpinBox | QPlainTextEdit
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)
def destroyEditor(self, editor: Optional[QWidget], index: QModelIndex) -> None:
"""
Intercept editor destroyment
"""
self.signals.enable_escape_signal.emit(True)
return super().destroyEditor(editor, index)
def editorEvent(
self, event: QEvent, model: QAbstractItemModel, option, index: QModelIndex
) -> bool:
"""Capture mouse click position."""
if event.type() == QEvent.Type.MouseButtonPress:
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)
if event.type() == QEvent.Type.Show:
if self.click_position and isinstance(editor, QPlainTextEdit):
# Map click position to editor's local space
local_click_position = editor.mapFromParent(self.click_position)
# Move cursor to the calculated position
cursor = editor.cursorForPosition(local_click_position)
editor.setTextCursor(cursor)
# Reset click position
self.click_position = None
return super().eventFilter(editor, event)
elif event.type() == QEvent.Type.KeyPress:
key_event = cast(QKeyEvent, event)
if key_event.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:
# Close editor if no changes have been made
data_modified = False
if isinstance(editor, QPlainTextEdit):
data_modified = self.original_model_data != editor.toPlainText()
elif isinstance(editor, QDoubleSpinBox):
data_modified = (
self.original_model_data != int(editor.value()) * 1000
)
if not data_modified:
self.closeEditor.emit(editor)
return True
discard_edits = QMessageBox.question(
cast(QWidget, self.parent()), "Abandon edit", "Discard changes?"
)
if discard_edits == QMessageBox.StandardButton.Yes:
self.closeEditor.emit(editor)
return True
return False
def setEditorData(self, editor, index):
proxy_model = index.model()
edit_index = proxy_model.mapToSource(index)
self.original_model_data = self.source_model.data(
edit_index, Qt.ItemDataRole.EditRole
)
if index.column() == Col.INTRO.value:
editor.setValue(self.original_model_data.value() / 1000)
else:
editor.setPlainText(self.original_model_data.value())
def setModelData(self, editor, model, index):
proxy_model = index.model()
edit_index = proxy_model.mapToSource(index)
if isinstance(editor, QPlainTextEdit):
value = editor.toPlainText().strip()
elif isinstance(editor, QDoubleSpinBox):
value = editor.value()
self.source_model.setData(edit_index, value, Qt.ItemDataRole.EditRole)
def updateEditorGeometry(self, editor, option, index):
editor.setGeometry(option.rect)
class PlaylistStyle(QProxyStyle):
def drawPrimitive(self, element, option, painter, widget=None):
"""
Draw a line across the entire row rather than just the column
we're hovering over.
"""
if (
element == QStyle.PrimitiveElement.PE_IndicatorItemViewItemDrop
and not option.rect.isNull()
):
option_new = QStyleOption(option)
option_new.rect.setLeft(0)
if widget:
option_new.rect.setRight(widget.width())
option = option_new
super().drawPrimitive(element, option, painter, widget)
class PlaylistTab(QTableView):
"""
The playlist view
"""
def __init__(
self,
musicmuster: "Window",
playlist_id: int,
) -> None:
super().__init__()
# Save passed settings
self.musicmuster = musicmuster
self.playlist_id = playlist_id
log.debug(f"PlaylistTab.__init__({playlist_id=})")
# Set up widget
self.source_model = PlaylistModel(playlist_id)
self.proxy_model = PlaylistProxyModel(self.source_model)
self.setItemDelegate(EscapeDelegate(self, self.source_model))
self.setAlternatingRowColors(True)
self.setVerticalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel)
self.setDragDropMode(QAbstractItemView.DragDropMode.InternalMove)
self.setDragDropOverwriteMode(False)
self.setAcceptDrops(True)
# Set our custom style - this draws the drop indicator across the whole row
self.setStyle(PlaylistStyle())
# We will enable dragging when rows are selected. Disabling it
# here means we can click and drag to select rows.
self.setDragEnabled(False)
# Prepare for context menu
self.menu = QMenu()
self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
self.customContextMenuRequested.connect(self._context_menu)
# Connect signals
self.signals = MusicMusterSignals()
self.signals.resize_rows_signal.connect(self.resize_rows)
self.signals.span_cells_signal.connect(self._span_cells)
# Selection model
self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
self.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
# Load playlist rows
self.setModel(self.proxy_model)
self._set_column_widths()
# Set up for Audacity
try:
self.ac = AudacityController()
except ApplicationError as e:
self.ac = None
show_warning(self.musicmuster, "Audacity error", str(e))
# Stretch last column *after* setting column widths which is
# *much* faster
h_header = self.horizontalHeader()
if isinstance(h_header, QHeaderView):
h_header.sectionResized.connect(self._column_resize)
h_header.setStretchLastSection(True)
# Setting ResizeToContents causes screen flash on load
self.resize_rows()
# ########## Overridden class functions ##########
def closeEditor(
self, editor: QWidget | None, hint: QAbstractItemDelegate.EndEditHint
) -> None:
"""
Override closeEditor to enable play controls and update display.
"""
self.musicmuster.action_Clear_selection.setEnabled(True)
super(PlaylistTab, self).closeEditor(editor, hint)
# Optimise row heights after increasing row height for editing
self.resize_rows()
# Update start times in case a start time in a note has been
# edited
self.source_model.update_track_times()
# Deselect edited line
self.clear_selection()
def dropEvent(self, event):
if event.source() is not self or (
event.dropAction() != Qt.DropAction.MoveAction
and self.dragDropMode() != QAbstractItemView.InternalMove
):
super().dropEvent(event)
from_rows = self.selected_model_row_numbers()
to_index = self.indexAt(event.position().toPoint())
if (
self.dropIndicatorPosition()
== QAbstractItemView.DropIndicatorPosition.BelowItem
):
proxy_index = self.proxy_model.createIndex(
to_index.row() + 1,
to_index.column(),
to_index.internalId(),
)
else:
proxy_index = to_index
to_model_row = self.proxy_model.mapToSource(proxy_index).row()
log.debug(
f"PlaylistTab.dropEvent(): {from_rows=}, {proxy_index=}, {to_model_row=}"
)
if (
0 <= min(from_rows) <= self.source_model.rowCount()
and 0 <= max(from_rows) <= self.source_model.rowCount()
and 0 <= to_model_row <= self.source_model.rowCount()
):
# If we move a row to immediately under the current track, make
# that moved row the next track
set_next_row: Optional[int] = None
if (
track_sequence.current
and to_model_row == track_sequence.current.row_number + 1
):
set_next_row = to_model_row
self.source_model.move_rows(from_rows, to_model_row)
# Reset drag mode to allow row selection by dragging
self.setDragEnabled(False)
# Deselect rows
self.clear_selection()
# Resize rows
self.resize_rows()
# Set next row if we are immediately under current row
if set_next_row:
self.source_model.set_next_row(set_next_row)
event.accept()
def mouseReleaseEvent(self, event):
"""
Enable dragging if rows are selected
"""
if self.selectedIndexes():
self.setDragEnabled(True)
else:
self.setDragEnabled(False)
self.reset()
super().mouseReleaseEvent(event)
def selectionChanged(
self, selected: QItemSelection, deselected: QItemSelection
) -> None:
"""
Toggle drag behaviour according to whether rows are selected
"""
selected_rows = self.get_selected_rows()
# If no rows are selected, we have nothing to do
if len(selected_rows) == 0:
self.musicmuster.lblSumPlaytime.setText("")
else:
if not self.musicmuster.disable_selection_timing:
selected_duration = self.source_model.get_rows_duration(
self.get_selected_rows()
)
if selected_duration > 0:
self.musicmuster.lblSumPlaytime.setText(
f"Selected duration: {ms_to_mmss(selected_duration)}"
)
else:
self.musicmuster.lblSumPlaytime.setText("")
else:
log.debug(
f"playlists.py.selectionChanged: {self.musicmuster.disable_selection_timing=}"
)
super().selectionChanged(selected, deselected)
# ########## Custom functions ##########
def _add_context_menu(
self,
text: str,
action: Callable,
disabled: bool = False,
parent_menu: Optional[QMenu] = None,
) -> Optional[QAction]:
"""
Add item to self.menu
"""
if parent_menu is None:
parent_menu = self.menu
menu_item = parent_menu.addAction(text)
if not menu_item:
return None
menu_item.setDisabled(disabled)
menu_item.triggered.connect(action)
return menu_item
def _add_track(self) -> None:
"""Add a track to a section header making it a normal track row"""
model_row_number = self.source_model_selected_row_number()
if model_row_number is None:
return
with db.Session() as session:
dlg = TrackSelectDialog(
parent=self.musicmuster,
session=session,
new_row_number=model_row_number,
source_model=self.source_model,
add_to_header=True,
)
dlg.exec()
session.commit()
def _build_context_menu(self, item: QTableWidgetItem) -> None:
"""Used to process context (right-click) menu, which is defined here"""
self.menu.clear()
proxy_model = self.proxy_model
index = proxy_model.index(item.row(), item.column())
model_row_number = proxy_model.mapToSource(index).row()
header_row = proxy_model.is_header_row(model_row_number)
track_row = not header_row
if track_sequence.current:
this_is_current_row = model_row_number == track_sequence.current.row_number
else:
this_is_current_row = False
if track_sequence.next:
this_is_next_row = model_row_number == track_sequence.next.row_number
else:
this_is_next_row = False
track_path = self.source_model.get_row_info(model_row_number).path
# Open/import in/from Audacity
if track_row and not this_is_current_row:
if self.ac and track_path == self.ac.path:
# This track was opened in Audacity
self._add_context_menu(
"Update from Audacity",
lambda: self._import_from_audacity(model_row_number),
)
self._add_context_menu(
"Cancel Audacity",
lambda: self._cancel_audacity(),
)
else:
self._add_context_menu(
"Open in Audacity", lambda: self._open_in_audacity(model_row_number)
)
# Rescan
if track_row and not this_is_current_row:
self._add_context_menu(
"Rescan track", lambda: self._rescan(model_row_number)
)
self._add_context_menu(
"Mark for moving", lambda: self._mark_for_moving()
)
if self.musicmuster.move_source_rows:
self._add_context_menu(
"Move selected rows here", lambda: self._move_selected_rows()
)
# ----------------------
self.menu.addSeparator()
# Delete row
if not this_is_current_row and not this_is_next_row:
self._add_context_menu("Delete row", lambda: self._delete_rows())
# Remove track from row
if track_row and not this_is_current_row and not this_is_next_row:
self._add_context_menu(
"Remove track from row",
lambda: proxy_model.remove_track(model_row_number),
)
# Remove comments
self._add_context_menu("Remove comments", lambda: self._remove_comments())
# Add track to section header (ie, make this a track row)
if header_row:
self._add_context_menu("Add a track", lambda: self._add_track())
# # ----------------------
self.menu.addSeparator()
# Mark unplayed
if track_row and proxy_model.is_played_row(model_row_number):
self._add_context_menu(
"Mark unplayed",
lambda: self._mark_as_unplayed(self.get_selected_rows()),
)
# Unmark as next
if this_is_next_row:
self._add_context_menu(
"Unmark as next track", lambda: self._unmark_as_next()
)
# ----------------------
self.menu.addSeparator()
# Sort
sort_menu = self.menu.addMenu("Sort")
self._add_context_menu(
"by title",
lambda: proxy_model.sort_by_title(self.get_selected_rows()),
parent_menu=sort_menu,
)
self._add_context_menu(
"by artist",
lambda: proxy_model.sort_by_artist(self.get_selected_rows()),
parent_menu=sort_menu,
)
self._add_context_menu(
"by duration",
lambda: proxy_model.sort_by_duration(self.get_selected_rows()),
parent_menu=sort_menu,
)
self._add_context_menu(
"by last played",
lambda: proxy_model.sort_by_lastplayed(self.get_selected_rows()),
parent_menu=sort_menu,
)
self._add_context_menu(
"randomly",
lambda: proxy_model.sort_randomly(self.get_selected_rows()),
parent_menu=sort_menu,
)
# Info
if track_row:
self._add_context_menu("Info", lambda: self._info_row(model_row_number))
# Track path
if track_row:
self._add_context_menu(
"Copy track path", lambda: self._copy_path(model_row_number)
)
def _cancel_audacity(self):
"""
Cancel Audacity editing. We don't do anything with Audacity, just "forget"
that we have an edit open.
"""
self.ac.path = None
def clear_selection(self) -> None:
"""Unselect all tracks and reset drag mode"""
self.clearSelection()
# We want to remove the focus from any widget otherwise keyboard
# activity may edit a cell.
fw = self.musicmuster.focusWidget()
if fw:
fw.clearFocus()
self.setDragEnabled(False)
def _column_resize(self, column_number: int, _old: int, _new: int) -> None:
"""
Called when column width changes. Save new width to database.
"""
log.debug(f"_column_resize({column_number=}, {_old=}, {_new=}")
header = self.horizontalHeader()
if not header:
return
# Resize rows if necessary
self.resizeRowsToContents()
with db.Session() as session:
attr_name = f"playlist_col_{column_number}_width"
record = Settings.get_setting(session, attr_name)
record.f_int = self.columnWidth(column_number)
session.commit()
def _context_menu(self, pos):
"""Display right-click menu"""
item = self.indexAt(pos)
self._build_context_menu(item)
self.menu.exec(self.mapToGlobal(pos))
def _copy_path(self, row_number: int) -> None:
"""
If passed row_number has a track, copy the track path, single-quoted,
to the clipboard. Otherwise, return None.
"""
track_path = self.source_model.get_row_info(row_number).path
if not track_path:
return
replacements = [
("'", "\\'"),
(" ", "\\ "),
("(", "\\("),
(")", "\\)"),
]
for old, new in replacements:
track_path = track_path.replace(old, new)
cb = QApplication.clipboard()
if cb:
cb.clear(mode=cb.Mode.Clipboard)
cb.setText(track_path, mode=cb.Mode.Clipboard)
def _delete_rows(self) -> None:
"""
Delete mutliple rows
Actions required:
- Confirm deletion should go ahead
- Pass to model to do the deed
"""
rows_to_delete = self.get_selected_rows()
log.debug(f"_delete_rows({rows_to_delete=}")
row_count = len(rows_to_delete)
if row_count < 1:
return
# Get confirmation
plural = "s" if row_count > 1 else ""
if not ask_yes_no("Delete rows", f"Really delete {row_count} row{plural}?"):
return
self.source_model.delete_rows(self.selected_model_row_numbers())
self.clear_selection()
def get_selected_row_track_info(self) -> Optional[TrackInfo]:
"""
Return the track_id and row number of the selected
row. If no row selected or selected row does not have a track,
return None.
"""
selected_row = self.get_selected_row()
if selected_row is None:
return None
model_row_number = self.source_model_selected_row_number()
if model_row_number is None:
return None
else:
track_id = self.source_model.get_row_track_id(model_row_number)
if not track_id:
return None
else:
return TrackInfo(track_id, selected_row)
def get_selected_row(self) -> Optional[int]:
"""
Return selected row number. If no rows or multiple rows selected, return None
"""
selected = self.get_selected_rows()
if len(selected) == 1:
return selected[0]
else:
return None
def get_selected_rows(self) -> List[int]:
"""Return a list of model-selected row numbers sorted by row"""
# Use a set to deduplicate result (a selected row will have all
# items in that row selected)
result = sorted(
list(
set(
[
self.proxy_model.mapToSource(a).row()
for a in self.selectedIndexes()
]
)
)
)
log.debug(f"get_selected_rows() returned: {result=}")
return result
def _import_from_audacity(self, row_number: int) -> None:
"""
Import current Audacity track to passed row
"""
try:
self.ac.export()
self._rescan(row_number)
except ApplicationError as e:
show_warning(self.musicmuster, "Audacity error", str(e))
self._cancel_audacity()
def _info_row(self, row_number: int) -> None:
"""Display popup with info re row"""
prd = self.source_model.get_row_info(row_number)
if prd:
txt = (
f"Title: {prd.title}\n"
f"Artist: {prd.artist}\n"
f"Track ID: {prd.track_id}\n"
f"Track duration: {ms_to_mmss(prd.duration)}\n"
f"Track bitrate: {prd.bitrate}\n"
"\n\n"
f"Path: {prd.path}\n"
)
else:
txt = f"Can't find info about row{row_number}"
show_OK(self.musicmuster, "Track info", txt)
def _mark_as_unplayed(self, row_numbers: List[int]) -> None:
"""Mark row as unplayed"""
self.source_model.mark_unplayed(row_numbers)
self.clear_selection()
def _mark_for_moving(self) -> None:
"""
Mark selected rows for pasting
"""
self.musicmuster.mark_rows_for_moving()
def _move_selected_rows(self) -> None:
"""
Move selected rows here
"""
self.musicmuster.paste_rows()
def _open_in_audacity(self, row_number: int) -> None:
"""
Open track in passed row in Audacity
"""
path = self.source_model.get_row_track_path(row_number)
if not path:
log.error(f"_open_in_audacity: can't get path for {row_number=}")
return
try:
if not self.ac:
self.ac = AudacityController()
self.ac.open(path)
except ApplicationError as e:
show_warning(self.musicmuster, "Audacity error", str(e))
def _remove_comments(self) -> None:
"""
Remove comments from selected rows
"""
row_numbers = self.selected_model_row_numbers()
if not row_numbers:
return
self.source_model.remove_comments(row_numbers)
def _rescan(self, row_number: int) -> None:
"""Rescan track"""
self.source_model.rescan_track(row_number)
self.clear_selection()
def resize_rows(self, playlist_id: Optional[int] = None) -> None:
"""
If playlist_id is us, resize rows
"""
log.debug(f"resize_rows({playlist_id=}) {self.playlist_id=}")
if playlist_id and playlist_id != self.playlist_id:
return
# Suggestion from phind.com
def resize_row(row, count=1):
row_count = self.source_model.rowCount()
for todo in range(count):
if row < row_count:
self.resizeRowToContents(row)
row += 1
if row < row_count:
QTimer.singleShot(0, lambda: resize_row(row, count))
# Start resizing from row 0, 10 rows at a time
QTimer.singleShot(0, lambda: resize_row(0, 10))
def scroll_to_top(self, row_number: int) -> None:
"""
Scroll to put passed row_number Config.SCROLL_TOP_MARGIN from the
top.
"""
if row_number is None:
return
row_index = self.proxy_model.index(row_number, 0)
self.scrollTo(row_index, QAbstractItemView.ScrollHint.PositionAtTop)
def select_duplicate_rows(self) -> None:
"""
Select the last of any rows with duplicate tracks in current playlist.
This allows the selection to typically come towards the end of the playlist away
from any show specific sections.
"""
# Clear any selected rows to avoid confustion
self.clear_selection()
# We need to be in MultiSelection mode
self.setSelectionMode(QAbstractItemView.SelectionMode.MultiSelection)
# Get the duplicate rows
duplicate_rows = self.source_model.get_duplicate_rows()
# Select the rows
for duplicate_row in duplicate_rows:
self.selectRow(duplicate_row)
# Reset selection mode
self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
def source_model_selected_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
return self.proxy_model.mapToSource(selected_index).row()
def selected_model_row_numbers(self) -> 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 []
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, "Multiple rows selected", "Select only one row"
)
return None
elif not row_indexes:
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():
return sm.selectedRows()
return []
def _set_column_widths(self) -> None:
"""Column widths from settings"""
log.debug("_set_column_widths()")
header = self.horizontalHeader()
if not header:
return
# Last column is set to stretch so ignore it here
with db.Session() as session:
for column_number in range(header.count() - 1):
attr_name = f"playlist_col_{column_number}_width"
record = Settings.get_setting(session, attr_name)
if record.f_int is not None:
self.setColumnWidth(column_number, record.f_int)
else:
self.setColumnWidth(column_number, Config.DEFAULT_COLUMN_WIDTH)
def set_row_as_next_track(self) -> None:
"""
Set selected row as next track
"""
model_row_number = self.source_model_selected_row_number()
log.debug(f"set_row_as_next_track() {model_row_number=}")
if model_row_number is None:
return
self.source_model.set_next_row(model_row_number)
self.clearSelection()
def _span_cells(
self, playlist_id: int, row: int, column: int, rowSpan: int, columnSpan: int
) -> None:
"""
Implement spanning of cells, initiated by signal
"""
if playlist_id != self.playlist_id:
return
proxy_model = self.proxy_model
edit_index = proxy_model.mapFromSource(
self.source_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.
if (
self.rowSpan(row, column) == rowSpan
and self.columnSpan(row, column) == columnSpan
):
return
self.setSpan(row, column, rowSpan, columnSpan)
def _unmark_as_next(self) -> None:
"""Rescan track"""
track_sequence.set_next(None)
self.clear_selection()
self.signals.next_track_changed_signal.emit()