Files
lintunes/lintunes/gui/track_table.py
Trav 2af0d91a76 Add Ctrl-X cut and paste-above-selection
Cut (Ctrl-X) marks the selection on the clipboard with a `cut` flag; a
paste of a cut from a real playlist is a *move* (single-undo across
playlists via move_tracks_between_playlists; same-playlist reorders),
while a copy — or a cut from the library, which has nothing to remove —
just adds. Paste now inserts *above the selected track* rather than
appending. Cut/Paste added to the track context menu; parse_tracks_mime
now tolerates a None/empty clipboard.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 21:55:38 -04:00

774 lines
30 KiB
Python

import json
import os
from PyQt6.QtWidgets import (
QTableView, QAbstractItemView, QMenu, QApplication,
)
from PyQt6.QtCore import (
Qt, QAbstractTableModel, QModelIndex, QSortFilterProxyModel, pyqtSignal,
QMimeData, QUrl, QPoint, QPointF, QRectF, QMetaType,
)
from PyQt6.QtGui import (
QDrag, QKeySequence, QShortcut, QPixmap, QPainter, QPen, QPolygonF, QColor,
QFontMetrics, QDesktopServices, QCursor,
)
from PyQt6.QtDBus import (
QDBusConnection, QDBusInterface, QDBusMessage, QDBusArgument,
)
from lintunes.models import Track
from lintunes.gui import drag_ghost
TRACKS_MIME = "application/x-lintunes-tracks"
SORT_ROLE = Qt.ItemDataRole.UserRole
# Manual play-order pseudo-column (playlist views only, always first)
INDEX_FIELD = "#"
# Column definitions: (field_name, display_name, default_width)
ALL_COLUMNS = [
("name", "Name", 250),
("artist", "Artist", 180),
("album", "Album", 180),
("album_artist", "Album Artist", 180),
("genre", "Genre", 120),
("total_time", "Time", 60),
("year", "Year", 50),
("track_number", "Track #", 60),
("disc_number", "Disc", 40),
("play_count", "Plays", 50),
("skip_count", "Skips", 50),
("rating", "Rating", 80),
("date_added", "Date Added", 140),
("date_modified", "Date Modified", 140),
("play_date_utc", "Last Played", 140),
("skip_date", "Last Skipped", 140),
("bit_rate", "Bit Rate", 60),
("sample_rate", "Sample Rate", 80),
("composer", "Composer", 150),
("grouping", "Grouping", 120),
("comments", "Comments", 150),
("bpm", "BPM", 50),
("kind", "Kind", 120),
("size", "Size", 70),
]
COLUMN_MAP = {col[0]: col for col in ALL_COLUMNS}
def format_time(ms: int) -> str:
if ms <= 0:
return ""
total_secs = round(ms / 1000)
mins, secs = divmod(total_secs, 60)
if mins >= 60:
hours, mins = divmod(mins, 60)
return f"{hours}:{mins:02d}:{secs:02d}"
return f"{mins}:{secs:02d}"
def format_total_time(ms: int) -> str:
"""Trimmed DD:HH:MM:SS: drop empty leading units, keep at least M:SS.
e.g. 14:21, 9:47:33, 24:18:42:07."""
total_secs = round(max(0, ms) / 1000)
days, rem = divmod(total_secs, 86400)
hours, rem = divmod(rem, 3600)
mins, secs = divmod(rem, 60)
parts = [days, hours, mins, secs]
while len(parts) > 2 and parts[0] == 0:
parts.pop(0)
return ":".join(str(p) if i == 0 else f"{p:02d}"
for i, p in enumerate(parts))
def _format_rating(rating: int) -> str:
if rating <= 0:
return ""
return "" * (rating // 20)
def _format_size(size: int) -> str:
if size <= 0:
return ""
mb = size / (1024 * 1024)
return f"{mb:.1f} MB"
def _format_date(iso_str: str) -> str:
return iso_str[:10] if iso_str else ""
# Now-playing speaker pixmaps, cached per (playing, color)
_speaker_cache: dict[tuple[bool, str], QPixmap] = {}
def speaker_pixmap(playing: bool, color: QColor) -> QPixmap:
"""16px speaker silhouette; with sound waves when playing."""
key = (playing, color.name())
cached = _speaker_cache.get(key)
if cached is not None:
return cached
from PyQt6.QtCore import QPointF, QRectF
# 19px wide (not 16) so the outer wave arc, which reaches ~x=17.7 with
# its pen, isn't clipped on the right. Height stays 16.
pixmap = QPixmap(19, 16)
pixmap.fill(Qt.GlobalColor.transparent)
painter = QPainter(pixmap)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
painter.setPen(Qt.PenStyle.NoPen)
painter.setBrush(color)
painter.drawPolygon(QPolygonF([
QPointF(1, 5.5), QPointF(4.5, 5.5), QPointF(8.5, 1.5),
QPointF(8.5, 14.5), QPointF(4.5, 10.5), QPointF(1, 10.5),
]))
if playing:
pen = QPen(color, 1.4)
painter.setPen(pen)
painter.setBrush(Qt.BrushStyle.NoBrush)
painter.drawArc(QRectF(9.5, 5.5, 4, 5), -60 * 16, 120 * 16)
painter.drawArc(QRectF(10.5, 3.0, 6.5, 10), -60 * 16, 120 * 16)
painter.end()
_speaker_cache[key] = pixmap
return pixmap
def make_tracks_mime(track_ids: list[int], source_playlist: str = "",
rows: list[int] | None = None, cut: bool = False) -> QMimeData:
mime = QMimeData()
payload = {"track_ids": track_ids, "source_playlist": source_playlist,
"rows": rows or [], "cut": cut}
mime.setData(TRACKS_MIME, json.dumps(payload).encode())
return mime
def parse_tracks_mime(mime: QMimeData) -> dict | None:
if mime is None or not mime.hasFormat(TRACKS_MIME):
return None
try:
return json.loads(bytes(mime.data(TRACKS_MIME)).decode())
except (ValueError, UnicodeDecodeError):
return None
def reveal_paths(paths: list[str]) -> None:
"""Select the given files in the desktop file manager.
Uses the freedesktop org.freedesktop.FileManager1 interface (Nautilus,
Dolphin, Nemo, …), which highlights the files in their folder. Falls back
to just opening the containing folder if that service is unavailable.
"""
if not paths:
return
bus = QDBusConnection.sessionBus()
if bus.isConnected():
# The URIs must be marshalled as a D-Bus string array ("as"); a plain
# Python list goes over as "av" and Nautilus rejects it (InvalidArgs).
uris = QDBusArgument()
uris.beginArray(QMetaType(QMetaType.Type.QString.value).id())
for p in paths:
uris.add(QUrl.fromLocalFile(p).toString())
uris.endArray()
iface = QDBusInterface(
"org.freedesktop.FileManager1",
"/org/freedesktop/FileManager1",
"org.freedesktop.FileManager1",
bus,
)
reply = iface.call("ShowItems", uris, "")
if reply.type() != QDBusMessage.MessageType.ErrorMessage:
return
# No FileManager1 service (or it errored): open the folder instead.
QDesktopServices.openUrl(QUrl.fromLocalFile(os.path.dirname(paths[0])))
class TrackTableModel(QAbstractTableModel):
def __init__(self, parent=None, with_index_column=False):
super().__init__(parent)
self._tracks: list[Track] = []
self._fields: list[str] = []
self._with_index = with_index_column
self._now_playing_id: int | None = None
self._now_playing_active = False
self.set_visible_columns(["name", "artist", "album"])
def set_now_playing(self, track_id: int | None, playing: bool):
old_id = self._now_playing_id
self._now_playing_id = track_id
self._now_playing_active = playing
for refresh_id in {old_id, track_id} - {None}:
self.refresh_track(refresh_id)
@property
def fields(self) -> list[str]:
return self._fields
def set_visible_columns(self, columns: list[str]):
self.beginResetModel()
fields = [c for c in columns if c in COLUMN_MAP]
if not fields:
fields = ["name"]
if self._with_index:
fields = [INDEX_FIELD] + fields
self._fields = fields
self.endResetModel()
def set_tracks(self, tracks: list[Track]):
self.beginResetModel()
self._tracks = list(tracks)
self.endResetModel()
def track_at(self, row: int) -> Track:
return self._tracks[row]
@property
def tracks(self) -> list[Track]:
return self._tracks
def refresh_track(self, track_id: int):
for row, track in enumerate(self._tracks):
if track.track_id == track_id:
self.dataChanged.emit(self.index(row, 0),
self.index(row, self.columnCount() - 1))
def flags(self, index):
# ItemIsDragEnabled is what lets the view initiate track drags
return (Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsSelectable
| Qt.ItemFlag.ItemIsDragEnabled)
def rowCount(self, parent=QModelIndex()):
return 0 if parent.isValid() else len(self._tracks)
def columnCount(self, parent=QModelIndex()):
return 0 if parent.isValid() else len(self._fields)
def headerData(self, section, orientation, role=Qt.ItemDataRole.DisplayRole):
if orientation == Qt.Orientation.Horizontal and role == Qt.ItemDataRole.DisplayRole:
field = self._fields[section]
if field == INDEX_FIELD:
return "#"
return COLUMN_MAP[field][1]
return None
def data(self, index, role=Qt.ItemDataRole.DisplayRole):
if not index.isValid():
return None
field = self._fields[index.column()]
track = self._tracks[index.row()]
if field == INDEX_FIELD:
if role == Qt.ItemDataRole.DisplayRole or role == SORT_ROLE:
return index.row() + 1
return None
if (role == Qt.ItemDataRole.DecorationRole and field == "name"
and track.track_id == self._now_playing_id):
from PyQt6.QtWidgets import QApplication
color = QApplication.palette().text().color()
return speaker_pixmap(self._now_playing_active, color)
value = getattr(track, field, "")
if role == SORT_ROLE:
if isinstance(value, str):
return value.lower()
return value if value is not None else 0
if role == Qt.ItemDataRole.DisplayRole:
if field == "total_time":
return format_time(value)
if field == "rating":
return _format_rating(value)
if field == "size":
return _format_size(value)
if field in ("date_added", "play_date_utc", "skip_date", "date_modified"):
return _format_date(value or "")
if isinstance(value, int) and value == 0:
return ""
return str(value) if value else ""
return None
class TrackTableView(QTableView):
"""Shared track list for the library and playlist views.
Owners connect to the signals and apply changes through LibraryManager;
the view itself never mutates the library.
"""
play_requested = pyqtSignal(list, int) # ids in view order, start index
sort_changed = pyqtSignal(str, bool) # field, ascending
columns_changed = pyqtSignal(list) # visible field names
column_width_changed = pyqtSignal(str, int)
reorder_requested = pyqtSignal(list, int) # manual rows, dest manual row
tracks_dropped = pyqtSignal(list, object) # track ids, insert row or None
files_dropped = pyqtSignal(list, object) # paths, insert row or None
remove_requested = pyqtSignal(list) # manual rows
paste_requested = pyqtSignal()
info_requested = pyqtSignal(list) # selected track ids
tracks_changed = pyqtSignal() # displayed track set changed
show_in_playlist_requested = pyqtSignal(int, str) # track_id, playlist pid
def __init__(self, parent=None, playlist_mode=False):
super().__init__(parent)
self._playlist_mode = playlist_mode
self._source_playlist_id = ""
self.model_ = TrackTableModel(self, with_index_column=playlist_mode)
self.proxy = QSortFilterProxyModel(self)
self.proxy.setSourceModel(self.model_)
self.proxy.setSortRole(SORT_ROLE)
self.setModel(self.proxy)
self.setAlternatingRowColors(True)
self.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
self.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
self.setSortingEnabled(True)
self.setShowGrid(False)
self.verticalHeader().setVisible(False)
self.verticalHeader().setDefaultSectionSize(22)
self.setHorizontalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel)
self.setVerticalScrollMode(QAbstractItemView.ScrollMode.ScrollPerItem)
header = self.horizontalHeader()
header.setSectionsMovable(True)
header.setStretchLastSection(True)
# Don't bold header labels when a row/cell becomes current (playback
# sets the current index, which would otherwise bold every section).
header.setHighlightSections(False)
header.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
header.customContextMenuRequested.connect(self._show_header_menu)
header.sortIndicatorChanged.connect(self._on_sort_indicator)
header.sectionResized.connect(self._on_section_resized)
self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
self.customContextMenuRequested.connect(self._show_context_menu)
self.doubleClicked.connect(self._on_double_click)
# Set by the owning view (which holds the manager) so the right-click
# menu can list the playlists a track belongs to without the view
# itself reaching into the library.
self.playlists_for_track = None # Callable[[int], list[tuple[str,str]]]
# Drag and drop (default indicator off: we paint a glowing line)
self.setDragEnabled(True)
self.setAcceptDrops(True)
self.setDropIndicatorShown(False)
self._drop_indicator_y: int | None = None
QShortcut(QKeySequence.StandardKey.Copy, self,
context=Qt.ShortcutContext.WidgetShortcut,
activated=self.copy_selection)
QShortcut(QKeySequence.StandardKey.Cut, self,
context=Qt.ShortcutContext.WidgetShortcut,
activated=self.cut_selection)
QShortcut(QKeySequence.StandardKey.Paste, self,
context=Qt.ShortcutContext.WidgetShortcut,
activated=self.paste_requested)
self._suppress_signals = False
# ---- configuration ----
def set_source_playlist(self, pid: str):
self._source_playlist_id = pid
def context_id(self) -> str:
"""Identifies what this table is currently showing, so the now-playing
icon can be scoped to the context playback started from."""
if self._playlist_mode:
return f"playlist:{self._source_playlist_id}"
return "library"
def reveal_track(self, track_id: int) -> bool:
"""Scroll to and select the row for ``track_id``; False if not shown."""
for src_row, track in enumerate(self.model_.tracks):
if track.track_id == track_id:
proxy_index = self.proxy.mapFromSource(
self.model_.index(src_row, 0))
self.setCurrentIndex(proxy_index)
self.selectRow(proxy_index.row())
self.scrollTo(
proxy_index,
QAbstractItemView.ScrollHint.PositionAtCenter)
return True
return False
def set_tracks(self, tracks: list[Track]):
self.model_.set_tracks(tracks)
self.tracks_changed.emit()
def total_stats(self) -> tuple[int, int]:
"""(track count, summed total_time in ms) of the displayed tracks."""
tracks = self.model_.tracks
return len(tracks), sum(t.total_time for t in tracks)
def apply_settings(self, settings):
"""Apply a PlaylistSettings: columns, widths, sort."""
self._suppress_signals = True
try:
self.model_.set_visible_columns(settings.visible_columns)
self._apply_default_widths()
for field, width in settings.column_widths.items():
if field in self.model_.fields:
self.setColumnWidth(self.model_.fields.index(field), width)
sort_field = settings.sort_column
if sort_field not in self.model_.fields:
sort_field = self.model_.fields[0]
order = (Qt.SortOrder.AscendingOrder if settings.sort_ascending
else Qt.SortOrder.DescendingOrder)
self.sortByColumn(self.model_.fields.index(sort_field), order)
finally:
self._suppress_signals = False
def _apply_default_widths(self):
for i, field in enumerate(self.model_.fields):
if field == INDEX_FIELD:
self.setColumnWidth(i, 50)
else:
self.setColumnWidth(i, COLUMN_MAP[field][2])
# ---- selection / ordering helpers ----
def view_order_track_ids(self) -> list[int]:
ids = []
for proxy_row in range(self.proxy.rowCount()):
source_row = self.proxy.mapToSource(self.proxy.index(proxy_row, 0)).row()
ids.append(self.model_.track_at(source_row).track_id)
return ids
def selected_source_rows(self) -> list[int]:
rows = {self.proxy.mapToSource(idx).row()
for idx in self.selectionModel().selectedRows()}
return sorted(rows)
def selected_track_ids(self) -> list[int]:
return [self.model_.track_at(r).track_id for r in self.selected_source_rows()]
def selected_locations(self) -> list[str]:
"""File paths of the selected tracks, skipping any without a location."""
locs = [self.model_.track_at(r).location
for r in self.selected_source_rows()]
return [loc for loc in locs if loc]
def is_manual_sort(self) -> bool:
header = self.horizontalHeader()
return (self._playlist_mode
and self.model_.fields
and self.model_.fields[header.sortIndicatorSection()] == INDEX_FIELD
and header.sortIndicatorOrder() == Qt.SortOrder.AscendingOrder)
# ---- copy / paste ----
def copy_selection(self):
self._put_on_clipboard(cut=False)
def cut_selection(self):
"""Mark the selection for a move: a later paste removes them from this
source (a real playlist) and inserts them at the paste target."""
self._put_on_clipboard(cut=True)
def _put_on_clipboard(self, cut: bool):
ids = self.selected_track_ids()
if not ids:
return
mime = make_tracks_mime(ids, self._source_playlist_id,
self.selected_source_rows(), cut=cut)
QApplication.clipboard().setMimeData(mime)
def paste_anchor_row(self):
"""Source-model row to paste *above* — the first selected track — or
None to append when nothing is selected."""
rows = self.selected_source_rows()
return rows[0] if rows else None
# ---- header interactions ----
def _on_sort_indicator(self, section, order):
if self._suppress_signals or not self.model_.fields:
return
field = self.model_.fields[section]
self.sort_changed.emit(field, order == Qt.SortOrder.AscendingOrder)
def _on_section_resized(self, section, _old, new_width):
if self._suppress_signals or section >= len(self.model_.fields):
return
field = self.model_.fields[section]
if field != INDEX_FIELD:
self.column_width_changed.emit(field, new_width)
def _show_header_menu(self, pos):
menu = QMenu(self)
visible = [f for f in self.model_.fields if f != INDEX_FIELD]
for field, label, _w in ALL_COLUMNS:
action = menu.addAction(label)
action.setCheckable(True)
action.setChecked(field in visible)
action.setData(field)
chosen = menu.exec(self.horizontalHeader().mapToGlobal(pos))
if chosen is None:
return
field = chosen.data()
if chosen.isChecked():
new_visible = visible + [field]
# Preserve canonical column ordering
new_visible = [f for f, _l, _w in ALL_COLUMNS if f in new_visible]
else:
new_visible = [f for f in visible if f != field]
if not new_visible:
return
self.columns_changed.emit(new_visible)
# ---- context menu ----
def _show_context_menu(self, pos):
if not self.selected_track_ids():
return
menu = QMenu(self)
info_action = menu.addAction("Get Info\tCtrl+I")
copy_action = menu.addAction("Copy\tCtrl+C")
cut_action = menu.addAction("Cut\tCtrl+X")
paste_action = None
if (self._playlist_mode
and parse_tracks_mime(QApplication.clipboard().mimeData())):
paste_action = menu.addAction("Paste\tCtrl+V")
locations = self.selected_locations()
reveal_action = copy_path_action = None
if locations:
menu.addSeparator()
reveal_action = menu.addAction("Reveal in File Browser")
copy_path_action = menu.addAction("Copy File Path")
# "Show in Playlist" — only for a single track, listing the regular
# playlists it belongs to. Hovering reveals the submenu; choosing an
# entry jumps to that playlist and highlights the first instance.
show_in_menu = None
selected = self.selected_track_ids()
if len(selected) == 1 and self.playlists_for_track is not None:
entries = self.playlists_for_track(selected[0])
if entries:
menu.addSeparator()
show_in_menu = menu.addMenu("Show in Playlist")
for pid, name in entries:
show_in_menu.addAction(name).setData(pid)
remove_action = None
if self._playlist_mode:
menu.addSeparator()
remove_action = menu.addAction("Remove from Playlist\tDel")
chosen = menu.exec(self.viewport().mapToGlobal(pos))
if chosen is None:
return
if chosen is info_action:
self.info_requested.emit(self.selected_track_ids())
elif chosen is copy_action:
self.copy_selection()
elif chosen is cut_action:
self.cut_selection()
elif paste_action is not None and chosen is paste_action:
self.paste_requested.emit()
elif chosen is reveal_action:
reveal_paths(locations)
elif chosen is copy_path_action:
QApplication.clipboard().setText("\n".join(locations))
elif show_in_menu is not None and chosen in show_in_menu.actions():
self.show_in_playlist_requested.emit(selected[0], chosen.data())
elif remove_action is not None and chosen is remove_action:
self.remove_requested.emit(self.selected_source_rows())
# ---- playback ----
def _on_double_click(self, index):
ids = self.view_order_track_ids()
if ids:
self.play_requested.emit(ids, index.row())
# ---- keyboard ----
def keyPressEvent(self, event):
if (event.key() in (Qt.Key.Key_Delete, Qt.Key.Key_Backspace)
and self._playlist_mode and self.selected_source_rows()):
self.remove_requested.emit(self.selected_source_rows())
return
if event.key() in (Qt.Key.Key_Return, Qt.Key.Key_Enter):
current = self.currentIndex()
if current.isValid():
self._on_double_click(current)
return
super().keyPressEvent(event)
# ---- drag and drop ----
def startDrag(self, supported_actions):
ids = self.selected_track_ids()
if not ids:
return
drag = QDrag(self)
drag.setMimeData(make_tracks_mime(ids, self._source_playlist_id,
self.selected_source_rows()))
# Mutter/Wayland won't render QDrag.setPixmap, so we drive our own
# cursor-following overlay (drag_ghost) instead.
pixmap, hotspot = self._drag_pixmap(ids)
drag_ghost.begin(self.window(), pixmap, hotspot, QCursor.pos())
try:
drag.exec(Qt.DropAction.CopyAction | Qt.DropAction.MoveAction)
finally:
drag_ghost.end()
def _drag_pixmap(self, ids: list[int]) -> tuple[QPixmap, QPoint]:
"""A song-file icon plus a rounded chip naming the dragged track(s).
Returns the composed pixmap and the hotspot (the icon's centre) so the
icon sits directly under the cursor with the name chip trailing right.
"""
name = self.model_.track_at(self.selected_source_rows()[0]).name
text = name or "(untitled)"
if len(ids) > 1:
text += f" +{len(ids) - 1} more"
metrics = QFontMetrics(self.font())
text = metrics.elidedText(text, Qt.TextElideMode.ElideRight, 260)
pad_x = 10
chip_w = metrics.horizontalAdvance(text) + 2 * pad_x
h = metrics.height() + 10
icon_size = h
gap = 6
w = icon_size + gap + chip_w
ratio = self.devicePixelRatioF()
pixmap = QPixmap(int(w * ratio), int(h * ratio))
pixmap.setDevicePixelRatio(ratio)
pixmap.fill(Qt.GlobalColor.transparent)
painter = QPainter(pixmap)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
painter.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform)
painter.drawPixmap(0, 0, drag_ghost.song_icon(icon_size, ratio))
chip_x = icon_size + gap
background = QColor(self.palette().highlight().color())
background.setAlpha(230)
painter.setPen(Qt.PenStyle.NoPen)
painter.setBrush(background)
painter.drawRoundedRect(QRectF(chip_x, 0, chip_w, h), h / 2, h / 2)
painter.setPen(self.palette().highlightedText().color())
painter.setFont(self.font())
painter.drawText(QRectF(chip_x + pad_x, 0, chip_w - 2 * pad_x, h),
Qt.AlignmentFlag.AlignVCenter, text)
painter.end()
return pixmap, QPoint(icon_size // 2, h // 2)
def dragEnterEvent(self, event):
if event.mimeData().hasFormat(TRACKS_MIME) or event.mimeData().hasUrls():
event.acceptProposedAction()
else:
event.ignore()
def dragMoveEvent(self, event):
drag_ghost.move(self.viewport().mapToGlobal(event.position().toPoint()))
mime = event.mimeData()
accepted = False
if mime.hasUrls():
accepted = True
else:
payload = parse_tracks_mime(mime)
if payload is None:
accepted = False
elif (self._playlist_mode and self._source_playlist_id
and payload.get("source_playlist") == self._source_playlist_id):
# reorder only allowed in manual order
accepted = self.is_manual_sort()
else:
# library view is not a drop target for tracks
accepted = self._playlist_mode
if accepted:
event.acceptProposedAction()
self._set_drop_indicator(
self._indicator_y(event.position().toPoint()))
else:
event.ignore()
self._set_drop_indicator(None)
def dragLeaveEvent(self, event):
self._set_drop_indicator(None)
super().dragLeaveEvent(event)
def dropEvent(self, event):
self._set_drop_indicator(None)
mime = event.mimeData()
drop_row = self._drop_row(event.position().toPoint())
if mime.hasUrls():
paths = [u.toLocalFile() for u in mime.urls() if u.isLocalFile()]
if paths:
self.files_dropped.emit(paths, drop_row)
event.acceptProposedAction()
return
payload = parse_tracks_mime(mime)
if payload is None or not self._playlist_mode:
return
same_playlist = (self._source_playlist_id
and payload.get("source_playlist") == self._source_playlist_id)
if same_playlist:
if self.is_manual_sort():
dest = drop_row if drop_row is not None else self.model_.rowCount()
self.reorder_requested.emit(payload.get("rows", []), dest)
event.acceptProposedAction()
else:
self.tracks_dropped.emit(payload.get("track_ids", []), drop_row)
event.acceptProposedAction()
def _drop_row(self, pos) -> int | None:
"""Manual-order row to insert before, or None to append.
Only meaningful when the view order equals manual order; otherwise
callers should append.
"""
index = self.indexAt(pos)
if not index.isValid():
return None
if not self.is_manual_sort():
return None
rect = self.visualRect(index)
row = index.row()
if pos.y() > rect.center().y():
row += 1
return row
# ---- drop indicator ----
def _indicator_y(self, pos) -> int:
"""Viewport y for the insertion line matching where the drop lands."""
count = self.proxy.rowCount()
if count == 0:
return 0
row = self._drop_row(pos)
if row is None: # drop appends
row = count
if row < count:
return self.visualRect(self.proxy.index(row, 0)).top()
return self.visualRect(self.proxy.index(count - 1, 0)).bottom() + 1
def _set_drop_indicator(self, y: int | None):
if y != self._drop_indicator_y:
self._drop_indicator_y = y
self.viewport().update()
def paintEvent(self, event):
super().paintEvent(event)
if self._drop_indicator_y is None:
return
y = max(1, min(self._drop_indicator_y, self.viewport().height() - 2))
right = self.viewport().width() - 4
painter = QPainter(self.viewport())
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
color = QColor(self.palette().highlight().color())
glow = QColor(color)
glow.setAlpha(70)
painter.setPen(QPen(glow, 6, Qt.PenStyle.SolidLine,
Qt.PenCapStyle.RoundCap))
painter.drawLine(4, y, right, y)
painter.setPen(QPen(color, 2))
painter.drawLine(4, y, right, y)
painter.setPen(Qt.PenStyle.NoPen)
painter.setBrush(color)
painter.drawEllipse(QPointF(4, y), 3, 3)
painter.drawEllipse(QPointF(right, y), 3, 3)