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>
This commit is contained in:
2026-06-26 21:55:38 -04:00
parent 1dd128b7f4
commit 2af0d91a76
6 changed files with 235 additions and 9 deletions

View File

@ -69,7 +69,7 @@ if it's missing, you'll be asked to pick a font on first run.
## Keys
Space play/pause · ←/→ previous/next · Ctrl+B column browser ·
Ctrl+I get info · Ctrl+, preferences · Ctrl+C/Ctrl+V copy/paste tracks ·
Ctrl+I get info · Ctrl+, preferences · Ctrl+C/Ctrl+X/Ctrl+V copy/cut/paste tracks ·
double-click sidebar art for a big art window
## App icon

View File

@ -13,7 +13,7 @@
- [ ] we need to be able to make smart playlists. They should also be properly imported from itunes. We need all the fields that itunes 12 is able to work with when creating a smart playlist. This is a complicated feature! We also need to be able to edit the criteria of a smart playlist once it is created. Smart playlists should have a little Rotated Floral Heart Bullet (❧) to the left of their title in the playlist list on the left.
- [ ] the ability to not just copy/paste a song but also to ctrl-x cut a song from one space and ctrl-v paste it somewhere else. AND when pasting a song(s) it should paste above the currently selected track. That's where it pastes to, not to the end of the playlist.
- [x] the ability to not just copy/paste a song but also to ctrl-x cut a song from one space and ctrl-v paste it somewhere else. AND when pasting a song(s) it should paste above the currently selected track. That's where it pastes to, not to the end of the playlist.
- [ ] if the user is dragging a track around and they drag above the top of the track list, the track list should scroll up. Same if they hover the track below the track list at the bottom it scrolls down. The track list should scroll in the direction the user is hovering the track. This helps the user move a track to a place in a playlist that isn't currently seen.
- [ ] the app should be a little more agressive about comandeering the play/pause button from other media playing on the system. If I was playing a youtube video and that was associated with play/pause, but it's been hours since I played it, and I started playing music in lintunes, I would expect the play/pause button to pause the lintunes music when I hit it— not also start playing a youtube video, you know what I mean?

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)

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:

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:

147
tests/test_round12.py Normal file
View File

@ -0,0 +1,147 @@
"""Round 12: cut (Ctrl-X) + paste-above-selection.
- The clipboard MIME carries a `cut` flag; a cut from a real playlist is a
*move* on paste, a copy (or a cut from the library) just adds.
- Paste inserts *above the currently selected track*, not at the end.
- A cross-playlist move is one undo step (move_tracks_between_playlists).
"""
from PyQt6.QtWidgets import QApplication
from lintunes.models import Library, Track
from lintunes.library_manager import LibraryManager
from lintunes.gui.track_table import (
TrackTableView, make_tracks_mime, parse_tracks_mime)
from lintunes.gui.playlist_view import PlaylistView
def _manager(tmp_path, n=4):
library = Library(tracks={i: Track(track_id=i, name=f"T{i}")
for i in range(1, n + 1)})
return LibraryManager(library, tmp_path)
def _select_source_row(table, source_row):
"""Select the visual row backing a given source-model row (sort-proof)."""
proxy_index = table.proxy.mapFromSource(table.model_.index(source_row, 0))
table.selectRow(proxy_index.row())
# --------------------------------------------------------------------------
# MIME + table helpers
# --------------------------------------------------------------------------
class TestMimeAndHelpers:
def test_cut_flag_round_trips(self):
payload = parse_tracks_mime(
make_tracks_mime([1, 2], "PID", [0, 1], cut=True))
assert payload["cut"] is True
assert payload["track_ids"] == [1, 2]
assert payload["source_playlist"] == "PID"
assert payload["rows"] == [0, 1]
def test_copy_defaults_to_not_cut(self):
payload = parse_tracks_mime(make_tracks_mime([1]))
assert payload["cut"] is False
def test_paste_anchor_row(self, qapp, tmp_path):
manager = _manager(tmp_path)
table = TrackTableView(playlist_mode=True)
table.set_tracks([manager.library.tracks[i] for i in (1, 2, 3)])
assert table.paste_anchor_row() is None # nothing selected
_select_source_row(table, 1)
assert table.paste_anchor_row() == 1
def test_cut_selection_marks_clipboard(self, qapp, tmp_path):
manager = _manager(tmp_path)
table = TrackTableView(playlist_mode=True)
table.set_source_playlist("PID")
table.set_tracks([manager.library.tracks[i] for i in (1, 2, 3)])
_select_source_row(table, 0)
table.cut_selection()
payload = parse_tracks_mime(QApplication.clipboard().mimeData())
assert payload["cut"] is True
assert payload["track_ids"] == [1]
# --------------------------------------------------------------------------
# manager: single-undo cross-playlist move
# --------------------------------------------------------------------------
class TestMoveBetweenPlaylists:
def test_move_and_single_undo(self, tmp_path):
manager = _manager(tmp_path)
a = manager.create_playlist("A")
b = manager.create_playlist("B")
manager.add_tracks_to_playlist(a.persistent_id, [1, 2, 3])
manager.add_tracks_to_playlist(b.persistent_id, [4])
manager.move_tracks_between_playlists(
a.persistent_id, [0, 1], b.persistent_id, 0)
assert a.track_ids == [3]
assert b.track_ids == [1, 2, 4]
manager.undo_stack.undo() # one step reverts both playlists
assert a.track_ids == [1, 2, 3]
assert b.track_ids == [4]
def test_same_playlist_is_a_noop(self, tmp_path):
manager = _manager(tmp_path)
a = manager.create_playlist("A")
manager.add_tracks_to_playlist(a.persistent_id, [1, 2, 3])
manager.move_tracks_between_playlists(
a.persistent_id, [0], a.persistent_id, 2)
assert a.track_ids == [1, 2, 3]
# --------------------------------------------------------------------------
# PlaylistView paste integration
# --------------------------------------------------------------------------
class TestPasteIntegration:
def test_copy_paste_inserts_above_selection(self, qapp, tmp_path):
manager = _manager(tmp_path)
b = manager.create_playlist("B")
manager.add_tracks_to_playlist(b.persistent_id, [4])
view = PlaylistView(manager)
view.show_playlist(b)
QApplication.clipboard().setMimeData(make_tracks_mime([1, 2]))
_select_source_row(view.table, 0) # above track 4
view._on_paste()
assert b.track_ids == [1, 2, 4]
def test_cut_paste_moves_across_playlists(self, qapp, tmp_path):
manager = _manager(tmp_path)
a = manager.create_playlist("A")
b = manager.create_playlist("B")
manager.add_tracks_to_playlist(a.persistent_id, [1, 2, 3])
manager.add_tracks_to_playlist(b.persistent_id, [4])
view = PlaylistView(manager)
view.show_playlist(b)
QApplication.clipboard().setMimeData(
make_tracks_mime([1, 2], a.persistent_id, [0, 1], cut=True))
_select_source_row(view.table, 0) # above track 4
view._on_paste()
assert b.track_ids == [1, 2, 4]
assert a.track_ids == [3]
# The move consumes the clipboard so it can't be pasted again.
assert parse_tracks_mime(QApplication.clipboard().mimeData()) is None
def test_cut_paste_same_playlist_reorders(self, qapp, tmp_path):
manager = _manager(tmp_path)
a = manager.create_playlist("A")
manager.add_tracks_to_playlist(a.persistent_id, [1, 2, 3, 4])
view = PlaylistView(manager)
view.show_playlist(a)
QApplication.clipboard().setMimeData(
make_tracks_mime([1], a.persistent_id, [0], cut=True))
_select_source_row(view.table, 2) # above track 3
view._on_paste()
assert a.track_ids == [2, 1, 3, 4]