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:
2026-06-26 21:55:38 -04:00
parent dcbd288b31
commit d1db60c057
6 changed files with 235 additions and 9 deletions
+23 -3
View File
@@ -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)
+30 -4
View File
@@ -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:
+33
View File
@@ -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: