Files
lintunes/lintunes/gui/track_table.py
T
travandClaude Opus 5 574e476dc3 v0.9.1: playlist merges stop losing your track order
What scrambled `a nissa one` took three defects at once. Reorder it on machine
A; open it on machine B and resize the window; B's file is now newer, so the
merge takes B's order — the old one — wholesale.

_merge_playlist was a 2-way union with no common ancestor: one side's order
wholesale, the other side's extras appended at the tail, so a track inserted in
the middle on one machine arrived at the bottom on the other. merge_track_order
re-inserts each side-only track after the nearest track both copies share
instead. _reconcile_playlist (the live-reload path) uses the same helper. The
union invariant is unchanged and property-tested over 300 random pairs — a
merge never drops a track.

Whose order wins is now Playlist.date_modified, bumped only in _set_track_ids
(add / remove / reorder / undo) and deliberately not by
mark_playlist_settings_dirty, with a file-mtime fallback for playlists written
before the field existed. The file's mtime was a lie: the last column is
stretch-sized, so Qt re-fires sectionResized whenever the viewport width
changes, and a window resize or splitter drag rewrote the open playlist's JSON
— moving its mtime and handing Syncthing another conflict — for a width
apply_settings overrides on load anyway. _on_section_resized now skips that
section; genuine drags on every other column still persist.

Replayed on a copy of the real playlists: `a nissa one` keeps its reorder
against a newer-by-mtime opponent, and `a nissa ideas` takes the other
machine's two mid-list inserts at 5 and 12 rather than 26 and 27.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M8Mzze7shr5pZoEgKQU5NW
2026-08-20 21:29:03 -04:00

1104 lines
44 KiB
Python

import json
import os
from PyQt6.QtWidgets import (
QTableView, QAbstractItemView, QMenu, QApplication, QProxyStyle, QStyle,
QStyledItemDelegate, QStyleOptionViewItem,
)
from PyQt6.QtCore import (
Qt, QAbstractTableModel, QModelIndex, QPersistentModelIndex,
pyqtSignal,
QMimeData, QUrl, QPoint, QPointF, QRect, QRectF, QMetaType, QTimer,
)
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"
# While dragging a track within this many pixels of the list's top/bottom edge,
# the list auto-scrolls so off-screen rows can be reached.
AUTOSCROLL_MARGIN = 28
class ClickToJumpScrollStyle(QProxyStyle):
"""Makes a left-click on the scrollbar trough jump the thumb straight to
that spot (absolute position) instead of paging toward it a step at a time.
Wraps the default app style and only overrides the one hint."""
def styleHint(self, hint, option=None, widget=None, returnData=None):
if hint == QStyle.StyleHint.SH_ScrollBar_LeftClickAbsolutePosition:
return 1
return super().styleHint(hint, option, widget, returnData)
def autoscroll_direction(y: int, height: int, accepted: bool,
margin: int = AUTOSCROLL_MARGIN) -> int:
"""-1 to scroll up, +1 down, 0 to stop: based on how close the drag is to
the top/bottom edge. Only scrolls over a valid drop target (accepted)."""
if not accepted:
return 0
if y < margin:
return -1
if y > height - margin:
return 1
return 0
# 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)
# ---- rating column interaction (hover dots + click to rate) ----
RATING_LEFT_PAD = 2 # px before the first of the five fixed rating slots
def rating_slot_width(fm: QFontMetrics) -> int:
"""Width of one rating slot, from the view's font so the painted glyphs
and the click hit-testing stay aligned at any UI scale."""
return max(fm.horizontalAdvance("★"), fm.horizontalAdvance("•")) + 4
def rating_slot_at(x: int, slot_w: int) -> int:
"""Which slot (1..5) an x offset within the rating cell lands on."""
if slot_w <= 0:
return 1
return max(1, min(5, (x - RATING_LEFT_PAD) // slot_w + 1))
def rating_from_click(current: int, slot: int) -> int:
"""New 0-100 rating for a click on slot 1..5. Clicking the slot equal to
the current star count clears the rating (iTunes behavior — and the only
way to un-rate); anything else sets slot*20."""
new = slot * 20
return 0 if new == current else new
class RatingDelegate(QStyledItemDelegate):
"""Paints the rating column as five fixed slots: ★ for each rated star
and, on the hovered cell, • for each empty slot — so the whole 1-5 range
is clickable at a glance. Installed table-wide; every other column is
painted by the default delegate."""
def __init__(self, view):
super().__init__(view)
self._view = view
def paint(self, painter, option, index):
model = self._view.model_
if model.fields[index.column()] != "rating":
super().paint(painter, option, index)
return
opt = QStyleOptionViewItem(option)
self.initStyleOption(opt, index)
opt.text = "" # base item = themed background/selection only
style = opt.widget.style() if opt.widget else QApplication.style()
style.drawControl(QStyle.ControlElement.CE_ItemViewItem, opt,
painter, opt.widget)
track = model.track_at(model.source_row(index.row()))
stars = max(0, min(5, (track.rating or 0) // 20))
hovered = self._view.is_rating_hovered(index)
if not stars and not hovered:
return
if opt.state & QStyle.StateFlag.State_Selected:
base = opt.palette.highlightedText().color()
else:
base = opt.palette.text().color()
dot = QColor(base)
dot.setAlpha(110)
slot_w = rating_slot_width(QFontMetrics(opt.font))
painter.save()
painter.setFont(opt.font)
for slot in range(1, 6):
cell = QRect(opt.rect.left() + RATING_LEFT_PAD + (slot - 1) * slot_w,
opt.rect.top(), slot_w, opt.rect.height())
if slot <= stars:
painter.setPen(base)
painter.drawText(cell, Qt.AlignmentFlag.AlignCenter, "★")
elif hovered:
painter.setPen(dot)
painter.drawText(cell, Qt.AlignmentFlag.AlignCenter, "•")
painter.restore()
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) -> QMimeData:
mime = QMimeData()
payload = {"track_ids": track_ids, "source_playlist": source_playlist,
"rows": rows or []}
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])))
def _sort_key(value):
"""Comparable key for one cell. Everything collapses to a (text, number)
pair so a column holding a mix of strings, numbers and None never asks
Python to compare a str with a float."""
if value is None:
return ("", 0.0)
if isinstance(value, str):
return (value.casefold(), 0.0)
try:
return ("", float(value))
except (TypeError, ValueError):
return (str(value).casefold(), 0.0)
class TrackTableModel(QAbstractTableModel):
"""Track grid model that sorts itself.
``_tracks`` stays in **canonical order** — the order ``set_tracks`` was
handed, i.e. the playlist's manual order — and ``_order`` maps a displayed
row to its index in it. Sorting only reshuffles ``_order``, so "source row"
keeps meaning "position in the playlist" for drag-reorder and selection,
and sorting by the ``#`` column is just the identity order.
This replaces a QSortFilterProxyModel: the proxy called ``data()`` once per
comparison, which cost ~8.5 s per load on a 21k-track library. Sorting a
precomputed key list here is ~200x faster, and nothing needed the proxy's
filtering.
"""
def __init__(self, parent=None, with_index_column=False):
super().__init__(parent)
self._tracks: list[Track] = []
self._rows_by_id: dict[int, list[int]] = {}
self._order: list[int] = [] # display row -> canonical row
self._view_of: list[int] = [] # canonical row -> display row
self._sort: tuple[str, bool] = (INDEX_FIELD, True) # field, ascending
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)
# A track can appear multiple times in a playlist, so map each id to
# all of its rows.
self._rows_by_id = {}
for row, track in enumerate(self._tracks):
self._rows_by_id.setdefault(track.track_id, []).append(row)
self._rebuild_order()
self.endResetModel()
def _rebuild_order(self):
"""Recompute the display order from the current sort. O(n log n) with a
key computed once per track, rather than once per comparison."""
count = len(self._tracks)
field, ascending = self._sort
if field == INDEX_FIELD or field not in COLUMN_MAP:
self._order = list(range(count))
else:
keys = [_sort_key(getattr(track, field, None))
for track in self._tracks]
self._order = sorted(range(count), key=keys.__getitem__,
reverse=not ascending)
self._view_of = [0] * count
for display_row, canonical_row in enumerate(self._order):
self._view_of[canonical_row] = display_row
def sort(self, column: int, order=Qt.SortOrder.AscendingOrder):
"""Called by QTableView when a header section is clicked (or by
sortByColumn). Keeps selection alive by remapping persistent indexes."""
if not self._fields or not 0 <= column < len(self._fields):
return
self._sort = (self._fields[column],
order == Qt.SortOrder.AscendingOrder)
# Order matters: QItemSelectionModel turns the current selection into
# persistent indexes in response to layoutAboutToBeChanged, so snapshot
# the list after emitting it or the selection is dropped.
self.layoutAboutToBeChanged.emit()
old_indexes = self.persistentIndexList()
old_order = self._order
self._rebuild_order()
new_indexes = []
for index in old_indexes:
row = index.row()
if 0 <= row < len(old_order):
new_indexes.append(
self.index(self._view_of[old_order[row]], index.column()))
else:
new_indexes.append(QModelIndex())
self.changePersistentIndexList(old_indexes, new_indexes)
self.layoutChanged.emit()
def source_row(self, display_row: int) -> int:
"""Displayed row -> canonical row (what the proxy's mapToSource did)."""
if 0 <= display_row < len(self._order):
return self._order[display_row]
return -1
def display_row(self, canonical_row: int) -> int:
"""Canonical row -> displayed row (the proxy's mapFromSource)."""
if 0 <= canonical_row < len(self._view_of):
return self._view_of[canonical_row]
return -1
def track_at(self, row: int) -> Track:
return self._tracks[row]
@property
def tracks(self) -> list[Track]:
return self._tracks
def row_for_id(self, track_id: int) -> int | None:
rows = self._rows_by_id.get(track_id)
return rows[0] if rows else None
def refresh_track(self, track_id: int):
for canonical_row in self._rows_by_id.get(track_id, ()):
row = self._view_of[canonical_row]
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[self._order[index.row()]]
if field == INDEX_FIELD:
if role == Qt.ItemDataRole.DisplayRole:
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 == 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
cut_requested = pyqtSignal(list) # source rows to remove now
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
rating_edited = pyqtSignal(int, int) # track_id, new rating 0-100
download_art_requested = pyqtSignal(list) # selected track ids
remove_from_library_requested = pyqtSignal(list) # track ids, file kept
delete_from_library_requested = pyqtSignal(list) # track ids, file trashed
def __init__(self, parent=None, playlist_mode=False):
super().__init__(parent)
self._playlist_mode = playlist_mode
self._source_playlist_id = ""
# False for smart playlists: blocks manual add/remove/reorder/cut/paste.
self._content_editable = True
self.model_ = TrackTableModel(self, with_index_column=playlist_mode)
# The model sorts itself (see TrackTableModel) — no proxy in between.
self.setModel(self.model_)
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)
# Rating column: hover shows the five clickable slots, click rates.
self.setItemDelegate(RatingDelegate(self))
self.setMouseTracking(True)
self.viewport().setMouseTracking(True)
self._hover_rating_index: QPersistentModelIndex | None = None
# 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
# Edge auto-scroll during a drag (Qt's built-in autoscroll never runs
# because our dragMoveEvent accepts the event itself).
self._autoscroll_dir = 0
self._autoscroll_pos: QPoint | None = None
self._autoscroll_timer = QTimer(self)
self._autoscroll_timer.setInterval(40)
self._autoscroll_timer.timeout.connect(self._autoscroll_tick)
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 set_content_editable(self, editable: bool):
"""Smart playlists pass False: dropping, reordering, cut/paste and
remove are all disabled (membership is derived from criteria)."""
self._content_editable = editable
self.setAcceptDrops(editable)
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."""
src_row = self.model_.row_for_id(track_id)
if src_row is None:
return False
index = self.model_.index(self.model_.display_row(src_row), 0)
self.setCurrentIndex(index)
self.selectRow(index.row())
self.scrollTo(index,
QAbstractItemView.ScrollHint.PositionAtCenter)
return True
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]:
model = self.model_
return [model.track_at(model.source_row(row)).track_id
for row in range(model.rowCount())]
def selected_source_rows(self) -> list[int]:
rows = {self.model_.source_row(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):
ids = self.selected_track_ids()
if not ids:
return
QApplication.clipboard().setMimeData(make_tracks_mime(
ids, self._source_playlist_id, self.selected_source_rows()))
def cut_selection(self):
"""Copy the selection, then — in a playlist — remove it immediately so
it visibly leaves; a paste re-inserts it at the target. From the
library there's nothing to remove, so this is just a copy."""
ids = self.selected_track_ids()
if not ids:
return
rows = self.selected_source_rows()
QApplication.clipboard().setMimeData(
make_tracks_mime(ids, self._source_playlist_id, rows))
if self._playlist_mode and self._source_playlist_id and self._content_editable:
self.cut_requested.emit(rows)
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
header = self.horizontalHeader()
if (header.stretchLastSection()
and section == header.logicalIndex(header.count() - 1)):
# The last column is stretch-sized, so Qt re-fires this every time
# the viewport changes width — resizing the window or dragging the
# splitter would otherwise rewrite the whole playlist JSON (and hand
# Syncthing a conflict). Its width is derived, not chosen: there's no
# right edge to drag, and apply_settings overrides it on load.
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 self._content_editable
and parse_tracks_mime(QApplication.clipboard().mimeData())):
paste_action = menu.addAction("Paste\tCtrl+V")
locations = self.selected_locations()
reveal_action = copy_path_action = download_art_action = None
if locations:
menu.addSeparator()
reveal_action = menu.addAction("Reveal in File Browser")
copy_path_action = menu.addAction("Copy File Path")
download_art_action = menu.addAction("Download Album Art…")
# "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 and self._content_editable:
menu.addSeparator()
remove_action = menu.addAction("Remove from Playlist\tDel")
# Library removal is offered in both modes (unlike the playlist-only
# remove above) and has no keyboard shortcut — Del stays "remove from
# playlist", and touching files shouldn't be one keystroke away.
menu.addSeparator()
unlist_action = menu.addAction("Remove from Library")
delete_action = menu.addAction("Remove from Library and Delete File")
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 download_art_action is not None and chosen is download_art_action:
self.download_art_requested.emit(self.selected_track_ids())
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())
elif chosen is unlist_action:
self.remove_from_library_requested.emit(self.selected_track_ids())
elif chosen is delete_action:
self.delete_from_library_requested.emit(self.selected_track_ids())
# ---- rating hover / click ----
def _is_rating_index(self, index) -> bool:
return (index.isValid()
and self.model_.fields[index.column()] == "rating")
def is_rating_hovered(self, index) -> bool:
hover = self._hover_rating_index
return (hover is not None and hover.isValid()
and hover.row() == index.row()
and hover.column() == index.column())
def _set_rating_hover(self, index):
"""Track the hovered rating cell, repainting the old and new cells."""
new = QPersistentModelIndex(index) if index is not None else None
old = self._hover_rating_index
if new == old:
return
self._hover_rating_index = new
for persistent in (old, new):
if persistent is not None and persistent.isValid():
self.viewport().update(self.visualRect(
self.model_.index(persistent.row(), persistent.column())))
def mouseMoveEvent(self, event):
index = self.indexAt(event.position().toPoint())
self._set_rating_hover(index if self._is_rating_index(index) else None)
super().mouseMoveEvent(event)
def leaveEvent(self, event):
self._set_rating_hover(None)
super().leaveEvent(event)
def mousePressEvent(self, event):
if event.button() == Qt.MouseButton.LeftButton:
pos = event.position().toPoint()
index = self.indexAt(pos)
if self._is_rating_index(index):
track = self.model_.track_at(self.model_.source_row(index.row()))
slot = rating_slot_at(
pos.x() - self.visualRect(index).left(),
rating_slot_width(QFontMetrics(self.font())))
self.rating_edited.emit(
track.track_id, rating_from_click(track.rating or 0, slot))
return # consume: a rating click never selects or starts a drag
super().mousePressEvent(event)
# ---- 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._content_editable
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):
if not self._content_editable: # smart playlist: no drops
event.ignore()
self._set_drop_indicator(None)
return
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
pos = event.position().toPoint()
if accepted:
event.acceptProposedAction()
self._set_drop_indicator(self._indicator_y(pos))
else:
event.ignore()
self._set_drop_indicator(None)
self._update_autoscroll(pos, accepted)
def dragLeaveEvent(self, event):
self._set_drop_indicator(None)
self._stop_autoscroll()
super().dragLeaveEvent(event)
def dropEvent(self, event):
if not self._content_editable: # smart playlist: no drops
return
self._set_drop_indicator(None)
self._stop_autoscroll()
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.model_.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.model_.index(row, 0)).top()
return self.visualRect(self.model_.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()
# ---- drag auto-scroll ----
def _update_autoscroll(self, pos: QPoint, accepted: bool):
"""Start/stop edge auto-scroll based on the drag's position."""
self._autoscroll_pos = pos
self._autoscroll_dir = autoscroll_direction(
pos.y(), self.viewport().height(), accepted)
if self._autoscroll_dir and not self._autoscroll_timer.isActive():
self._autoscroll_timer.start()
elif not self._autoscroll_dir and self._autoscroll_timer.isActive():
self._autoscroll_timer.stop()
def _autoscroll_tick(self):
bar = self.verticalScrollBar()
before = bar.value()
bar.setValue(before + self._autoscroll_dir)
if bar.value() == before: # reached the top/bottom — nothing more to do
self._autoscroll_timer.stop()
return
# The cursor may be held still while content scrolls under it, so
# refresh the drop line for the rows now beneath it.
if self._autoscroll_pos is not None:
self._set_drop_indicator(self._indicator_y(self._autoscroll_pos))
def _stop_autoscroll(self):
self._autoscroll_dir = 0
self._autoscroll_pos = None
self._autoscroll_timer.stop()
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)