Files
lintunes/lintunes/gui/sidebar.py
T
travandClaude Opus 5.5 8ffec1be82 v0.31.6: album art inside the playlists' frame; text and playhead lifted 2px
The playlist list and the album art under it share one frame (the list's
own style, drawn around both), and the no-art placeholder is a shade
lighter than that frame line so the line still reads around it. The
playlist name, times and playhead sit 2px up (HeaderRow.LIFT), on the
Library button's bottom line by eye.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 23:36:48 -07:00

846 lines
36 KiB
Python

from PyQt6.QtWidgets import (
QWidget, QVBoxLayout, QPushButton, QTreeWidget, QTreeWidgetItem, QMenu,
QInputDialog, QMessageBox, QAbstractItemView, QLineEdit, QApplication,
QHBoxLayout, QListWidget, QListWidgetItem, QStackedWidget, QSizePolicy,
QFrame,
)
from PyQt6.QtCore import Qt, QTimer, QPoint, pyqtSignal
from PyQt6.QtGui import QBrush, QPainter, QPalette, QPixmap
from lintunes.models import PlaylistType
from lintunes import tagging, theme
from lintunes.gui.track_table import (
parse_tracks_mime, TRACKS_MIME, autoscroll_direction,
)
from lintunes.gui.playlist_ops import add_tracks_with_dup_check
from lintunes.gui.art_window import ArtWindow
from lintunes.gui import drag_ghost
PID_ROLE = Qt.ItemDataRole.UserRole
KIND_ROLE = Qt.ItemDataRole.UserRole + 1 # "folder" | "playlist" | "smart"
# Rotated Floral Heart Bullet, painted in the branch area (where the folder
# triangle sits) for smart playlists, so the name stays aligned with the others.
SMART_GLYPH = "❧"
# Bare window left of the sidebar, so the playlists (and the Library button
# over them) don't run into the window's edge.
SIDEBAR_INSET = 8
class SidebarPanel(QWidget):
"""Left panel: Library button on top, playlist tree, album art nestled
flush at the bottom."""
library_selected = pyqtSignal()
playlist_selected = pyqtSignal(str)
# Friend sharing: the ▾ beside Library was clicked (show the friends
# popup), and in friend mode their library / one of their playlists was
# chosen, or a playlist's UFO (follow) toggled.
friend_selector_clicked = pyqtSignal()
friend_library_selected = pyqtSignal()
friend_playlist_selected = pyqtSignal(str)
follow_toggled = pyqtSignal(str)
def __init__(self, manager, player, parent=None):
super().__init__(parent)
layout = QVBoxLayout(self)
# A strip of bare window left of the playlists; the Library button
# above them (in the transport bar's header row) is exactly as wide
# as the list, scrollbar included — see TransportBar.track_split.
layout.setContentsMargins(SIDEBAR_INSET, 0, 0, 0)
layout.setSpacing(0)
self.friend_mode = False
top = QWidget()
top_row = QHBoxLayout(top)
top_row.setContentsMargins(0, 0, 0, 0)
top_row.setSpacing(0)
self.library_button = QPushButton("Library")
self.library_button.setCheckable(True)
self.library_button.setChecked(True)
# The library selector: a small square cut from the button's right
# end, flush against it. Shown once there is a friend to choose.
self.friend_button = QPushButton("▾")
self.friend_button.setToolTip("Browse a friend's library")
self.friend_button.setVisible(False)
for button in (self.library_button, self.friend_button):
# As tall as the header row makes the strip.
button.setSizePolicy(button.sizePolicy().horizontalPolicy(),
QSizePolicy.Policy.Expanding)
top_row.addWidget(self.library_button, stretch=1)
top_row.addWidget(self.friend_button)
self.refresh_style()
# Not in this panel's layout: MainWindow lays it into the transport
# bar's header row, over this panel's width (TransportBar.track_split).
self.header = top
top.header_owner = self
# The playlists and the album art under them are one group inside
# one frame — the list's own frame, drawn around both, so the art
# sits within it rather than hanging below.
group = QFrame()
self._lists = QStackedWidget()
self.tree = PlaylistTree(manager)
self._lists.addWidget(self.tree)
self.friend_list = FriendPlaylistList()
self._lists.addWidget(self.friend_list)
group.setFrameShape(self.tree.frameShape())
group.setFrameShadow(self.tree.frameShadow())
for view in (self.tree, self.friend_list):
view.setFrameShape(QFrame.Shape.NoFrame)
inner = QVBoxLayout(group)
inner.setContentsMargins(0, 0, 0, 0)
inner.setSpacing(0)
inner.addWidget(self._lists, stretch=1)
self.art = SidebarArt(player)
inner.addWidget(self.art)
layout.addWidget(group, stretch=1)
# What the header row lines the Library button up with.
self.list_area = group
self.library_button.clicked.connect(self._on_library_clicked)
self.tree.playlist_selected.connect(self._on_playlist_selected)
self.tree.followed_selected.connect(
lambda _key: self.library_button.setChecked(False))
self.friend_button.clicked.connect(self.friend_selector_clicked)
self.friend_list.playlist_selected.connect(self._on_friend_playlist)
self.friend_list.follow_toggled.connect(self.follow_toggled)
def _on_library_clicked(self):
self.library_button.setChecked(True)
if self.friend_mode:
self.friend_list.clearSelection()
self.friend_list.setCurrentItem(None)
self.friend_library_selected.emit()
return
self.tree.clearSelection()
self.tree.setCurrentItem(None)
self.library_selected.emit()
def _on_friend_playlist(self, pid: str):
self.library_button.setChecked(False)
self.friend_playlist_selected.emit(pid)
# ---- friend mode ----
def set_friend_selector_visible(self, visible: bool):
self.friend_button.setVisible(visible)
self.refresh_style()
def show_friend(self, library_name: str, playlists, followed: set):
"""Friend mode: the Library button becomes their library (purple) and
the tree their shared playlists, each with a UFO follow toggle."""
self.friend_mode = True
self.library_button.setText(library_name)
self.friend_list.set_playlists(playlists, followed)
self._lists.setCurrentWidget(self.friend_list)
self.library_button.setChecked(True)
self.refresh_style()
def show_mine(self):
self.friend_mode = False
self.library_button.setText("Library")
self._lists.setCurrentWidget(self.tree)
self.refresh_style()
def _on_playlist_selected(self, pid: str):
self.library_button.setChecked(False)
self.playlist_selected.emit(pid)
def current_playlist_id(self) -> str:
return self.tree.current_playlist_id()
def reflect_library(self):
"""Mirror 'Library is selected' in the sidebar without re-navigating."""
self.library_button.setChecked(True)
self.tree.blockSignals(True)
self.tree.clearSelection()
self.tree.setCurrentItem(None)
self.tree.blockSignals(False)
def reflect_playlist(self, pid: str):
"""Mirror 'this playlist is selected' without re-navigating."""
self.library_button.setChecked(False)
self.tree.select_item(pid)
def apply_metrics(self, header_strip: int):
# Square: as wide as the button is tall (the header row sets that).
self.friend_button.setFixedWidth(max(16, header_strip - 4))
def refresh_style(self):
"""(Re)build the Library button's look from the live palette, so its
emboss follows theme changes like every other button's. With the
friend selector showing, the two share a border and have square
corners where they meet; in friend mode both turn light purple."""
from lintunes.gui.cassette_art import FRIEND_PURPLE
palette = QApplication.palette()
base = palette.color(QPalette.ColorRole.AlternateBase)
lit = palette.color(QPalette.ColorRole.Highlight)
checked_text = palette.color(QPalette.ColorRole.HighlightedText).name()
if self.friend_mode:
base = lit = FRIEND_PURPLE
checked_text = "black"
split = not self.friend_button.isHidden()
joint = ("border-top-right-radius: 0; border-bottom-right-radius: 0;"
if split else "")
self.library_button.setStyleSheet(theme.button_face_qss(
"QPushButton", base, extra=f"text-align: left; padding-left: 10px; {joint}",
checked_color=lit, checked_text=checked_text))
# The ▾ is the same button cut off at the end: same face, no left
# border (it shares the Library button's), square where they meet.
self.friend_button.setStyleSheet(theme.button_face_qss(
"QPushButton", base,
extra="padding: 0; border-left: none; border-top-left-radius: 0;"
" border-bottom-left-radius: 0;"))
class FriendPlaylistList(QListWidget):
"""A friend's shared playlists in friend mode. The UFO at each row's left
is the follow toggle: purple when followed, a grey outline when not."""
playlist_selected = pyqtSignal(str)
follow_toggled = pyqtSignal(str)
def __init__(self, parent=None):
super().__init__(parent)
self.setUniformItemSizes(True)
self._followed: set[str] = set()
self.currentItemChanged.connect(self._on_current)
def set_playlists(self, playlists, followed: set):
self._followed = set(followed)
self.blockSignals(True)
self.clear()
for playlist in playlists:
item = QListWidgetItem(playlist.name)
item.setData(Qt.ItemDataRole.UserRole, playlist.pid)
self.addItem(item)
self.blockSignals(False)
self._refresh_icons()
def set_followed(self, pid: str, on: bool):
(self._followed.add if on else self._followed.discard)(pid)
self._refresh_icons()
def _refresh_icons(self):
from lintunes.gui.cassette_art import ufo_icon
on, off = ufo_icon(followed=True), ufo_icon(followed=False)
for row in range(self.count()):
item = self.item(row)
followed = item.data(Qt.ItemDataRole.UserRole) in self._followed
item.setIcon(on if followed else off)
item.setToolTip("Following — plays from your sidebar. Click the "
"UFO to stop." if followed else
"Click the UFO to follow this playlist.")
def _icon_hit(self, pos) -> bool:
index = self.indexAt(pos)
if not index.isValid():
return False
rect = self.visualRect(index)
return pos.x() - rect.left() < self.iconSize().width() + 8
def mousePressEvent(self, event):
pos = event.position().toPoint()
if event.button() == Qt.MouseButton.LeftButton and self._icon_hit(pos):
pid = self.indexAt(pos).data(Qt.ItemDataRole.UserRole)
self.follow_toggled.emit(pid)
return
super().mousePressEvent(event)
def _on_current(self, current, _previous):
if current is not None:
self.playlist_selected.emit(current.data(Qt.ItemDataRole.UserRole))
class SidebarArt(QWidget):
"""Square album art pinned to the sidebar bottom; gray square when the
current track has no embedded art. A click opens the big art window, or,
on the gray square, asks for the track's art to be downloaded."""
download_art_requested = pyqtSignal(int) # track id
def __init__(self, player, parent=None):
super().__init__(parent)
self._player = player
self._pixmap: QPixmap | None = None
self._track = None
self._art_window = ArtWindow()
player.track_changed.connect(self.set_track)
def set_track(self, track):
self._track = track
self._pixmap = None
if track is not None and track.location:
data = tagging.read_embedded_artwork(track.location)
if data:
pixmap = QPixmap()
if pixmap.loadFromData(data):
self._pixmap = pixmap
if self._pixmap is not None:
self.setToolTip("Click to view full size")
elif track is not None:
self.setToolTip("Click to download album art")
else:
self.setToolTip("")
if self._art_window.isVisible():
self._art_window.set_artwork(self._pixmap, self._title())
self.update()
def refresh(self):
"""Re-read art for the current track (after tag edits)."""
self.set_track(self._track)
def _title(self) -> str:
if self._track is None:
return "Album Art"
return (" — ".join(p for p in (self._track.artist, self._track.album) if p)
or self._track.name)
def resizeEvent(self, event):
# Keep it square: height follows the sidebar's width
if self.height() != self.width():
self.setFixedHeight(self.width())
super().resizeEvent(event)
def mouseReleaseEvent(self, event):
# A click is a release that lands on the art. Opening on press would
# also fire when a drag merely starts here.
if (event.button() == Qt.MouseButton.LeftButton
and self.rect().contains(event.position().toPoint())):
if self._pixmap is not None:
self._art_window.show_artwork(self._pixmap, self._title(),
self.screen())
elif self._track is not None:
self.download_art_requested.emit(self._track.track_id)
super().mouseReleaseEvent(event)
def paintEvent(self, event):
painter = QPainter(self)
# A shade lighter than mid: the sidebar group's frame line is drawn
# in mid, and would vanish into a placeholder of the same gray.
painter.fillRect(self.rect(), self.palette().mid().color().lighter(112))
if self._pixmap is None or self._pixmap.isNull():
return
scaled = self._pixmap.scaled(
self.size(), Qt.AspectRatioMode.KeepAspectRatioByExpanding,
Qt.TransformationMode.SmoothTransformation)
x = (self.width() - scaled.width()) // 2
y = (self.height() - scaled.height()) // 2
painter.drawPixmap(x, y, scaled)
class PlaylistTree(QTreeWidget):
"""The user's playlist folder tree. Mutations go through LibraryManager;
the tree rebuilds on its playlists_changed signal."""
playlist_selected = pyqtSignal(str) # persistent_id
export_requested = pyqtSignal(str) # persistent_id
# A friend's playlist I follow (key "cassette:<token>:<their pid>").
followed_selected = pyqtSignal(str)
unfollow_requested = pyqtSignal(str)
def __init__(self, manager, parent=None):
super().__init__(parent)
self._manager = manager
# Friend sharing: Callable[[], list[dict]] of followed playlists, each
# {key, name, token, friend, layout}. Never Playlists — they aren't
# mine, so they stay out of the library and its conflict merge.
self.followed_provider = None
self._drop_target: QTreeWidgetItem | None = None
# KIND_ROLE value a drop during the active drag would land on
# ("folder" for internal playlist moves, "playlist" for track drops).
self._drag_target_kind: str | None = None
self.setHeaderHidden(True)
self.setIndentation(14)
self.setAcceptDrops(True)
self.setDragEnabled(True)
self.setDragDropMode(QAbstractItemView.DragDropMode.DragDrop)
self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
self.customContextMenuRequested.connect(self._show_context_menu)
self.currentItemChanged.connect(self._on_selection_changed)
self.itemChanged.connect(self._on_item_renamed)
# Clicking the already-selected playlist renames it (Qt waits out the
# double-click interval first, and a press that becomes a drag never
# opens the editor).
self.setEditTriggers(self.editTriggers()
| QAbstractItemView.EditTrigger.SelectedClicked)
# Only a brand-new playlist opens its editor with everything selected,
# so typing replaces "untitled playlist"; see edit().
self._select_all_next_edit = False
# Edge auto-scroll during a drag (Qt's built-in autoscroll never runs
# because our dragMoveEvent accepts the event itself). Mirrors
# track_table's Round 13 machinery.
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)
manager.playlists_changed.connect(self.rebuild)
self.rebuild()
# ---- tree construction ----
def rebuild(self):
selected_pid = self.current_playlist_id()
# Preserve which folders the user has open across rebuilds. On the very
# first build this set is empty, so the app opens fully collapsed.
expanded = self._expanded_pids()
self.blockSignals(True)
self.clear()
playlists = self._manager.library.playlists
children: dict[str, list] = {}
for playlist in playlists.values():
parent_pid = playlist.parent_persistent_id
if parent_pid not in playlists:
parent_pid = ""
children.setdefault(parent_pid, []).append(playlist)
def add_level(parent_item, parent_pid):
for playlist in sorted(children.get(parent_pid, []),
key=lambda p: p.name.lower()):
is_folder = playlist.playlist_type == PlaylistType.FOLDER
is_smart = playlist.is_smart
item = QTreeWidgetItem([playlist.name])
item.setData(0, PID_ROLE, playlist.persistent_id)
kind = "folder" if is_folder else "smart" if is_smart else "playlist"
item.setData(0, KIND_ROLE, kind)
item.setFlags(item.flags() | Qt.ItemFlag.ItemIsEditable)
if parent_item is None:
self.addTopLevelItem(item)
else:
parent_item.addChild(item)
if is_folder:
add_level(item, playlist.persistent_id)
item.setExpanded(playlist.persistent_id in expanded)
followed = self.followed_provider() if self.followed_provider else []
mixed = [f for f in followed if f["layout"] != "folder"]
foldered: dict[str, list] = {}
for entry in followed:
if entry["layout"] == "folder":
foldered.setdefault(entry["token"], []).append(entry)
if not mixed and not foldered:
add_level(None, "")
else:
# One alphabetical top level: my playlists and folders, followed
# playlists shown mixed in, and a folder per grouped friend.
mine = {p.persistent_id: p for p in children.get("", [])}
rows = [(p.name.lower(), "mine", pid) for pid, p in mine.items()]
rows += [(f["name"].lower(), "followed", f) for f in mixed]
rows += [(entries[0]["friend"].lower(), "friend", entries)
for entries in foldered.values()]
for _key, kind, value in sorted(rows, key=lambda r: (r[0], r[1])):
if kind == "mine":
self._add_mine(value, children, expanded, add_level)
elif kind == "followed":
self.addTopLevelItem(self._followed_item(value))
else:
self.addTopLevelItem(self._friend_folder(value, expanded))
self.blockSignals(False)
if selected_pid:
item = self._find_item(selected_pid)
if item:
self.setCurrentItem(item)
def _add_mine(self, pid, children, expanded, add_level):
"""One of my top-level playlists/folders (and a folder's contents),
when the top level is being interleaved with followed ones."""
playlist = self._manager.library.playlists[pid]
is_folder = playlist.playlist_type == PlaylistType.FOLDER
item = QTreeWidgetItem([playlist.name])
item.setData(0, PID_ROLE, pid)
item.setData(0, KIND_ROLE, "folder" if is_folder else
"smart" if playlist.is_smart else "playlist")
item.setFlags(item.flags() | Qt.ItemFlag.ItemIsEditable)
self.addTopLevelItem(item)
if is_folder:
add_level(item, pid)
item.setExpanded(pid in expanded)
def _followed_item(self, entry) -> QTreeWidgetItem:
from lintunes.gui.cassette_art import ufo_icon
item = QTreeWidgetItem([entry["name"]])
item.setData(0, PID_ROLE, entry["key"])
item.setData(0, KIND_ROLE, "followed")
item.setIcon(0, ufo_icon())
item.setToolTip(0, f"{entry['friend']}'s playlist")
item.setFlags(Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsSelectable)
return item
def _friend_folder(self, entries, expanded) -> QTreeWidgetItem:
from lintunes.gui.cassette_art import ufo_icon
token = entries[0]["token"]
folder = QTreeWidgetItem([entries[0]["friend"]])
folder.setData(0, PID_ROLE, f"cassette-folder:{token}")
folder.setData(0, KIND_ROLE, "friend-folder")
folder.setIcon(0, ufo_icon())
folder.setFlags(Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsSelectable)
for entry in sorted(entries, key=lambda e: e["name"].lower()):
folder.addChild(self._followed_item(entry))
folder.setExpanded(f"cassette-folder:{token}" in expanded)
return folder
def _expanded_pids(self) -> set[str]:
"""Persistent ids of folders currently expanded in the tree."""
result: set[str] = set()
stack = [self.topLevelItem(i) for i in range(self.topLevelItemCount())]
while stack:
item = stack.pop()
if item.data(0, KIND_ROLE) in ("folder", "friend-folder") \
and item.isExpanded():
result.add(item.data(0, PID_ROLE))
stack.extend(item.child(i) for i in range(item.childCount()))
return result
def _find_item(self, pid: str):
stack = [self.topLevelItem(i) for i in range(self.topLevelItemCount())]
while stack:
item = stack.pop()
if item.data(0, PID_ROLE) == pid:
return item
stack.extend(item.child(i) for i in range(item.childCount()))
return None
def drawBranches(self, painter, rect, index):
"""Paint the ❧ for smart playlists in the branch column (where a folder's
disclosure triangle sits), so the playlist name stays left-aligned with
the others instead of being pushed right by a text prefix."""
super().drawBranches(painter, rect, index)
item = self.itemFromIndex(index)
if item is None or item.data(0, KIND_ROLE) != "smart":
return
selected = self.selectionModel().isSelected(index)
role = (self.palette().highlightedText() if selected
else self.palette().text())
painter.save()
painter.setPen(role.color())
painter.drawText(rect.adjusted(0, 0, -2, 0),
Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter,
SMART_GLYPH)
painter.restore()
def current_playlist_id(self) -> str:
item = self.currentItem()
if item is None:
return ""
return item.data(0, PID_ROLE) or ""
def select_item(self, pid: str):
"""Select a playlist row without emitting the selection signal."""
item = self._find_item(pid)
if item is not None:
self.blockSignals(True)
self.setCurrentItem(item)
self.blockSignals(False)
def create_playlist_interactive(self, parent_pid: str = ""):
"""Create a playlist, select it, and open inline rename so the user can
name it just by typing (Enter commits, Escape keeps "untitled playlist").
The manager's playlists_changed signal rebuilds the tree synchronously,
so the new item already exists by the time create_playlist() returns."""
playlist = self._manager.create_playlist("untitled playlist", parent_pid)
item = self._find_item(playlist.persistent_id)
if item is not None:
self.setCurrentItem(item) # selects + navigates to the new playlist
self._select_all_next_edit = True
self.editItem(item, 0) # live inline rename; editor selects all
def create_smart_playlist_interactive(self, parent_pid: str = ""):
"""Open the criteria editor; on accept, create the smart playlist and
drop into inline rename (mirrors create_playlist_interactive)."""
from lintunes.gui.smart_playlist_dialog import SmartPlaylistEditorDialog
from lintunes.smart import SmartCriteria
dialog = SmartPlaylistEditorDialog(SmartCriteria(), self,
title="New Smart Playlist")
if not dialog.exec():
return
playlist = self._manager.create_smart_playlist(
"untitled smart playlist", dialog.criteria(), parent_pid)
item = self._find_item(playlist.persistent_id)
if item is not None:
self.setCurrentItem(item)
self._select_all_next_edit = True
self.editItem(item, 0)
def edit_smart_playlist(self, pid: str):
playlist = self._manager.library.playlists.get(pid)
if not playlist or not playlist.is_smart:
return
from lintunes.gui.smart_playlist_dialog import SmartPlaylistEditorDialog
dialog = SmartPlaylistEditorDialog(playlist.smart_criteria, self,
title="Edit Smart Playlist")
if dialog.exec():
self._manager.set_smart_criteria(pid, dialog.criteria())
# ---- selection / rename ----
def _on_selection_changed(self, current, _previous):
if current is None:
return
if current.data(0, KIND_ROLE) in ("playlist", "smart"):
self.playlist_selected.emit(current.data(0, PID_ROLE))
elif current.data(0, KIND_ROLE) == "followed":
self.followed_selected.emit(current.data(0, PID_ROLE))
def edit(self, index, trigger=QAbstractItemView.EditTrigger.AllEditTriggers,
event=None):
"""Every inline rename (click, double-click, F2, context menu) comes
through here. Qt selects the editor's whole text inside this call,
after the delegate has run, so renaming an existing playlist undoes
that afterwards: nothing selected, cursor at the end. Defaulted args
because this override shadows PyQt's one-argument edit() slot too."""
opened = super().edit(index, trigger, event)
# A SelectedClicked edit returns True having only armed Qt's
# double-click timer; the editor opens when that fires (through here
# again), so only a real editor settles the one-shot flag.
editor = self.indexWidget(index) if opened else None
if isinstance(editor, QLineEdit):
if not self._select_all_next_edit:
editor.deselect()
editor.end(False)
self._select_all_next_edit = False
return opened
def _on_item_renamed(self, item, _column):
pid = item.data(0, PID_ROLE)
if pid:
self._manager.rename_playlist(pid, item.text(0).strip())
# ---- context menu ----
def _show_context_menu(self, pos):
item = self.itemAt(pos)
kind = item.data(0, KIND_ROLE) if item else ""
pid = item.data(0, PID_ROLE) if item else ""
if kind == "followed":
menu = QMenu(self)
unfollow = menu.addAction("Unfollow")
if menu.exec(self.viewport().mapToGlobal(pos)) is unfollow:
self.unfollow_requested.emit(pid)
return
if kind == "friend-folder":
return
# New items land inside the folder under the cursor (or at top level)
parent_pid = ""
if kind == "folder":
parent_pid = pid
elif kind in ("playlist", "smart"):
playlist = self._manager.library.playlists.get(pid)
if playlist:
parent_pid = playlist.parent_persistent_id
menu = QMenu(self)
new_playlist = menu.addAction("New Playlist")
new_smart = menu.addAction("New Smart Playlist")
new_folder = menu.addAction("New Playlist Folder")
rename = delete = edit_smart = export = None
if kind in ("playlist", "folder", "smart"):
menu.addSeparator()
if kind == "smart":
edit_smart = menu.addAction("Edit Smart Playlist…")
rename = menu.addAction("Rename")
delete = menu.addAction("Delete")
if kind in ("playlist", "smart"):
menu.addSeparator()
export = menu.addAction("Export Playlist…")
chosen = menu.exec(self.viewport().mapToGlobal(pos))
if chosen is None:
return
if chosen is new_playlist:
self.create_playlist_interactive(parent_pid)
elif chosen is new_smart:
self.create_smart_playlist_interactive(parent_pid)
elif edit_smart is not None and chosen is edit_smart:
self.edit_smart_playlist(pid)
elif chosen is new_folder:
name, ok = QInputDialog.getText(self, "New Playlist Folder", "Name:",
text="untitled folder")
if ok and name.strip():
self._manager.create_folder(name.strip(), parent_pid)
elif export is not None and chosen is export:
self.export_requested.emit(pid)
elif rename is not None and chosen is rename:
self.editItem(item, 0)
elif delete is not None and chosen is delete:
label = "folder (and everything in it)" if kind == "folder" else "playlist"
answer = QMessageBox.question(
self, "Delete", f'Delete the {label} "{item.text(0)}"?')
if answer == QMessageBox.StandardButton.Yes:
self._manager.delete_playlist(pid)
# ---- drag and drop ----
def mimeTypes(self):
return [TRACKS_MIME]
def dragEnterEvent(self, event):
if event.mimeData().hasFormat(TRACKS_MIME) or event.source() is self:
event.acceptProposedAction()
else:
event.ignore()
def dragMoveEvent(self, event):
pos = event.position().toPoint()
drag_ghost.move(self.viewport().mapToGlobal(pos))
if event.source() is self:
# Moving a playlist into/out of folders. The tree accepts an
# internal move somewhere no matter what (empty area = top level),
# so edge auto-scroll stays live even over non-folder rows —
# otherwise scrolling down to a distant folder would be nearly
# impossible past the playlists in between.
self._drag_target_kind = "folder"
target = self.itemAt(pos)
kind = target.data(0, KIND_ROLE) if target else ""
if kind in ("folder", "") or target is None:
event.acceptProposedAction()
else:
event.ignore()
self._highlight_drop_target(pos, "folder")
self._update_autoscroll(pos, accepted=True)
return
if not event.mimeData().hasFormat(TRACKS_MIME):
self._clear_drop_target()
self._update_autoscroll(pos, accepted=False)
event.ignore()
return
self._drag_target_kind = "playlist"
target = self.itemAt(pos)
if target and target.data(0, KIND_ROLE) == "playlist":
event.acceptProposedAction()
self._update_autoscroll(pos, accepted=True)
else:
event.ignore()
self._update_autoscroll(pos, accepted=False)
self._highlight_drop_target(pos, "playlist")
def dragLeaveEvent(self, event):
self._clear_drop_target()
self._stop_autoscroll()
super().dragLeaveEvent(event)
def _highlight_drop_target(self, pos: QPoint, kind: str):
"""Highlight the row at ``pos`` if it's a valid target for the active
drag — a folder for internal playlist moves, a playlist for track
drops — so the user can see where the drop would land. Clears any
stale highlight otherwise. A folder inside the dragged row's own
branch is never highlighted: the manager would reject that drop as a
cycle, so lighting it up would lie."""
target = self.itemAt(pos)
ok = target is not None and target.data(0, KIND_ROLE) == kind
if ok and kind == "folder":
ok = not self._is_own_branch(self.currentItem(), target)
if ok:
self._set_drop_target(target)
else:
self._clear_drop_target()
@staticmethod
def _is_own_branch(dragged, item) -> bool:
"""True if ``item`` is the dragged row or inside its subtree."""
while item is not None:
if item is dragged:
return True
item = item.parent()
return False
def _set_drop_target(self, item):
"""Highlight the row a drop would land on (cleared on the way
out). Foreground is set too so the name stays legible on the highlight."""
if item is self._drop_target:
return
self._clear_drop_target()
self._drop_target = item
self._drop_target_bg = item.background(0)
self._drop_target_fg = item.foreground(0)
item.setBackground(0, QBrush(self.palette().highlight().color()))
item.setForeground(0, QBrush(self.palette().highlightedText().color()))
def _clear_drop_target(self):
if self._drop_target is not None:
self._drop_target.setBackground(0, self._drop_target_bg)
self._drop_target.setForeground(0, self._drop_target_fg)
self._drop_target = None
# ---- 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 rows scroll under it, so keep the
# drop-target highlight on the row now beneath it.
if (self._autoscroll_pos is not None
and self._drag_target_kind is not None):
self._highlight_drop_target(self._autoscroll_pos,
self._drag_target_kind)
def _stop_autoscroll(self):
self._autoscroll_dir = 0
self._autoscroll_pos = None
self._drag_target_kind = None
self._autoscroll_timer.stop()
def dropEvent(self, event):
self._clear_drop_target()
self._stop_autoscroll()
target = self.itemAt(event.position().toPoint())
if event.source() is self:
item = self.currentItem()
pid = item.data(0, PID_ROLE) if item else ""
if not pid:
return
new_parent = ""
if target is not None and target.data(0, KIND_ROLE) == "folder":
new_parent = target.data(0, PID_ROLE)
self._manager.move_playlist(pid, new_parent)
event.acceptProposedAction()
return
payload = parse_tracks_mime(event.mimeData())
if payload and target and target.data(0, KIND_ROLE) == "playlist":
added = add_tracks_with_dup_check(
self._manager, self, target.data(0, PID_ROLE),
payload.get("track_ids", []))
if added:
self._flash_item(target)
event.acceptProposedAction()
def _flash_item(self, item):
"""Briefly pulse a playlist row's highlight to confirm a drop landed.
Adding tracks emits playlist_content_changed (not playlists_changed),
so the tree isn't rebuilt and the item reference stays valid.
"""
highlight = QBrush(self.palette().highlight().color())
original = item.background(0)
def on():
item.setBackground(0, highlight)
def off():
item.setBackground(0, original)
on()
QTimer.singleShot(150, off)
QTimer.singleShot(300, on)
QTimer.singleShot(450, off)