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:
2026-06-26 22:12:27 -04:00
parent 3e43c81302
commit b6d1805962
5 changed files with 98 additions and 129 deletions

View File

@ -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]