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.
This commit is contained in:
@@ -137,7 +137,27 @@ class PlaylistView(QWidget):
|
||||
if self._playlist is None:
|
||||
return
|
||||
payload = parse_tracks_mime(QApplication.clipboard().mimeData())
|
||||
if payload:
|
||||
if not payload:
|
||||
return
|
||||
track_ids = payload.get("track_ids", [])
|
||||
if not track_ids:
|
||||
return
|
||||
pid = self._playlist.persistent_id
|
||||
# Paste above the selected track (None -> append).
|
||||
position = self.table.paste_anchor_row()
|
||||
src_pid = payload.get("source_playlist", "")
|
||||
|
||||
# A cut from a real playlist is a *move* (no duplicate prompt); a copy
|
||||
# — or a cut from the library, which has nothing to remove — just adds.
|
||||
if payload.get("cut") and src_pid:
|
||||
if src_pid == pid:
|
||||
dest = position if position is not None else len(self._playlist.track_ids)
|
||||
self._manager.move_tracks_in_playlist(
|
||||
pid, payload.get("rows", []), dest)
|
||||
else:
|
||||
self._manager.move_tracks_between_playlists(
|
||||
src_pid, payload.get("rows", []), pid, position)
|
||||
QApplication.clipboard().clear() # a move pastes once
|
||||
else:
|
||||
add_tracks_with_dup_check(
|
||||
self._manager, self, self._playlist.persistent_id,
|
||||
payload.get("track_ids", []))
|
||||
self._manager, self, pid, track_ids, position)
|
||||
|
||||
@@ -136,16 +136,16 @@ def speaker_pixmap(playing: bool, color: QColor) -> QPixmap:
|
||||
|
||||
|
||||
def make_tracks_mime(track_ids: list[int], source_playlist: str = "",
|
||||
rows: list[int] | None = None) -> QMimeData:
|
||||
rows: list[int] | None = None, cut: bool = False) -> QMimeData:
|
||||
mime = QMimeData()
|
||||
payload = {"track_ids": track_ids, "source_playlist": source_playlist,
|
||||
"rows": rows or []}
|
||||
"rows": rows or [], "cut": cut}
|
||||
mime.setData(TRACKS_MIME, json.dumps(payload).encode())
|
||||
return mime
|
||||
|
||||
|
||||
def parse_tracks_mime(mime: QMimeData) -> dict | None:
|
||||
if not mime.hasFormat(TRACKS_MIME):
|
||||
if mime is None or not mime.hasFormat(TRACKS_MIME):
|
||||
return None
|
||||
try:
|
||||
return json.loads(bytes(mime.data(TRACKS_MIME)).decode())
|
||||
@@ -360,6 +360,9 @@ class TrackTableView(QTableView):
|
||||
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)
|
||||
@@ -459,13 +462,27 @@ class TrackTableView(QTableView):
|
||||
# ---- 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())
|
||||
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):
|
||||
@@ -511,6 +528,11 @@ class TrackTableView(QTableView):
|
||||
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
|
||||
@@ -543,6 +565,10 @@ class TrackTableView(QTableView):
|
||||
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:
|
||||
|
||||
@@ -234,6 +234,39 @@ class LibraryManager(QObject):
|
||||
self._set_track_ids(pid, after)
|
||||
self._push_track_ids("Reorder Playlist", pid, before, after)
|
||||
|
||||
def move_tracks_between_playlists(self, src_pid: str, src_rows: list[int],
|
||||
dest_pid: str, dest_position: int | None = None):
|
||||
"""Cut tracks out of one playlist and insert them into another as a
|
||||
single undoable move (Ctrl-X / Ctrl-V across playlists). For the same
|
||||
playlist, use move_tracks_in_playlist instead."""
|
||||
src = self.library.playlists.get(src_pid)
|
||||
dest = self.library.playlists.get(dest_pid)
|
||||
if (not src or not dest or src_pid == dest_pid
|
||||
or dest.playlist_type == PlaylistType.FOLDER):
|
||||
return
|
||||
src_before = list(src.track_ids)
|
||||
dest_before = list(dest.track_ids)
|
||||
rows = sorted(set(r for r in src_rows if 0 <= r < len(src_before)))
|
||||
if not rows:
|
||||
return
|
||||
moving = [src_before[r] for r in rows]
|
||||
src_after = [tid for i, tid in enumerate(src_before) if i not in rows]
|
||||
dest_after = list(dest_before)
|
||||
if dest_position is None or dest_position >= len(dest_after):
|
||||
dest_after.extend(moving)
|
||||
else:
|
||||
dest_after[dest_position:dest_position] = moving
|
||||
|
||||
def apply(src_ids, dest_ids):
|
||||
self._set_track_ids(src_pid, src_ids)
|
||||
self._set_track_ids(dest_pid, dest_ids)
|
||||
|
||||
apply(src_after, dest_after)
|
||||
self.undo_stack.push(Command(
|
||||
"Move to Playlist",
|
||||
undo=lambda: apply(src_before, dest_before),
|
||||
redo=lambda: apply(src_after, dest_after)))
|
||||
|
||||
def _set_track_ids(self, pid: str, ids: list[int]):
|
||||
playlist = self.library.playlists.get(pid)
|
||||
if playlist is None:
|
||||
|
||||
Reference in New Issue
Block a user