Cut removes immediately; scrollbar click-to-jump
Ctrl-X now removes the track from the playlist right away (so it visibly leaves) and a paste re-inserts it at the target — replacing the earlier deferred-move design (dropped move_tracks_between_playlists and the MIME cut flag). A cut from the library is still just a copy. Separately, a left-click on any scrollbar trough now jumps to that spot instead of paging, via an app-wide ClickToJumpScrollStyle proxy. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -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())
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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]
|
||||
|
||||
Reference in New Issue
Block a user