From 3e43c81302eebf2f3a11374c9418bba21f430330 Mon Sep 17 00:00:00 2001 From: Trav Date: Fri, 26 Jun 2026 21:58:30 -0400 Subject: [PATCH] Add drag auto-scroll near the list edges While a track drag hovers within 28px of the track list's top/bottom edge, the list scrolls that way (40ms timer) so the user can drop onto off-screen rows. Qt's built-in autoscroll never fires because our dragMoveEvent accepts the event itself, so this is driven manually; the drop line refreshes as content scrolls under a stationary cursor. Co-Authored-By: Claude Opus 4.8 --- TASKS.md | 2 +- lintunes/gui/track_table.py | 63 ++++++++++++++++++++++++++++++-- tests/test_round13.py | 71 +++++++++++++++++++++++++++++++++++++ 3 files changed, 132 insertions(+), 4 deletions(-) create mode 100644 tests/test_round13.py diff --git a/TASKS.md b/TASKS.md index c76a073..166bbe6 100644 --- a/TASKS.md +++ b/TASKS.md @@ -14,7 +14,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. - [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. +- [x] 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/track_table.py b/lintunes/gui/track_table.py index a6f6c09..9b39da4 100644 --- a/lintunes/gui/track_table.py +++ b/lintunes/gui/track_table.py @@ -6,7 +6,7 @@ from PyQt6.QtWidgets import ( ) from PyQt6.QtCore import ( Qt, QAbstractTableModel, QModelIndex, QSortFilterProxyModel, pyqtSignal, - QMimeData, QUrl, QPoint, QPointF, QRectF, QMetaType, + QMimeData, QUrl, QPoint, QPointF, QRectF, QMetaType, QTimer, ) from PyQt6.QtGui import ( QDrag, QKeySequence, QShortcut, QPixmap, QPainter, QPen, QPolygonF, QColor, @@ -23,6 +23,23 @@ from lintunes.gui import drag_ghost TRACKS_MIME = "application/x-lintunes-tracks" SORT_ROLE = Qt.ItemDataRole.UserRole +# While dragging a track within this many pixels of the list's top/bottom edge, +# the list auto-scrolls so off-screen rows can be reached. +AUTOSCROLL_MARGIN = 28 + + +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 + the top/bottom edge. Only scrolls over a valid drop target (accepted).""" + if not accepted: + return 0 + if y < margin: + return -1 + if y > height - margin: + return 1 + return 0 + # Manual play-order pseudo-column (playlist views only, always first) INDEX_FIELD = "#" @@ -357,6 +374,14 @@ class TrackTableView(QTableView): self.setDropIndicatorShown(False) self._drop_indicator_y: int | None = None + # Edge auto-scroll during a drag (Qt's built-in autoscroll never runs + # because our dragMoveEvent accepts the event itself). + self._autoscroll_dir = 0 + self._autoscroll_pos: QPoint | None = None + self._autoscroll_timer = QTimer(self) + self._autoscroll_timer.setInterval(40) + self._autoscroll_timer.timeout.connect(self._autoscroll_tick) + QShortcut(QKeySequence.StandardKey.Copy, self, context=Qt.ShortcutContext.WidgetShortcut, activated=self.copy_selection) @@ -679,20 +704,23 @@ class TrackTableView(QTableView): else: # library view is not a drop target for tracks accepted = self._playlist_mode + pos = event.position().toPoint() if accepted: event.acceptProposedAction() - self._set_drop_indicator( - self._indicator_y(event.position().toPoint())) + self._set_drop_indicator(self._indicator_y(pos)) else: event.ignore() self._set_drop_indicator(None) + self._update_autoscroll(pos, accepted) def dragLeaveEvent(self, event): self._set_drop_indicator(None) + self._stop_autoscroll() super().dragLeaveEvent(event) def dropEvent(self, event): self._set_drop_indicator(None) + self._stop_autoscroll() mime = event.mimeData() drop_row = self._drop_row(event.position().toPoint()) if mime.hasUrls(): @@ -751,6 +779,35 @@ class TrackTableView(QTableView): self._drop_indicator_y = y self.viewport().update() + # ---- drag auto-scroll ---- + + def _update_autoscroll(self, pos: QPoint, accepted: bool): + """Start/stop edge auto-scroll based on the drag's position.""" + self._autoscroll_pos = pos + self._autoscroll_dir = autoscroll_direction( + pos.y(), self.viewport().height(), accepted) + if self._autoscroll_dir and not self._autoscroll_timer.isActive(): + self._autoscroll_timer.start() + elif not self._autoscroll_dir and self._autoscroll_timer.isActive(): + self._autoscroll_timer.stop() + + def _autoscroll_tick(self): + bar = self.verticalScrollBar() + before = bar.value() + bar.setValue(before + self._autoscroll_dir) + if bar.value() == before: # reached the top/bottom — nothing more to do + self._autoscroll_timer.stop() + return + # The cursor may be held still while content scrolls under it, so + # refresh the drop line for the rows now beneath it. + if self._autoscroll_pos is not None: + self._set_drop_indicator(self._indicator_y(self._autoscroll_pos)) + + def _stop_autoscroll(self): + self._autoscroll_dir = 0 + self._autoscroll_pos = None + self._autoscroll_timer.stop() + def paintEvent(self, event): super().paintEvent(event) if self._drop_indicator_y is None: diff --git a/tests/test_round13.py b/tests/test_round13.py new file mode 100644 index 0000000..c3ccc1d --- /dev/null +++ b/tests/test_round13.py @@ -0,0 +1,71 @@ +"""Round 13: drag auto-scroll. + +While dragging a track near the top/bottom edge of the list, it scrolls in +that direction so off-screen rows can be reached. Qt's built-in autoscroll +never runs (our dragMoveEvent accepts the event itself), so this is manual. +""" + +from PyQt6.QtCore import QPoint + +from lintunes.gui.track_table import TrackTableView, autoscroll_direction + + +class TestAutoscrollDirection: + def test_top_edge_scrolls_up(self): + assert autoscroll_direction(5, height=200, accepted=True) == -1 + + def test_bottom_edge_scrolls_down(self): + assert autoscroll_direction(195, height=200, accepted=True) == 1 + + def test_middle_does_not_scroll(self): + assert autoscroll_direction(100, height=200, accepted=True) == 0 + + def test_not_a_drop_target_never_scrolls(self): + assert autoscroll_direction(5, height=200, accepted=False) == 0 + assert autoscroll_direction(195, height=200, accepted=False) == 0 + + +class TestAutoscrollTick: + def test_tick_scrolls_by_direction(self, qapp): + view = TrackTableView(playlist_mode=True) + bar = view.verticalScrollBar() + bar.setRange(0, 50) + bar.setValue(10) + + view._autoscroll_dir = 1 + view._autoscroll_tick() + assert bar.value() == 11 + + view._autoscroll_dir = -1 + view._autoscroll_tick() + assert bar.value() == 10 + + def test_tick_stops_at_the_end(self, qapp): + view = TrackTableView(playlist_mode=True) + bar = view.verticalScrollBar() + bar.setRange(0, 50) + bar.setValue(50) # already at the bottom + + view._autoscroll_dir = 1 + view._autoscroll_timer.start() + view._autoscroll_tick() + + assert bar.value() == 50 + assert not view._autoscroll_timer.isActive() + + +class TestUpdateAutoscroll: + def test_rejected_drop_keeps_timer_off(self, qapp): + view = TrackTableView(playlist_mode=True) + view._update_autoscroll(QPoint(10, 5), accepted=False) + assert view._autoscroll_dir == 0 + assert not view._autoscroll_timer.isActive() + + def test_stop_autoscroll_resets(self, qapp): + view = TrackTableView(playlist_mode=True) + view._autoscroll_dir = 1 + view._autoscroll_timer.start() + view._stop_autoscroll() + assert view._autoscroll_dir == 0 + assert view._autoscroll_pos is None + assert not view._autoscroll_timer.isActive()