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

@ -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())

View File

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

View File

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

View File

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

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]