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.
This commit is contained in:
2026-06-26 21:58:30 -04:00
parent d1db60c057
commit 29d997d205
3 changed files with 132 additions and 4 deletions
+60 -3
View File
@@ -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: