mirror of
https://github.com/fergalmoran/picard.git
synced 2026-02-18 05:33:59 +00:00
Merge pull request #2469 from zas/move_basetreeview
Move BaseTreeView and associated methods to its own file
This commit is contained in:
@@ -45,10 +45,6 @@
|
||||
|
||||
from collections import defaultdict
|
||||
from functools import partial
|
||||
from heapq import (
|
||||
heappop,
|
||||
heappush,
|
||||
)
|
||||
|
||||
from PyQt6 import (
|
||||
QtCore,
|
||||
@@ -56,24 +52,7 @@ from PyQt6 import (
|
||||
QtWidgets,
|
||||
)
|
||||
|
||||
from picard import log
|
||||
from picard.album import (
|
||||
Album,
|
||||
NatAlbum,
|
||||
)
|
||||
from picard.cluster import (
|
||||
Cluster,
|
||||
ClusterList,
|
||||
UnclusteredFiles,
|
||||
)
|
||||
from picard.config import get_config
|
||||
from picard.extension_points.item_actions import (
|
||||
ext_point_album_actions,
|
||||
ext_point_cluster_actions,
|
||||
ext_point_clusterlist_actions,
|
||||
ext_point_file_actions,
|
||||
ext_point_track_actions,
|
||||
)
|
||||
from picard.album import NatAlbum
|
||||
from picard.file import (
|
||||
File,
|
||||
FileErrorType,
|
||||
@@ -83,40 +62,21 @@ from picard.i18n import (
|
||||
gettext as _,
|
||||
ngettext,
|
||||
)
|
||||
from picard.track import (
|
||||
NonAlbumTrack,
|
||||
Track,
|
||||
)
|
||||
from picard.track import Track
|
||||
from picard.util import (
|
||||
icontheme,
|
||||
iter_files_from_objects,
|
||||
natsort,
|
||||
normpath,
|
||||
restore_method,
|
||||
strxfrm,
|
||||
)
|
||||
|
||||
from picard.ui.collectionmenu import CollectionMenu
|
||||
from picard.ui.colors import interface_colors
|
||||
from picard.ui.enums import MainAction
|
||||
from picard.ui.itemviews.basetreeview import BaseTreeView
|
||||
from picard.ui.itemviews.columns import (
|
||||
DEFAULT_COLUMNS,
|
||||
ITEM_ICON_COLUMN,
|
||||
ColumnAlign,
|
||||
ColumnSortType,
|
||||
)
|
||||
from picard.ui.ratingwidget import RatingWidget
|
||||
from picard.ui.scriptsmenu import ScriptsMenu
|
||||
from picard.ui.util import menu_builder
|
||||
from picard.ui.widgets.tristatesortheaderview import TristateSortHeaderView
|
||||
|
||||
|
||||
COLUMN_ICON_SIZE = 16
|
||||
COLUMN_ICON_BORDER = 2
|
||||
ICON_SIZE = QtCore.QSize(COLUMN_ICON_SIZE+COLUMN_ICON_BORDER,
|
||||
COLUMN_ICON_SIZE+COLUMN_ICON_BORDER)
|
||||
|
||||
DEFAULT_SECTION_SIZE = 100
|
||||
|
||||
|
||||
def get_match_color(similarity, basecolor):
|
||||
@@ -266,556 +226,6 @@ class MainPanel(QtWidgets.QSplitter):
|
||||
break
|
||||
|
||||
|
||||
class ConfigurableColumnsHeader(TristateSortHeaderView):
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(QtCore.Qt.Orientation.Horizontal, parent)
|
||||
self._visible_columns = set([ITEM_ICON_COLUMN])
|
||||
|
||||
self.sortIndicatorChanged.connect(self.on_sort_indicator_changed)
|
||||
|
||||
# enable sorting, but don't actually use it by default
|
||||
# XXX it would be nice to be able to go to the 'no sort' mode, but the
|
||||
# internal model that QTreeWidget uses doesn't support it
|
||||
self.setSortIndicator(-1, QtCore.Qt.SortOrder.AscendingOrder)
|
||||
|
||||
def show_column(self, column, show):
|
||||
if column == ITEM_ICON_COLUMN:
|
||||
# The first column always visible
|
||||
# Still execute following to ensure it is shown
|
||||
show = True
|
||||
self.parent().setColumnHidden(column, not show)
|
||||
if show:
|
||||
self._visible_columns.add(column)
|
||||
else:
|
||||
self._visible_columns.discard(column)
|
||||
|
||||
def contextMenuEvent(self, event):
|
||||
menu = QtWidgets.QMenu(self)
|
||||
parent = self.parent()
|
||||
|
||||
for i, column in enumerate(DEFAULT_COLUMNS):
|
||||
if i == ITEM_ICON_COLUMN:
|
||||
continue
|
||||
action = QtGui.QAction(_(column.title), parent)
|
||||
action.setCheckable(True)
|
||||
action.setChecked(i in self._visible_columns)
|
||||
action.setEnabled(not self.is_locked)
|
||||
action.triggered.connect(partial(self.show_column, i))
|
||||
menu.addAction(action)
|
||||
|
||||
menu.addSeparator()
|
||||
restore_action = QtGui.QAction(_("Restore default columns"), parent)
|
||||
restore_action.setEnabled(not self.is_locked)
|
||||
restore_action.triggered.connect(self.restore_defaults)
|
||||
menu.addAction(restore_action)
|
||||
|
||||
lock_action = QtGui.QAction(_("Lock columns"), parent)
|
||||
lock_action.setCheckable(True)
|
||||
lock_action.setChecked(self.is_locked)
|
||||
lock_action.toggled.connect(self.lock)
|
||||
menu.addAction(lock_action)
|
||||
|
||||
menu.exec(event.globalPos())
|
||||
event.accept()
|
||||
|
||||
def restore_defaults(self):
|
||||
self.parent().restore_default_columns()
|
||||
|
||||
def paintSection(self, painter, rect, index):
|
||||
column = DEFAULT_COLUMNS[index]
|
||||
if column.is_icon:
|
||||
painter.save()
|
||||
super().paintSection(painter, rect, index)
|
||||
painter.restore()
|
||||
column.paint_icon(painter, rect)
|
||||
else:
|
||||
super().paintSection(painter, rect, index)
|
||||
|
||||
def on_sort_indicator_changed(self, index, order):
|
||||
if DEFAULT_COLUMNS[index].is_icon:
|
||||
self.setSortIndicator(-1, QtCore.Qt.SortOrder.AscendingOrder)
|
||||
|
||||
def lock(self, is_locked):
|
||||
super().lock(is_locked)
|
||||
|
||||
def __str__(self):
|
||||
name = getattr(self.parent(), 'NAME', str(self.parent().__class__.__name__))
|
||||
return f"{name}'s header"
|
||||
|
||||
|
||||
def _alternative_versions(album):
|
||||
config = get_config()
|
||||
versions = album.release_group.versions
|
||||
|
||||
album_tracks_count = album.get_num_total_files() or len(album.tracks)
|
||||
preferred_countries = set(config.setting['preferred_release_countries'])
|
||||
preferred_formats = set(config.setting['preferred_release_formats'])
|
||||
ORDER_BEFORE, ORDER_AFTER = 0, 1
|
||||
|
||||
alternatives = []
|
||||
for version in versions:
|
||||
trackmatch = countrymatch = formatmatch = ORDER_BEFORE
|
||||
if version['totaltracks'] != album_tracks_count:
|
||||
trackmatch = ORDER_AFTER
|
||||
if preferred_countries:
|
||||
countries = set(version['countries'])
|
||||
if not countries or not countries.intersection(preferred_countries):
|
||||
countrymatch = ORDER_AFTER
|
||||
if preferred_formats:
|
||||
formats = set(version['formats'])
|
||||
if not formats or not formats.intersection(preferred_formats):
|
||||
formatmatch = ORDER_AFTER
|
||||
group = (trackmatch, countrymatch, formatmatch)
|
||||
# order by group, name, and id on push
|
||||
heappush(alternatives, (group, version['name'], version['id'], version['extra']))
|
||||
|
||||
while alternatives:
|
||||
yield heappop(alternatives)
|
||||
|
||||
|
||||
def _build_other_versions_actions(releases_menu, album, alternative_versions):
|
||||
heading = QtGui.QAction(album.release_group.version_headings, parent=releases_menu)
|
||||
heading.setDisabled(True)
|
||||
font = heading.font()
|
||||
font.setBold(True)
|
||||
heading.setFont(font)
|
||||
yield heading
|
||||
|
||||
prev_group = None
|
||||
for group, action_text, release_id, extra in alternative_versions:
|
||||
if group != prev_group:
|
||||
if prev_group is not None:
|
||||
sep = QtGui.QAction(parent=releases_menu)
|
||||
sep.setSeparator(True)
|
||||
yield sep
|
||||
prev_group = group
|
||||
action = QtGui.QAction(action_text, parent=releases_menu)
|
||||
action.setCheckable(True)
|
||||
if extra:
|
||||
action.setToolTip(extra)
|
||||
if album.id == release_id:
|
||||
action.setChecked(True)
|
||||
action.triggered.connect(partial(album.switch_release_version, release_id))
|
||||
yield action
|
||||
|
||||
|
||||
def _add_other_versions(releases_menu, album, action_loading):
|
||||
|
||||
if album.release_group.versions_count is not None:
|
||||
releases_menu.setTitle(_("&Other versions (%d)") % album.release_group.versions_count)
|
||||
|
||||
actions = _build_other_versions_actions(releases_menu, album, _alternative_versions(album))
|
||||
releases_menu.insertActions(action_loading, actions)
|
||||
releases_menu.removeAction(action_loading)
|
||||
|
||||
|
||||
class BaseTreeView(QtWidgets.QTreeWidget):
|
||||
|
||||
def __init__(self, window, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setAccessibleName(_(self.NAME))
|
||||
self.setAccessibleDescription(_(self.DESCRIPTION))
|
||||
self.tagger = QtCore.QCoreApplication.instance()
|
||||
self.window = window
|
||||
# Should multiple files dropped be assigned to tracks sequentially?
|
||||
self._move_to_multi_tracks = True
|
||||
|
||||
self._init_header()
|
||||
|
||||
self.setAcceptDrops(True)
|
||||
self.setDragEnabled(True)
|
||||
self.setDropIndicatorShown(True)
|
||||
self.setSelectionMode(QtWidgets.QAbstractItemView.SelectionMode.ExtendedSelection)
|
||||
|
||||
self.setSortingEnabled(True)
|
||||
|
||||
self.expand_all_action = QtGui.QAction(_("&Expand all"), self)
|
||||
self.expand_all_action.triggered.connect(self.expandAll)
|
||||
self.collapse_all_action = QtGui.QAction(_("&Collapse all"), self)
|
||||
self.collapse_all_action.triggered.connect(self.collapseAll)
|
||||
self.select_all_action = QtGui.QAction(_("Select &all"), self)
|
||||
self.select_all_action.triggered.connect(self.selectAll)
|
||||
self.select_all_action.setShortcut(QtGui.QKeySequence(_("Ctrl+A")))
|
||||
self.doubleClicked.connect(self.activate_item)
|
||||
self.setUniformRowHeights(True)
|
||||
|
||||
self.icon_plugins = icontheme.lookup('applications-system', icontheme.ICON_SIZE_MENU)
|
||||
|
||||
def contextMenuEvent(self, event):
|
||||
item = self.itemAt(event.pos())
|
||||
if not item:
|
||||
return
|
||||
config = get_config()
|
||||
obj = item.obj
|
||||
plugin_actions = None
|
||||
can_view_info = self.window.actions[MainAction.VIEW_INFO].isEnabled()
|
||||
menu = QtWidgets.QMenu(self)
|
||||
menu.setSeparatorsCollapsible(True)
|
||||
|
||||
def add_actions(*args):
|
||||
menu_builder(menu, self.window.actions, *args)
|
||||
|
||||
if isinstance(obj, Track):
|
||||
add_actions(
|
||||
MainAction.VIEW_INFO if can_view_info else None,
|
||||
)
|
||||
plugin_actions = list(ext_point_track_actions)
|
||||
if obj.num_linked_files == 1:
|
||||
add_actions(
|
||||
MainAction.PLAY_FILE,
|
||||
MainAction.OPEN_FOLDER,
|
||||
MainAction.TRACK_SEARCH,
|
||||
)
|
||||
plugin_actions.extend(ext_point_file_actions)
|
||||
add_actions(
|
||||
MainAction.BROWSER_LOOKUP,
|
||||
MainAction.GENERATE_FINGERPRINTS if obj.num_linked_files > 0 else None,
|
||||
'-',
|
||||
MainAction.REFRESH if isinstance(obj, NonAlbumTrack) else None,
|
||||
)
|
||||
elif isinstance(obj, Cluster):
|
||||
add_actions(
|
||||
MainAction.VIEW_INFO if can_view_info else None,
|
||||
MainAction.BROWSER_LOOKUP,
|
||||
MainAction.SUBMIT_CLUSTER,
|
||||
'-',
|
||||
MainAction.AUTOTAG,
|
||||
MainAction.ANALYZE,
|
||||
MainAction.CLUSTER if isinstance(obj, UnclusteredFiles) else MainAction.ALBUM_SEARCH,
|
||||
MainAction.GENERATE_FINGERPRINTS,
|
||||
)
|
||||
plugin_actions = list(ext_point_cluster_actions)
|
||||
elif isinstance(obj, ClusterList):
|
||||
add_actions(
|
||||
MainAction.AUTOTAG,
|
||||
MainAction.ANALYZE,
|
||||
MainAction.GENERATE_FINGERPRINTS,
|
||||
)
|
||||
plugin_actions = list(ext_point_clusterlist_actions)
|
||||
elif isinstance(obj, File):
|
||||
add_actions(
|
||||
MainAction.VIEW_INFO if can_view_info else None,
|
||||
MainAction.PLAY_FILE,
|
||||
MainAction.OPEN_FOLDER,
|
||||
MainAction.BROWSER_LOOKUP,
|
||||
MainAction.SUBMIT_FILE_AS_RECORDING,
|
||||
MainAction.SUBMIT_FILE_AS_RELEASE,
|
||||
'-',
|
||||
MainAction.AUTOTAG,
|
||||
MainAction.ANALYZE,
|
||||
MainAction.TRACK_SEARCH,
|
||||
MainAction.GENERATE_FINGERPRINTS,
|
||||
)
|
||||
plugin_actions = list(ext_point_file_actions)
|
||||
elif isinstance(obj, Album):
|
||||
add_actions(
|
||||
MainAction.VIEW_INFO if can_view_info else None,
|
||||
MainAction.BROWSER_LOOKUP,
|
||||
MainAction.GENERATE_FINGERPRINTS if obj.get_num_total_files() > 0 else None,
|
||||
'-',
|
||||
MainAction.REFRESH,
|
||||
)
|
||||
plugin_actions = list(ext_point_album_actions)
|
||||
|
||||
add_actions(
|
||||
'-',
|
||||
MainAction.SAVE,
|
||||
MainAction.REMOVE,
|
||||
)
|
||||
|
||||
if isinstance(obj, Album) and not isinstance(obj, NatAlbum) and obj.loaded:
|
||||
releases_menu = QtWidgets.QMenu(_("&Other versions"), menu)
|
||||
releases_menu.setToolTipsVisible(True)
|
||||
releases_menu.setEnabled(False)
|
||||
add_actions(
|
||||
'-',
|
||||
releases_menu,
|
||||
)
|
||||
action_more_details = releases_menu.addAction(_("Show &more details…"))
|
||||
action_more_details.triggered.connect(self.window.actions[MainAction.ALBUM_OTHER_VERSIONS].trigger)
|
||||
|
||||
album = obj
|
||||
if len(self.selectedItems()) == 1 and album.release_group:
|
||||
action_loading = QtGui.QAction(_("Loading…"), parent=releases_menu)
|
||||
action_loading.setDisabled(True)
|
||||
action_other_versions_separator = QtGui.QAction(parent=releases_menu)
|
||||
action_other_versions_separator.setSeparator(True)
|
||||
releases_menu.insertActions(action_more_details, [action_loading, action_other_versions_separator])
|
||||
|
||||
if album.release_group.loaded:
|
||||
_add_other_versions(releases_menu, album, action_loading)
|
||||
else:
|
||||
callback = partial(_add_other_versions, releases_menu, album, action_loading)
|
||||
album.release_group.load_versions(callback)
|
||||
releases_menu.setEnabled(True)
|
||||
|
||||
if config.setting['enable_ratings'] and \
|
||||
len(self.window.selected_objects) == 1 and isinstance(obj, Track):
|
||||
action = QtWidgets.QWidgetAction(menu)
|
||||
action.setDefaultWidget(RatingWidget(menu, obj))
|
||||
add_actions(
|
||||
'-',
|
||||
action,
|
||||
)
|
||||
|
||||
# Using type here is intentional. isinstance will return true for the
|
||||
# NatAlbum instance, which can't be part of a collection.
|
||||
selected_albums = [a for a in self.window.selected_objects if type(a) == Album] # pylint: disable=C0123 # noqa: E721
|
||||
if selected_albums:
|
||||
add_actions(
|
||||
'-',
|
||||
CollectionMenu(selected_albums, _("Collections"), menu),
|
||||
)
|
||||
|
||||
scripts = config.setting['list_of_scripts']
|
||||
|
||||
if plugin_actions:
|
||||
plugin_menu = QtWidgets.QMenu(_("P&lugins"), menu)
|
||||
plugin_menu.setIcon(self.icon_plugins)
|
||||
add_actions(
|
||||
'-',
|
||||
plugin_menu,
|
||||
)
|
||||
|
||||
plugin_menus = {}
|
||||
for action in plugin_actions:
|
||||
action_menu = plugin_menu
|
||||
for index in range(1, len(action.MENU) + 1):
|
||||
key = tuple(action.MENU[:index])
|
||||
if key in plugin_menus:
|
||||
action_menu = plugin_menus[key]
|
||||
else:
|
||||
action_menu = plugin_menus[key] = action_menu.addMenu(key[-1])
|
||||
action_menu.addAction(action)
|
||||
|
||||
if scripts:
|
||||
scripts_menu = ScriptsMenu(scripts, _("&Run scripts"), menu)
|
||||
scripts_menu.setIcon(self.icon_plugins)
|
||||
add_actions(
|
||||
'-',
|
||||
scripts_menu,
|
||||
)
|
||||
|
||||
if isinstance(obj, Cluster) or isinstance(obj, ClusterList) or isinstance(obj, Album):
|
||||
add_actions(
|
||||
'-',
|
||||
self.expand_all_action,
|
||||
self.collapse_all_action,
|
||||
)
|
||||
|
||||
add_actions(self.select_all_action)
|
||||
menu.exec(event.globalPos())
|
||||
event.accept()
|
||||
|
||||
@restore_method
|
||||
def restore_state(self):
|
||||
config = get_config()
|
||||
self.restore_default_columns()
|
||||
|
||||
header_state = config.persist[self.header_state]
|
||||
header = self.header()
|
||||
if header_state and header.restoreState(header_state):
|
||||
log.debug("Restoring state of %s" % header)
|
||||
for i in range(0, self.columnCount()):
|
||||
header.show_column(i, not self.isColumnHidden(i))
|
||||
|
||||
header.lock(config.persist[self.header_locked])
|
||||
|
||||
def save_state(self):
|
||||
config = get_config()
|
||||
header = self.header()
|
||||
if header.prelock_state is not None:
|
||||
state = header.prelock_state
|
||||
else:
|
||||
state = header.saveState()
|
||||
log.debug("Saving state of %s" % header)
|
||||
config.persist[self.header_state] = state
|
||||
config.persist[self.header_locked] = header.is_locked
|
||||
|
||||
def restore_default_columns(self):
|
||||
labels = [_(c.title) if not c.is_icon else '' for c in DEFAULT_COLUMNS]
|
||||
self.setHeaderLabels(labels)
|
||||
|
||||
header = self.header()
|
||||
header.setStretchLastSection(True)
|
||||
header.setDefaultAlignment(QtCore.Qt.AlignmentFlag.AlignLeft | QtCore.Qt.AlignmentFlag.AlignVCenter)
|
||||
header.setDefaultSectionSize(DEFAULT_SECTION_SIZE)
|
||||
|
||||
for i, c in enumerate(DEFAULT_COLUMNS):
|
||||
header.show_column(i, c.is_default)
|
||||
if c.is_icon:
|
||||
header.resizeSection(i, c.header_icon_size_with_border.width())
|
||||
header.setSectionResizeMode(i, QtWidgets.QHeaderView.ResizeMode.Fixed)
|
||||
else:
|
||||
header.resizeSection(i, c.size if c.size is not None else DEFAULT_SECTION_SIZE)
|
||||
header.setSectionResizeMode(i, QtWidgets.QHeaderView.ResizeMode.Interactive)
|
||||
|
||||
self.sortByColumn(-1, QtCore.Qt.SortOrder.AscendingOrder)
|
||||
|
||||
def _init_header(self):
|
||||
header = ConfigurableColumnsHeader(self)
|
||||
self.setHeader(header)
|
||||
self.restore_state()
|
||||
|
||||
def supportedDropActions(self):
|
||||
return QtCore.Qt.DropAction.CopyAction | QtCore.Qt.DropAction.MoveAction
|
||||
|
||||
def mimeTypes(self):
|
||||
"""List of MIME types accepted by this view."""
|
||||
return ['text/uri-list', 'application/picard.album-list']
|
||||
|
||||
def dragEnterEvent(self, event):
|
||||
super().dragEnterEvent(event)
|
||||
self._handle_external_drag(event)
|
||||
|
||||
def dragMoveEvent(self, event):
|
||||
super().dragMoveEvent(event)
|
||||
self._handle_external_drag(event)
|
||||
|
||||
def _handle_external_drag(self, event):
|
||||
if event.isAccepted() and (not event.source() or event.mimeData().hasUrls()):
|
||||
event.setDropAction(QtCore.Qt.DropAction.CopyAction)
|
||||
event.accept()
|
||||
|
||||
def startDrag(self, supportedActions):
|
||||
"""Start drag, *without* using pixmap."""
|
||||
items = self.selectedItems()
|
||||
if items:
|
||||
drag = QtGui.QDrag(self)
|
||||
drag.setMimeData(self.mimeData(items))
|
||||
# Render the dragged element as drag representation
|
||||
item = self.currentItem()
|
||||
rectangle = self.visualItemRect(item)
|
||||
pixmap = QtGui.QPixmap(rectangle.width(), rectangle.height())
|
||||
self.viewport().render(pixmap, QtCore.QPoint(), QtGui.QRegion(rectangle))
|
||||
drag.setPixmap(pixmap)
|
||||
drag.exec(QtCore.Qt.DropAction.MoveAction)
|
||||
|
||||
def mimeData(self, items):
|
||||
"""Return MIME data for specified items."""
|
||||
album_ids = []
|
||||
files = []
|
||||
url = QtCore.QUrl.fromLocalFile
|
||||
for item in items:
|
||||
obj = item.obj
|
||||
if isinstance(obj, Album):
|
||||
album_ids.append(obj.id)
|
||||
elif obj.iterfiles:
|
||||
files.extend([url(f.filename) for f in obj.iterfiles()])
|
||||
mimeData = QtCore.QMimeData()
|
||||
mimeData.setData('application/picard.album-list', '\n'.join(album_ids).encode())
|
||||
if files:
|
||||
mimeData.setUrls(files)
|
||||
return mimeData
|
||||
|
||||
def scrollTo(self, index, scrolltype=QtWidgets.QAbstractItemView.ScrollHint.EnsureVisible):
|
||||
# QTreeView.scrollTo resets the horizontal scroll position to 0.
|
||||
# Reimplemented to maintain current horizontal scroll position.
|
||||
hscrollbar = self.horizontalScrollBar()
|
||||
xpos = hscrollbar.value()
|
||||
super().scrollTo(index, scrolltype)
|
||||
hscrollbar.setValue(xpos)
|
||||
|
||||
@staticmethod
|
||||
def drop_urls(urls, target, move_to_multi_tracks=True):
|
||||
files = []
|
||||
new_paths = []
|
||||
tagger = QtCore.QCoreApplication.instance()
|
||||
for url in urls:
|
||||
log.debug("Dropped the URL: %r", url.toString(QtCore.QUrl.UrlFormattingOption.RemoveUserInfo))
|
||||
if url.scheme() == 'file' or not url.scheme():
|
||||
filename = normpath(url.toLocalFile().rstrip('\0'))
|
||||
file = tagger.files.get(filename)
|
||||
if file:
|
||||
files.append(file)
|
||||
else:
|
||||
new_paths.append(filename)
|
||||
elif url.scheme() in {'http', 'https'}:
|
||||
file_lookup = tagger.get_file_lookup()
|
||||
file_lookup.mbid_lookup(url.path(), browser_fallback=False)
|
||||
if files:
|
||||
tagger.move_files(files, target, move_to_multi_tracks)
|
||||
if new_paths:
|
||||
tagger.add_paths(new_paths, target=target)
|
||||
|
||||
def dropEvent(self, event):
|
||||
if event.proposedAction() == QtCore.Qt.DropAction.IgnoreAction:
|
||||
event.acceptProposedAction()
|
||||
return
|
||||
# Dropping with Alt key pressed forces all dropped files being
|
||||
# assigned to the same track.
|
||||
if event.modifiers() == QtCore.Qt.KeyboardModifier.AltModifier:
|
||||
self._move_to_multi_tracks = False
|
||||
QtWidgets.QTreeView.dropEvent(self, event)
|
||||
# The parent dropEvent implementation automatically accepts the proposed
|
||||
# action. Override this, for external drops we never support move (which
|
||||
# can result in file deletion, e.g. on Windows).
|
||||
if event.isAccepted() and (not event.source() or event.mimeData().hasUrls()):
|
||||
event.setDropAction(QtCore.Qt.DropAction.CopyAction)
|
||||
event.accept()
|
||||
|
||||
def dropMimeData(self, parent, index, data, action):
|
||||
target = None
|
||||
if parent:
|
||||
if index == parent.childCount():
|
||||
item = parent
|
||||
else:
|
||||
item = parent.child(index)
|
||||
if item is not None:
|
||||
target = item.obj
|
||||
if target is None:
|
||||
target = self.default_drop_target
|
||||
log.debug("Drop target = %r", target)
|
||||
handled = False
|
||||
# text/uri-list
|
||||
urls = data.urls()
|
||||
if urls:
|
||||
# Use QTimer.singleShot to run expensive processing outside of the drop handler.
|
||||
QtCore.QTimer.singleShot(0, partial(self.drop_urls, urls, target, self._move_to_multi_tracks))
|
||||
handled = True
|
||||
# application/picard.album-list
|
||||
albums = data.data('application/picard.album-list')
|
||||
if albums:
|
||||
album_ids = bytes(albums).decode().split("\n")
|
||||
log.debug("Dropped albums = %r", album_ids)
|
||||
files = iter_files_from_objects(self.tagger.load_album(id) for id in album_ids)
|
||||
# Use QTimer.singleShot to run expensive processing outside of the drop handler.
|
||||
move_files = partial(self.tagger.move_files, list(files), target)
|
||||
QtCore.QTimer.singleShot(0, move_files)
|
||||
handled = True
|
||||
self._move_to_multi_tracks = True # Reset for next drop
|
||||
return handled
|
||||
|
||||
def activate_item(self, index):
|
||||
obj = self.itemFromIndex(index).obj
|
||||
# Double-clicking albums or clusters should expand them. The album info can be
|
||||
# viewed by using the toolbar button.
|
||||
if not isinstance(obj, (Album, Cluster)) and obj.can_view_info:
|
||||
self.window.view_info()
|
||||
|
||||
def add_cluster(self, cluster, parent_item=None):
|
||||
if parent_item is None:
|
||||
parent_item = self.clusters
|
||||
cluster_item = ClusterItem(cluster, not cluster.special, parent_item)
|
||||
if cluster.hide_if_empty and not cluster.files:
|
||||
cluster_item.update()
|
||||
cluster_item.setHidden(True)
|
||||
else:
|
||||
cluster_item.add_files(cluster.files)
|
||||
|
||||
def moveCursor(self, action, modifiers):
|
||||
if action in {QtWidgets.QAbstractItemView.CursorAction.MoveUp, QtWidgets.QAbstractItemView.CursorAction.MoveDown}:
|
||||
item = self.currentItem()
|
||||
if item and not item.isSelected():
|
||||
self.setCurrentItem(item)
|
||||
return QtWidgets.QTreeWidget.moveCursor(self, action, modifiers)
|
||||
|
||||
@property
|
||||
def default_drop_target(self):
|
||||
return None
|
||||
|
||||
|
||||
class FileTreeView(BaseTreeView):
|
||||
|
||||
NAME = N_("file view")
|
||||
|
||||
652
picard/ui/itemviews/basetreeview.py
Normal file
652
picard/ui/itemviews/basetreeview.py
Normal file
@@ -0,0 +1,652 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Picard, the next-generation MusicBrainz tagger
|
||||
#
|
||||
# Copyright (C) 2006-2008, 2011-2012 Lukáš Lalinský
|
||||
# Copyright (C) 2007 Robert Kaye
|
||||
# Copyright (C) 2008 Gary van der Merwe
|
||||
# Copyright (C) 2008 Hendrik van Antwerpen
|
||||
# Copyright (C) 2008-2011, 2014-2015, 2018-2024 Philipp Wolfer
|
||||
# Copyright (C) 2009 Carlin Mangar
|
||||
# Copyright (C) 2009 Nikolai Prokoschenko
|
||||
# Copyright (C) 2011 Tim Blechmann
|
||||
# Copyright (C) 2011-2012 Chad Wilson
|
||||
# Copyright (C) 2011-2013 Michael Wiencek
|
||||
# Copyright (C) 2012 Your Name
|
||||
# Copyright (C) 2012-2013 Wieland Hoffmann
|
||||
# Copyright (C) 2013-2014, 2016, 2018-2024 Laurent Monin
|
||||
# Copyright (C) 2013-2014, 2017, 2020 Sophist-UK
|
||||
# Copyright (C) 2016 Rahul Raturi
|
||||
# Copyright (C) 2016 Simon Legner
|
||||
# Copyright (C) 2016 Suhas
|
||||
# Copyright (C) 2016-2017 Sambhav Kothari
|
||||
# Copyright (C) 2018 Vishal Choudhary
|
||||
# Copyright (C) 2020-2021 Gabriel Ferreira
|
||||
# Copyright (C) 2021 Bob Swift
|
||||
# Copyright (C) 2021 Louis Sautier
|
||||
# Copyright (C) 2021 Petit Minion
|
||||
# Copyright (C) 2023 certuna
|
||||
# Copyright (C) 2024 Suryansh Shakya
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
|
||||
|
||||
from functools import partial
|
||||
from heapq import (
|
||||
heappop,
|
||||
heappush,
|
||||
)
|
||||
|
||||
from PyQt6 import (
|
||||
QtCore,
|
||||
QtGui,
|
||||
QtWidgets,
|
||||
)
|
||||
|
||||
from picard import log
|
||||
from picard.album import (
|
||||
Album,
|
||||
NatAlbum,
|
||||
)
|
||||
from picard.cluster import (
|
||||
Cluster,
|
||||
ClusterList,
|
||||
UnclusteredFiles,
|
||||
)
|
||||
from picard.config import get_config
|
||||
from picard.extension_points.item_actions import (
|
||||
ext_point_album_actions,
|
||||
ext_point_cluster_actions,
|
||||
ext_point_clusterlist_actions,
|
||||
ext_point_file_actions,
|
||||
ext_point_track_actions,
|
||||
)
|
||||
from picard.file import File
|
||||
from picard.i18n import gettext as _
|
||||
from picard.track import (
|
||||
NonAlbumTrack,
|
||||
Track,
|
||||
)
|
||||
from picard.util import (
|
||||
icontheme,
|
||||
iter_files_from_objects,
|
||||
normpath,
|
||||
restore_method,
|
||||
)
|
||||
|
||||
from picard.ui.collectionmenu import CollectionMenu
|
||||
from picard.ui.enums import MainAction
|
||||
from picard.ui.itemviews.columns import (
|
||||
DEFAULT_COLUMNS,
|
||||
ITEM_ICON_COLUMN,
|
||||
)
|
||||
from picard.ui.ratingwidget import RatingWidget
|
||||
from picard.ui.scriptsmenu import ScriptsMenu
|
||||
from picard.ui.util import menu_builder
|
||||
from picard.ui.widgets.tristatesortheaderview import TristateSortHeaderView
|
||||
|
||||
|
||||
DEFAULT_SECTION_SIZE = 100
|
||||
|
||||
|
||||
class ConfigurableColumnsHeader(TristateSortHeaderView):
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(QtCore.Qt.Orientation.Horizontal, parent)
|
||||
self._visible_columns = set([ITEM_ICON_COLUMN])
|
||||
|
||||
self.sortIndicatorChanged.connect(self.on_sort_indicator_changed)
|
||||
|
||||
# enable sorting, but don't actually use it by default
|
||||
# XXX it would be nice to be able to go to the 'no sort' mode, but the
|
||||
# internal model that QTreeWidget uses doesn't support it
|
||||
self.setSortIndicator(-1, QtCore.Qt.SortOrder.AscendingOrder)
|
||||
|
||||
def show_column(self, column, show):
|
||||
if column == ITEM_ICON_COLUMN:
|
||||
# The first column always visible
|
||||
# Still execute following to ensure it is shown
|
||||
show = True
|
||||
self.parent().setColumnHidden(column, not show)
|
||||
if show:
|
||||
self._visible_columns.add(column)
|
||||
else:
|
||||
self._visible_columns.discard(column)
|
||||
|
||||
def contextMenuEvent(self, event):
|
||||
menu = QtWidgets.QMenu(self)
|
||||
parent = self.parent()
|
||||
|
||||
for i, column in enumerate(DEFAULT_COLUMNS):
|
||||
if i == ITEM_ICON_COLUMN:
|
||||
continue
|
||||
action = QtGui.QAction(_(column.title), parent)
|
||||
action.setCheckable(True)
|
||||
action.setChecked(i in self._visible_columns)
|
||||
action.setEnabled(not self.is_locked)
|
||||
action.triggered.connect(partial(self.show_column, i))
|
||||
menu.addAction(action)
|
||||
|
||||
menu.addSeparator()
|
||||
restore_action = QtGui.QAction(_("Restore default columns"), parent)
|
||||
restore_action.setEnabled(not self.is_locked)
|
||||
restore_action.triggered.connect(self.restore_defaults)
|
||||
menu.addAction(restore_action)
|
||||
|
||||
lock_action = QtGui.QAction(_("Lock columns"), parent)
|
||||
lock_action.setCheckable(True)
|
||||
lock_action.setChecked(self.is_locked)
|
||||
lock_action.toggled.connect(self.lock)
|
||||
menu.addAction(lock_action)
|
||||
|
||||
menu.exec(event.globalPos())
|
||||
event.accept()
|
||||
|
||||
def restore_defaults(self):
|
||||
self.parent().restore_default_columns()
|
||||
|
||||
def paintSection(self, painter, rect, index):
|
||||
column = DEFAULT_COLUMNS[index]
|
||||
if column.is_icon:
|
||||
painter.save()
|
||||
super().paintSection(painter, rect, index)
|
||||
painter.restore()
|
||||
column.paint_icon(painter, rect)
|
||||
else:
|
||||
super().paintSection(painter, rect, index)
|
||||
|
||||
def on_sort_indicator_changed(self, index, order):
|
||||
if DEFAULT_COLUMNS[index].is_icon:
|
||||
self.setSortIndicator(-1, QtCore.Qt.SortOrder.AscendingOrder)
|
||||
|
||||
def lock(self, is_locked):
|
||||
super().lock(is_locked)
|
||||
|
||||
def __str__(self):
|
||||
name = getattr(self.parent(), 'NAME', str(self.parent().__class__.__name__))
|
||||
return f"{name}'s header"
|
||||
|
||||
|
||||
def _alternative_versions(album):
|
||||
config = get_config()
|
||||
versions = album.release_group.versions
|
||||
|
||||
album_tracks_count = album.get_num_total_files() or len(album.tracks)
|
||||
preferred_countries = set(config.setting['preferred_release_countries'])
|
||||
preferred_formats = set(config.setting['preferred_release_formats'])
|
||||
ORDER_BEFORE, ORDER_AFTER = 0, 1
|
||||
|
||||
alternatives = []
|
||||
for version in versions:
|
||||
trackmatch = countrymatch = formatmatch = ORDER_BEFORE
|
||||
if version['totaltracks'] != album_tracks_count:
|
||||
trackmatch = ORDER_AFTER
|
||||
if preferred_countries:
|
||||
countries = set(version['countries'])
|
||||
if not countries or not countries.intersection(preferred_countries):
|
||||
countrymatch = ORDER_AFTER
|
||||
if preferred_formats:
|
||||
formats = set(version['formats'])
|
||||
if not formats or not formats.intersection(preferred_formats):
|
||||
formatmatch = ORDER_AFTER
|
||||
group = (trackmatch, countrymatch, formatmatch)
|
||||
# order by group, name, and id on push
|
||||
heappush(alternatives, (group, version['name'], version['id'], version['extra']))
|
||||
|
||||
while alternatives:
|
||||
yield heappop(alternatives)
|
||||
|
||||
|
||||
def _build_other_versions_actions(releases_menu, album, alternative_versions):
|
||||
heading = QtGui.QAction(album.release_group.version_headings, parent=releases_menu)
|
||||
heading.setDisabled(True)
|
||||
font = heading.font()
|
||||
font.setBold(True)
|
||||
heading.setFont(font)
|
||||
yield heading
|
||||
|
||||
prev_group = None
|
||||
for group, action_text, release_id, extra in alternative_versions:
|
||||
if group != prev_group:
|
||||
if prev_group is not None:
|
||||
sep = QtGui.QAction(parent=releases_menu)
|
||||
sep.setSeparator(True)
|
||||
yield sep
|
||||
prev_group = group
|
||||
action = QtGui.QAction(action_text, parent=releases_menu)
|
||||
action.setCheckable(True)
|
||||
if extra:
|
||||
action.setToolTip(extra)
|
||||
if album.id == release_id:
|
||||
action.setChecked(True)
|
||||
action.triggered.connect(partial(album.switch_release_version, release_id))
|
||||
yield action
|
||||
|
||||
|
||||
def _add_other_versions(releases_menu, album, action_loading):
|
||||
|
||||
if album.release_group.versions_count is not None:
|
||||
releases_menu.setTitle(_("&Other versions (%d)") % album.release_group.versions_count)
|
||||
|
||||
actions = _build_other_versions_actions(releases_menu, album, _alternative_versions(album))
|
||||
releases_menu.insertActions(action_loading, actions)
|
||||
releases_menu.removeAction(action_loading)
|
||||
|
||||
|
||||
class BaseTreeView(QtWidgets.QTreeWidget):
|
||||
|
||||
def __init__(self, window, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setAccessibleName(_(self.NAME))
|
||||
self.setAccessibleDescription(_(self.DESCRIPTION))
|
||||
self.tagger = QtCore.QCoreApplication.instance()
|
||||
self.window = window
|
||||
# Should multiple files dropped be assigned to tracks sequentially?
|
||||
self._move_to_multi_tracks = True
|
||||
|
||||
self._init_header()
|
||||
|
||||
self.setAcceptDrops(True)
|
||||
self.setDragEnabled(True)
|
||||
self.setDropIndicatorShown(True)
|
||||
self.setSelectionMode(QtWidgets.QAbstractItemView.SelectionMode.ExtendedSelection)
|
||||
|
||||
self.setSortingEnabled(True)
|
||||
|
||||
self.expand_all_action = QtGui.QAction(_("&Expand all"), self)
|
||||
self.expand_all_action.triggered.connect(self.expandAll)
|
||||
self.collapse_all_action = QtGui.QAction(_("&Collapse all"), self)
|
||||
self.collapse_all_action.triggered.connect(self.collapseAll)
|
||||
self.select_all_action = QtGui.QAction(_("Select &all"), self)
|
||||
self.select_all_action.triggered.connect(self.selectAll)
|
||||
self.select_all_action.setShortcut(QtGui.QKeySequence(_("Ctrl+A")))
|
||||
self.doubleClicked.connect(self.activate_item)
|
||||
self.setUniformRowHeights(True)
|
||||
|
||||
self.icon_plugins = icontheme.lookup('applications-system', icontheme.ICON_SIZE_MENU)
|
||||
|
||||
def contextMenuEvent(self, event):
|
||||
item = self.itemAt(event.pos())
|
||||
if not item:
|
||||
return
|
||||
config = get_config()
|
||||
obj = item.obj
|
||||
plugin_actions = None
|
||||
can_view_info = self.window.actions[MainAction.VIEW_INFO].isEnabled()
|
||||
menu = QtWidgets.QMenu(self)
|
||||
menu.setSeparatorsCollapsible(True)
|
||||
|
||||
def add_actions(*args):
|
||||
menu_builder(menu, self.window.actions, *args)
|
||||
|
||||
if isinstance(obj, Track):
|
||||
add_actions(
|
||||
MainAction.VIEW_INFO if can_view_info else None,
|
||||
)
|
||||
plugin_actions = list(ext_point_track_actions)
|
||||
if obj.num_linked_files == 1:
|
||||
add_actions(
|
||||
MainAction.PLAY_FILE,
|
||||
MainAction.OPEN_FOLDER,
|
||||
MainAction.TRACK_SEARCH,
|
||||
)
|
||||
plugin_actions.extend(ext_point_file_actions)
|
||||
add_actions(
|
||||
MainAction.BROWSER_LOOKUP,
|
||||
MainAction.GENERATE_FINGERPRINTS if obj.num_linked_files > 0 else None,
|
||||
'-',
|
||||
MainAction.REFRESH if isinstance(obj, NonAlbumTrack) else None,
|
||||
)
|
||||
elif isinstance(obj, Cluster):
|
||||
add_actions(
|
||||
MainAction.VIEW_INFO if can_view_info else None,
|
||||
MainAction.BROWSER_LOOKUP,
|
||||
MainAction.SUBMIT_CLUSTER,
|
||||
'-',
|
||||
MainAction.AUTOTAG,
|
||||
MainAction.ANALYZE,
|
||||
MainAction.CLUSTER if isinstance(obj, UnclusteredFiles) else MainAction.ALBUM_SEARCH,
|
||||
MainAction.GENERATE_FINGERPRINTS,
|
||||
)
|
||||
plugin_actions = list(ext_point_cluster_actions)
|
||||
elif isinstance(obj, ClusterList):
|
||||
add_actions(
|
||||
MainAction.AUTOTAG,
|
||||
MainAction.ANALYZE,
|
||||
MainAction.GENERATE_FINGERPRINTS,
|
||||
)
|
||||
plugin_actions = list(ext_point_clusterlist_actions)
|
||||
elif isinstance(obj, File):
|
||||
add_actions(
|
||||
MainAction.VIEW_INFO if can_view_info else None,
|
||||
MainAction.PLAY_FILE,
|
||||
MainAction.OPEN_FOLDER,
|
||||
MainAction.BROWSER_LOOKUP,
|
||||
MainAction.SUBMIT_FILE_AS_RECORDING,
|
||||
MainAction.SUBMIT_FILE_AS_RELEASE,
|
||||
'-',
|
||||
MainAction.AUTOTAG,
|
||||
MainAction.ANALYZE,
|
||||
MainAction.TRACK_SEARCH,
|
||||
MainAction.GENERATE_FINGERPRINTS,
|
||||
)
|
||||
plugin_actions = list(ext_point_file_actions)
|
||||
elif isinstance(obj, Album):
|
||||
add_actions(
|
||||
MainAction.VIEW_INFO if can_view_info else None,
|
||||
MainAction.BROWSER_LOOKUP,
|
||||
MainAction.GENERATE_FINGERPRINTS if obj.get_num_total_files() > 0 else None,
|
||||
'-',
|
||||
MainAction.REFRESH,
|
||||
)
|
||||
plugin_actions = list(ext_point_album_actions)
|
||||
|
||||
add_actions(
|
||||
'-',
|
||||
MainAction.SAVE,
|
||||
MainAction.REMOVE,
|
||||
)
|
||||
|
||||
if isinstance(obj, Album) and not isinstance(obj, NatAlbum) and obj.loaded:
|
||||
releases_menu = QtWidgets.QMenu(_("&Other versions"), menu)
|
||||
releases_menu.setToolTipsVisible(True)
|
||||
releases_menu.setEnabled(False)
|
||||
add_actions(
|
||||
'-',
|
||||
releases_menu,
|
||||
)
|
||||
action_more_details = releases_menu.addAction(_("Show &more details…"))
|
||||
action_more_details.triggered.connect(self.window.actions[MainAction.ALBUM_OTHER_VERSIONS].trigger)
|
||||
|
||||
album = obj
|
||||
if len(self.selectedItems()) == 1 and album.release_group:
|
||||
action_loading = QtGui.QAction(_("Loading…"), parent=releases_menu)
|
||||
action_loading.setDisabled(True)
|
||||
action_other_versions_separator = QtGui.QAction(parent=releases_menu)
|
||||
action_other_versions_separator.setSeparator(True)
|
||||
releases_menu.insertActions(action_more_details, [action_loading, action_other_versions_separator])
|
||||
|
||||
if album.release_group.loaded:
|
||||
_add_other_versions(releases_menu, album, action_loading)
|
||||
else:
|
||||
callback = partial(_add_other_versions, releases_menu, album, action_loading)
|
||||
album.release_group.load_versions(callback)
|
||||
releases_menu.setEnabled(True)
|
||||
|
||||
if config.setting['enable_ratings'] and \
|
||||
len(self.window.selected_objects) == 1 and isinstance(obj, Track):
|
||||
action = QtWidgets.QWidgetAction(menu)
|
||||
action.setDefaultWidget(RatingWidget(menu, obj))
|
||||
add_actions(
|
||||
'-',
|
||||
action,
|
||||
)
|
||||
|
||||
# Using type here is intentional. isinstance will return true for the
|
||||
# NatAlbum instance, which can't be part of a collection.
|
||||
selected_albums = [a for a in self.window.selected_objects if type(a) == Album] # pylint: disable=C0123 # noqa: E721
|
||||
if selected_albums:
|
||||
add_actions(
|
||||
'-',
|
||||
CollectionMenu(selected_albums, _("Collections"), menu),
|
||||
)
|
||||
|
||||
scripts = config.setting['list_of_scripts']
|
||||
|
||||
if plugin_actions:
|
||||
plugin_menu = QtWidgets.QMenu(_("P&lugins"), menu)
|
||||
plugin_menu.setIcon(self.icon_plugins)
|
||||
add_actions(
|
||||
'-',
|
||||
plugin_menu,
|
||||
)
|
||||
|
||||
plugin_menus = {}
|
||||
for action in plugin_actions:
|
||||
action_menu = plugin_menu
|
||||
for index in range(1, len(action.MENU) + 1):
|
||||
key = tuple(action.MENU[:index])
|
||||
if key in plugin_menus:
|
||||
action_menu = plugin_menus[key]
|
||||
else:
|
||||
action_menu = plugin_menus[key] = action_menu.addMenu(key[-1])
|
||||
action_menu.addAction(action)
|
||||
|
||||
if scripts:
|
||||
scripts_menu = ScriptsMenu(scripts, _("&Run scripts"), menu)
|
||||
scripts_menu.setIcon(self.icon_plugins)
|
||||
add_actions(
|
||||
'-',
|
||||
scripts_menu,
|
||||
)
|
||||
|
||||
if isinstance(obj, Cluster) or isinstance(obj, ClusterList) or isinstance(obj, Album):
|
||||
add_actions(
|
||||
'-',
|
||||
self.expand_all_action,
|
||||
self.collapse_all_action,
|
||||
)
|
||||
|
||||
add_actions(self.select_all_action)
|
||||
menu.exec(event.globalPos())
|
||||
event.accept()
|
||||
|
||||
@restore_method
|
||||
def restore_state(self):
|
||||
config = get_config()
|
||||
self.restore_default_columns()
|
||||
|
||||
header_state = config.persist[self.header_state]
|
||||
header = self.header()
|
||||
if header_state and header.restoreState(header_state):
|
||||
log.debug("Restoring state of %s" % header)
|
||||
for i in range(0, self.columnCount()):
|
||||
header.show_column(i, not self.isColumnHidden(i))
|
||||
|
||||
header.lock(config.persist[self.header_locked])
|
||||
|
||||
def save_state(self):
|
||||
config = get_config()
|
||||
header = self.header()
|
||||
if header.prelock_state is not None:
|
||||
state = header.prelock_state
|
||||
else:
|
||||
state = header.saveState()
|
||||
log.debug("Saving state of %s" % header)
|
||||
config.persist[self.header_state] = state
|
||||
config.persist[self.header_locked] = header.is_locked
|
||||
|
||||
def restore_default_columns(self):
|
||||
labels = [_(c.title) if not c.is_icon else '' for c in DEFAULT_COLUMNS]
|
||||
self.setHeaderLabels(labels)
|
||||
|
||||
header = self.header()
|
||||
header.setStretchLastSection(True)
|
||||
header.setDefaultAlignment(QtCore.Qt.AlignmentFlag.AlignLeft | QtCore.Qt.AlignmentFlag.AlignVCenter)
|
||||
header.setDefaultSectionSize(DEFAULT_SECTION_SIZE)
|
||||
|
||||
for i, c in enumerate(DEFAULT_COLUMNS):
|
||||
header.show_column(i, c.is_default)
|
||||
if c.is_icon:
|
||||
header.resizeSection(i, c.header_icon_size_with_border.width())
|
||||
header.setSectionResizeMode(i, QtWidgets.QHeaderView.ResizeMode.Fixed)
|
||||
else:
|
||||
header.resizeSection(i, c.size if c.size is not None else DEFAULT_SECTION_SIZE)
|
||||
header.setSectionResizeMode(i, QtWidgets.QHeaderView.ResizeMode.Interactive)
|
||||
|
||||
self.sortByColumn(-1, QtCore.Qt.SortOrder.AscendingOrder)
|
||||
|
||||
def _init_header(self):
|
||||
header = ConfigurableColumnsHeader(self)
|
||||
self.setHeader(header)
|
||||
self.restore_state()
|
||||
|
||||
def supportedDropActions(self):
|
||||
return QtCore.Qt.DropAction.CopyAction | QtCore.Qt.DropAction.MoveAction
|
||||
|
||||
def mimeTypes(self):
|
||||
"""List of MIME types accepted by this view."""
|
||||
return ['text/uri-list', 'application/picard.album-list']
|
||||
|
||||
def dragEnterEvent(self, event):
|
||||
super().dragEnterEvent(event)
|
||||
self._handle_external_drag(event)
|
||||
|
||||
def dragMoveEvent(self, event):
|
||||
super().dragMoveEvent(event)
|
||||
self._handle_external_drag(event)
|
||||
|
||||
def _handle_external_drag(self, event):
|
||||
if event.isAccepted() and (not event.source() or event.mimeData().hasUrls()):
|
||||
event.setDropAction(QtCore.Qt.DropAction.CopyAction)
|
||||
event.accept()
|
||||
|
||||
def startDrag(self, supportedActions):
|
||||
"""Start drag, *without* using pixmap."""
|
||||
items = self.selectedItems()
|
||||
if items:
|
||||
drag = QtGui.QDrag(self)
|
||||
drag.setMimeData(self.mimeData(items))
|
||||
# Render the dragged element as drag representation
|
||||
item = self.currentItem()
|
||||
rectangle = self.visualItemRect(item)
|
||||
pixmap = QtGui.QPixmap(rectangle.width(), rectangle.height())
|
||||
self.viewport().render(pixmap, QtCore.QPoint(), QtGui.QRegion(rectangle))
|
||||
drag.setPixmap(pixmap)
|
||||
drag.exec(QtCore.Qt.DropAction.MoveAction)
|
||||
|
||||
def mimeData(self, items):
|
||||
"""Return MIME data for specified items."""
|
||||
album_ids = []
|
||||
files = []
|
||||
url = QtCore.QUrl.fromLocalFile
|
||||
for item in items:
|
||||
obj = item.obj
|
||||
if isinstance(obj, Album):
|
||||
album_ids.append(obj.id)
|
||||
elif obj.iterfiles:
|
||||
files.extend([url(f.filename) for f in obj.iterfiles()])
|
||||
mimeData = QtCore.QMimeData()
|
||||
mimeData.setData('application/picard.album-list', '\n'.join(album_ids).encode())
|
||||
if files:
|
||||
mimeData.setUrls(files)
|
||||
return mimeData
|
||||
|
||||
def scrollTo(self, index, scrolltype=QtWidgets.QAbstractItemView.ScrollHint.EnsureVisible):
|
||||
# QTreeView.scrollTo resets the horizontal scroll position to 0.
|
||||
# Reimplemented to maintain current horizontal scroll position.
|
||||
hscrollbar = self.horizontalScrollBar()
|
||||
xpos = hscrollbar.value()
|
||||
super().scrollTo(index, scrolltype)
|
||||
hscrollbar.setValue(xpos)
|
||||
|
||||
@staticmethod
|
||||
def drop_urls(urls, target, move_to_multi_tracks=True):
|
||||
files = []
|
||||
new_paths = []
|
||||
tagger = QtCore.QCoreApplication.instance()
|
||||
for url in urls:
|
||||
log.debug("Dropped the URL: %r", url.toString(QtCore.QUrl.UrlFormattingOption.RemoveUserInfo))
|
||||
if url.scheme() == 'file' or not url.scheme():
|
||||
filename = normpath(url.toLocalFile().rstrip('\0'))
|
||||
file = tagger.files.get(filename)
|
||||
if file:
|
||||
files.append(file)
|
||||
else:
|
||||
new_paths.append(filename)
|
||||
elif url.scheme() in {'http', 'https'}:
|
||||
file_lookup = tagger.get_file_lookup()
|
||||
file_lookup.mbid_lookup(url.path(), browser_fallback=False)
|
||||
if files:
|
||||
tagger.move_files(files, target, move_to_multi_tracks)
|
||||
if new_paths:
|
||||
tagger.add_paths(new_paths, target=target)
|
||||
|
||||
def dropEvent(self, event):
|
||||
if event.proposedAction() == QtCore.Qt.DropAction.IgnoreAction:
|
||||
event.acceptProposedAction()
|
||||
return
|
||||
# Dropping with Alt key pressed forces all dropped files being
|
||||
# assigned to the same track.
|
||||
if event.modifiers() == QtCore.Qt.KeyboardModifier.AltModifier:
|
||||
self._move_to_multi_tracks = False
|
||||
QtWidgets.QTreeView.dropEvent(self, event)
|
||||
# The parent dropEvent implementation automatically accepts the proposed
|
||||
# action. Override this, for external drops we never support move (which
|
||||
# can result in file deletion, e.g. on Windows).
|
||||
if event.isAccepted() and (not event.source() or event.mimeData().hasUrls()):
|
||||
event.setDropAction(QtCore.Qt.DropAction.CopyAction)
|
||||
event.accept()
|
||||
|
||||
def dropMimeData(self, parent, index, data, action):
|
||||
target = None
|
||||
if parent:
|
||||
if index == parent.childCount():
|
||||
item = parent
|
||||
else:
|
||||
item = parent.child(index)
|
||||
if item is not None:
|
||||
target = item.obj
|
||||
if target is None:
|
||||
target = self.default_drop_target
|
||||
log.debug("Drop target = %r", target)
|
||||
handled = False
|
||||
# text/uri-list
|
||||
urls = data.urls()
|
||||
if urls:
|
||||
# Use QTimer.singleShot to run expensive processing outside of the drop handler.
|
||||
QtCore.QTimer.singleShot(0, partial(self.drop_urls, urls, target, self._move_to_multi_tracks))
|
||||
handled = True
|
||||
# application/picard.album-list
|
||||
albums = data.data('application/picard.album-list')
|
||||
if albums:
|
||||
album_ids = bytes(albums).decode().split("\n")
|
||||
log.debug("Dropped albums = %r", album_ids)
|
||||
files = iter_files_from_objects(self.tagger.load_album(id) for id in album_ids)
|
||||
# Use QTimer.singleShot to run expensive processing outside of the drop handler.
|
||||
move_files = partial(self.tagger.move_files, list(files), target)
|
||||
QtCore.QTimer.singleShot(0, move_files)
|
||||
handled = True
|
||||
self._move_to_multi_tracks = True # Reset for next drop
|
||||
return handled
|
||||
|
||||
def activate_item(self, index):
|
||||
obj = self.itemFromIndex(index).obj
|
||||
# Double-clicking albums or clusters should expand them. The album info can be
|
||||
# viewed by using the toolbar button.
|
||||
if not isinstance(obj, (Album, Cluster)) and obj.can_view_info:
|
||||
self.window.view_info()
|
||||
|
||||
def add_cluster(self, cluster, parent_item=None):
|
||||
if parent_item is None:
|
||||
parent_item = self.clusters
|
||||
from picard.ui.itemviews import ClusterItem
|
||||
cluster_item = ClusterItem(cluster, not cluster.special, parent_item)
|
||||
if cluster.hide_if_empty and not cluster.files:
|
||||
cluster_item.update()
|
||||
cluster_item.setHidden(True)
|
||||
else:
|
||||
cluster_item.add_files(cluster.files)
|
||||
|
||||
def moveCursor(self, action, modifiers):
|
||||
if action in {QtWidgets.QAbstractItemView.CursorAction.MoveUp, QtWidgets.QAbstractItemView.CursorAction.MoveDown}:
|
||||
item = self.currentItem()
|
||||
if item and not item.isSelected():
|
||||
self.setCurrentItem(item)
|
||||
return QtWidgets.QTreeWidget.moveCursor(self, action, modifiers)
|
||||
|
||||
@property
|
||||
def default_drop_target(self):
|
||||
return None
|
||||
Reference in New Issue
Block a user