diff --git a/lintunes/gui/playlist_view.py b/lintunes/gui/playlist_view.py index bf1c772..bed7007 100644 --- a/lintunes/gui/playlist_view.py +++ b/lintunes/gui/playlist_view.py @@ -40,6 +40,7 @@ class PlaylistView(QWidget): self.table.tracks_dropped.connect(self._on_tracks_dropped) self.table.files_dropped.connect(self._on_files_dropped) self.table.remove_requested.connect(self._on_remove) + self.table.cut_requested.connect(self._on_cut) self.table.paste_requested.connect(self._on_paste) manager.playlist_content_changed.connect(self._on_content_changed) @@ -133,6 +134,13 @@ class PlaylistView(QWidget): self._manager.remove_tracks_from_playlist( self._playlist.persistent_id, rows) + def _on_cut(self, rows): + # Cut removes immediately (so the track visibly leaves); the ids are + # already on the clipboard for a later paste to re-insert. + if self._playlist is not None: + self._manager.remove_tracks_from_playlist( + self._playlist.persistent_id, rows) + def _on_paste(self): if self._playlist is None: return @@ -140,24 +148,8 @@ class PlaylistView(QWidget): 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: + if track_ids: + # Paste above the selected track (None -> append). add_tracks_with_dup_check( - self._manager, self, pid, track_ids, position) + self._manager, self, self._playlist.persistent_id, + track_ids, self.table.paste_anchor_row()) diff --git a/lintunes/gui/track_table.py b/lintunes/gui/track_table.py index 9b39da4..a46bf35 100644 --- a/lintunes/gui/track_table.py +++ b/lintunes/gui/track_table.py @@ -2,7 +2,7 @@ import json import os from PyQt6.QtWidgets import ( - QTableView, QAbstractItemView, QMenu, QApplication, + QTableView, QAbstractItemView, QMenu, QApplication, QProxyStyle, QStyle, ) from PyQt6.QtCore import ( Qt, QAbstractTableModel, QModelIndex, QSortFilterProxyModel, pyqtSignal, @@ -28,6 +28,17 @@ SORT_ROLE = Qt.ItemDataRole.UserRole AUTOSCROLL_MARGIN = 28 +class ClickToJumpScrollStyle(QProxyStyle): + """Makes a left-click on the scrollbar trough jump the thumb straight to + that spot (absolute position) instead of paging toward it a step at a time. + Wraps the default app style and only overrides the one hint.""" + + def styleHint(self, hint, option=None, widget=None, returnData=None): + if hint == QStyle.StyleHint.SH_ScrollBar_LeftClickAbsolutePosition: + return 1 + return super().styleHint(hint, option, widget, returnData) + + def autoscroll_direction(y: int, height: int, accepted: bool, margin: int = AUTOSCROLL_MARGIN) -> int: """-1 to scroll up, +1 down, 0 to stop: based on how close the drag is to @@ -153,10 +164,10 @@ def speaker_pixmap(playing: bool, color: QColor) -> QPixmap: def make_tracks_mime(track_ids: list[int], source_playlist: str = "", - rows: list[int] | None = None, cut: bool = False) -> QMimeData: + rows: list[int] | None = None) -> QMimeData: mime = QMimeData() payload = {"track_ids": track_ids, "source_playlist": source_playlist, - "rows": rows or [], "cut": cut} + "rows": rows or []} mime.setData(TRACKS_MIME, json.dumps(payload).encode()) return mime @@ -321,6 +332,7 @@ class TrackTableView(QTableView): tracks_dropped = pyqtSignal(list, object) # track ids, insert row or None files_dropped = pyqtSignal(list, object) # paths, insert row or None remove_requested = pyqtSignal(list) # manual rows + cut_requested = pyqtSignal(list) # source rows to remove now paste_requested = pyqtSignal() info_requested = pyqtSignal(list) # selected track ids tracks_changed = pyqtSignal() # displayed track set changed @@ -487,20 +499,24 @@ 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(), cut=cut) - QApplication.clipboard().setMimeData(mime) + QApplication.clipboard().setMimeData(make_tracks_mime( + ids, self._source_playlist_id, self.selected_source_rows())) + + def cut_selection(self): + """Copy the selection, then — in a playlist — remove it immediately so + it visibly leaves; a paste re-inserts it at the target. From the + library there's nothing to remove, so this is just a copy.""" + ids = self.selected_track_ids() + if not ids: + return + rows = self.selected_source_rows() + QApplication.clipboard().setMimeData( + make_tracks_mime(ids, self._source_playlist_id, rows)) + if self._playlist_mode and self._source_playlist_id: + self.cut_requested.emit(rows) def paste_anchor_row(self): """Source-model row to paste *above* — the first selected track — or diff --git a/lintunes/library_manager.py b/lintunes/library_manager.py index a9acd32..40489ac 100644 --- a/lintunes/library_manager.py +++ b/lintunes/library_manager.py @@ -234,39 +234,6 @@ 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/lintunes/main.py b/lintunes/main.py index 0eaab5c..1b1c293 100644 --- a/lintunes/main.py +++ b/lintunes/main.py @@ -103,6 +103,7 @@ def run_gui(data_dir: Path, files: list[Path], qt_args: list[str] | None = None) from lintunes.preferences import Preferences from lintunes.lastfm import LastFm from lintunes.gui.main_window import MainWindow + from lintunes.gui.track_table import ClickToJumpScrollStyle from lintunes.mpris import setup_mpris resolve_conflicts(data_dir) @@ -111,6 +112,10 @@ def run_gui(data_dir: Path, files: list[Path], qt_args: list[str] | None = None) app = QApplication([sys.argv[0]] + (qt_args or [])) app.setApplicationName("LinTunes") app.setDesktopFileName("lintunes") + # Click anywhere on a scrollbar trough to jump there, not page a step at a + # time. Set app-wide (before apply_theme's stylesheet wraps the style) so + # the hint is honored everywhere, including the track list and sidebar. + app.setStyle(ClickToJumpScrollStyle()) prefs = Preferences(data_dir) theme.apply_theme(app, prefs) diff --git a/tests/test_round12.py b/tests/test_round12.py index 39d6961..1f4153b 100644 --- a/tests/test_round12.py +++ b/tests/test_round12.py @@ -1,17 +1,18 @@ -"""Round 12: cut (Ctrl-X) + paste-above-selection. +"""Round 12: cut (Ctrl-X), paste-above-selection, scrollbar click-to-jump. -- 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. +- Cut copies the selection to the clipboard and, in a playlist, removes it + *immediately* (so it visibly leaves); a paste re-inserts it at the target. + A cut from the library has nothing to remove, so it's just a copy. - Paste inserts *above the currently selected track*, not at the end. -- A cross-playlist move is one undo step (move_tracks_between_playlists). +- Clicking the scrollbar trough jumps there instead of paging a step. """ -from PyQt6.QtWidgets import QApplication +from PyQt6.QtWidgets import QApplication, QStyle 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) + TrackTableView, make_tracks_mime, parse_tracks_mime, ClickToJumpScrollStyle) from lintunes.gui.playlist_view import PlaylistView @@ -28,22 +29,10 @@ def _select_source_row(table, source_row): # -------------------------------------------------------------------------- -# MIME + table helpers +# clipboard helpers + cut signalling # -------------------------------------------------------------------------- -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 - +class TestCutSelection: def test_paste_anchor_row(self, qapp, tmp_path): manager = _manager(tmp_path) table = TrackTableView(playlist_mode=True) @@ -52,51 +41,51 @@ class TestMimeAndHelpers: _select_source_row(table, 1) assert table.paste_anchor_row() == 1 - def test_cut_selection_marks_clipboard(self, qapp, tmp_path): + def test_cut_in_playlist_copies_and_requests_removal(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)]) + removed = [] + table.cut_requested.connect(removed.append) + _select_source_row(table, 0) table.cut_selection() + + assert removed == [[0]] # asked the view to remove row 0 immediately + payload = parse_tracks_mime(QApplication.clipboard().mimeData()) + assert payload["track_ids"] == [1] + + def test_cut_in_library_is_just_a_copy(self, qapp, tmp_path): + manager = _manager(tmp_path) + table = TrackTableView(playlist_mode=False) # library + table.set_tracks([manager.library.tracks[i] for i in (1, 2, 3)]) + removed = [] + table.cut_requested.connect(removed.append) + + _select_source_row(table, 0) + table.cut_selection() + + assert removed == [] # nothing to remove from the library payload = parse_tracks_mime(QApplication.clipboard().mimeData()) - assert payload["cut"] is True assert payload["track_ids"] == [1] # -------------------------------------------------------------------------- -# manager: single-undo cross-playlist move +# scrollbar click-to-jump # -------------------------------------------------------------------------- -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] +class TestScrollbarJump: + def test_style_hint_enables_absolute_position(self, qapp): + """Applied app-wide in main.run_gui via app.setStyle(); here we just + confirm the proxy flips the left-click-jump hint on.""" + style = ClickToJumpScrollStyle() + assert style.styleHint( + QStyle.StyleHint.SH_ScrollBar_LeftClickAbsolutePosition) == 1 # -------------------------------------------------------------------------- -# PlaylistView paste integration +# PlaylistView integration # -------------------------------------------------------------------------- class TestPasteIntegration: @@ -113,24 +102,23 @@ class TestPasteIntegration: assert b.track_ids == [1, 2, 4] - def test_cut_paste_moves_across_playlists(self, qapp, tmp_path): + def test_cut_removes_now_then_paste_reinserts_elsewhere(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)) + view.show_playlist(a) + _select_source_row(view.table, 0) # track 1 + view.table.cut_selection() + assert a.track_ids == [2, 3] # gone immediately + + view.show_playlist(b) _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 + assert b.track_ids == [1, 4] def test_cut_paste_same_playlist_reorders(self, qapp, tmp_path): manager = _manager(tmp_path) @@ -139,9 +127,10 @@ class TestPasteIntegration: 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() + _select_source_row(view.table, 0) # track 1 + view.table.cut_selection() + assert a.track_ids == [2, 3, 4] + _select_source_row(view.table, 1) # above track 3 + view._on_paste() assert a.track_ids == [2, 1, 3, 4]