v0.1.5: sidebar playlist tree auto-scrolls when dragging near its edges

This commit is contained in:
2026-07-17 13:05:52 -04:00
parent 663d5e5a08
commit 2820e6df8a
4 changed files with 164 additions and 7 deletions

View File

@ -3,6 +3,25 @@
Legend: `[ ]` todo · `[~]` in progress · `[x]` done.
When a round closes, move its finished items to `tasks-done.md`.
## Round 24 — sidebar drag auto-scroll (v0.1.5)
Tests in `tests/test_round24.py`. Fix round → patch bump **0.1.5**.
Dragging a playlist near the top/bottom edge of the sidebar tree scrolled
nothing: `PlaylistTree.dragMoveEvent` fully overrides the base implementation,
so Qt's built-in autoscroll (in `QAbstractItemView.dragMoveEvent`) never ran —
the same root cause Round 13 fixed in the track table. Ported that machinery
(`autoscroll_direction` shared from `track_table`, 40 ms timer, one unit per
tick) into `PlaylistTree`. Playlist moves keep the scroll live at the edge
even over non-folder rows (the tree always accepts an internal move
somewhere); track drags gate on hovering a real playlist row, matching the
drop-target highlight, and the highlight re-tracks the row under a held-still
cursor as it scrolls.
- [x] `lintunes/gui/sidebar.py``_update_autoscroll` / `_autoscroll_tick` /
`_stop_autoscroll` wired into `dragMoveEvent`, `dragLeaveEvent`,
`dropEvent`.
## Round 23 — playback-control provenance log (v0.1.4)
Tests in `tests/test_round23.py`. Diagnostic round → patch bump **0.1.4**.

View File

@ -1,3 +1,3 @@
"""LinTunes — iTunes-style music library manager and player for Linux."""
__version__ = "0.1.4"
__version__ = "0.1.5"

View File

@ -2,12 +2,14 @@ from PyQt6.QtWidgets import (
QWidget, QVBoxLayout, QPushButton, QTreeWidget, QTreeWidgetItem, QMenu,
QInputDialog, QMessageBox, QAbstractItemView,
)
from PyQt6.QtCore import Qt, QTimer, pyqtSignal
from PyQt6.QtCore import Qt, QTimer, QPoint, pyqtSignal
from PyQt6.QtGui import QBrush, QPainter, QPixmap
from lintunes.models import PlaylistType
from lintunes import tagging
from lintunes.gui.track_table import parse_tracks_mime, TRACKS_MIME
from lintunes.gui.track_table import (
parse_tracks_mime, TRACKS_MIME, autoscroll_direction,
)
from lintunes.gui.playlist_ops import add_tracks_with_dup_check
from lintunes.gui.art_window import ArtWindow
from lintunes.gui import drag_ghost
@ -166,6 +168,15 @@ class PlaylistTree(QTreeWidget):
self.currentItemChanged.connect(self._on_selection_changed)
self.itemChanged.connect(self._on_item_renamed)
# Edge auto-scroll during a drag (Qt's built-in autoscroll never runs
# because our dragMoveEvent accepts the event itself). Mirrors
# track_table's Round 13 machinery.
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)
manager.playlists_changed.connect(self.rebuild)
self.rebuild()
@ -377,31 +388,41 @@ class PlaylistTree(QTreeWidget):
event.ignore()
def dragMoveEvent(self, event):
drag_ghost.move(self.viewport().mapToGlobal(event.position().toPoint()))
pos = event.position().toPoint()
drag_ghost.move(self.viewport().mapToGlobal(pos))
if event.source() is self:
# Moving a playlist into/out of folders
# Moving a playlist into/out of folders. The tree accepts an
# internal move somewhere no matter what (empty area = top level),
# so edge auto-scroll stays live even over non-folder rows —
# otherwise scrolling down to a distant folder would be nearly
# impossible past the playlists in between.
self._clear_drop_target()
target = self.itemAt(event.position().toPoint())
target = self.itemAt(pos)
kind = target.data(0, KIND_ROLE) if target else ""
if kind in ("folder", "") or target is None:
event.acceptProposedAction()
else:
event.ignore()
self._update_autoscroll(pos, accepted=True)
return
if not event.mimeData().hasFormat(TRACKS_MIME):
self._clear_drop_target()
self._update_autoscroll(pos, accepted=False)
event.ignore()
return
target = self.itemAt(event.position().toPoint())
target = self.itemAt(pos)
if target and target.data(0, KIND_ROLE) == "playlist":
self._set_drop_target(target)
event.acceptProposedAction()
self._update_autoscroll(pos, accepted=True)
else:
self._clear_drop_target()
event.ignore()
self._update_autoscroll(pos, accepted=False)
def dragLeaveEvent(self, event):
self._clear_drop_target()
self._stop_autoscroll()
super().dragLeaveEvent(event)
def _set_drop_target(self, item):
@ -422,8 +443,43 @@ class PlaylistTree(QTreeWidget):
self._drop_target.setForeground(0, self._drop_target_fg)
self._drop_target = None
# ---- 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 rows scroll under it, so keep the
# drop-target highlight on the row now beneath it (track drags only;
# internal playlist moves never set a drop target).
if self._autoscroll_pos is not None and self._drop_target is not None:
target = self.itemAt(self._autoscroll_pos)
if target and target.data(0, KIND_ROLE) == "playlist":
self._set_drop_target(target)
else:
self._clear_drop_target()
def _stop_autoscroll(self):
self._autoscroll_dir = 0
self._autoscroll_pos = None
self._autoscroll_timer.stop()
def dropEvent(self, event):
self._clear_drop_target()
self._stop_autoscroll()
target = self.itemAt(event.position().toPoint())
if event.source() is self:
item = self.currentItem()

82
tests/test_round24.py Normal file
View File

@ -0,0 +1,82 @@
"""Round 24: sidebar drag auto-scroll.
Dragging a playlist (or tracks) near the top/bottom edge of the sidebar's
playlist tree scrolls the tree so off-screen rows can be reached. Same root
cause and fix as the track table's Round 13: our dragMoveEvent accepts the
event itself, so Qt's built-in autoscroll never runs. The direction helper
(autoscroll_direction) is shared with the table and already covered by
test_round13.
"""
from PyQt6.QtCore import QPoint
from lintunes.models import Library, Track
from lintunes.library_manager import LibraryManager
from lintunes.gui.sidebar import PlaylistTree
def _tree(qapp, tmp_path, playlists=0):
library = Library(tracks={1: Track(track_id=1, name="One")})
manager = LibraryManager(library, tmp_path)
tree = PlaylistTree(manager)
for i in range(playlists):
manager.create_playlist(f"playlist {i}")
# Small viewport: enough rows that the scrollbar gets a real range (the
# view re-derives the range itself, so a manual setRange doesn't stick;
# show() forces the polish/layout pass that computes it).
tree.resize(200, 100)
tree.show()
return tree
class TestAutoscrollTick:
def test_tick_scrolls_by_direction(self, qapp, tmp_path):
tree = _tree(qapp, tmp_path, playlists=60)
bar = tree.verticalScrollBar()
assert bar.maximum() > 20 # real range from the rows
bar.setValue(10)
tree._autoscroll_dir = 1
tree._autoscroll_tick()
assert bar.value() == 11
tree._autoscroll_dir = -1
tree._autoscroll_tick()
assert bar.value() == 10
def test_tick_stops_at_the_end(self, qapp, tmp_path):
tree = _tree(qapp, tmp_path, playlists=60)
bar = tree.verticalScrollBar()
bar.setValue(bar.maximum()) # already at the bottom
tree._autoscroll_dir = 1
tree._autoscroll_timer.start()
tree._autoscroll_tick()
assert bar.value() == bar.maximum()
assert not tree._autoscroll_timer.isActive()
class TestUpdateAutoscroll:
def test_edge_starts_timer(self, qapp, tmp_path):
tree = _tree(qapp, tmp_path)
tree.resize(200, 300)
tree._update_autoscroll(QPoint(10, 5), accepted=True)
assert tree._autoscroll_dir == -1
assert tree._autoscroll_timer.isActive()
def test_rejected_drop_keeps_timer_off(self, qapp, tmp_path):
tree = _tree(qapp, tmp_path)
tree.resize(200, 300)
tree._update_autoscroll(QPoint(10, 5), accepted=False)
assert tree._autoscroll_dir == 0
assert not tree._autoscroll_timer.isActive()
def test_stop_autoscroll_resets(self, qapp, tmp_path):
tree = _tree(qapp, tmp_path)
tree._autoscroll_dir = 1
tree._autoscroll_timer.start()
tree._stop_autoscroll()
assert tree._autoscroll_dir == 0
assert tree._autoscroll_pos is None
assert not tree._autoscroll_timer.isActive()