diff --git a/README.md b/README.md index 9b79152..0a7ac9f 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/TASKS.md b/TASKS.md index 8d38b06..c76a073 100644 --- a/TASKS.md +++ b/TASKS.md @@ -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? diff --git a/lintunes/gui/playlist_view.py b/lintunes/gui/playlist_view.py index 8e2409e..bf1c772 100644 --- a/lintunes/gui/playlist_view.py +++ b/lintunes/gui/playlist_view.py @@ -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) diff --git a/lintunes/gui/track_table.py b/lintunes/gui/track_table.py index f41dd7f..a6f6c09 100644 --- a/lintunes/gui/track_table.py +++ b/lintunes/gui/track_table.py @@ -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: diff --git a/lintunes/library_manager.py b/lintunes/library_manager.py index 40489ac..a9acd32 100644 --- a/lintunes/library_manager.py +++ b/lintunes/library_manager.py @@ -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: diff --git a/tests/test_round12.py b/tests/test_round12.py new file mode 100644 index 0000000..39d6961 --- /dev/null +++ b/tests/test_round12.py @@ -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]