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. Legend: `[ ]` todo · `[~]` in progress · `[x]` done.
When a round closes, move its finished items to `tasks-done.md`. 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) ## Round 23 — playback-control provenance log (v0.1.4)
Tests in `tests/test_round23.py`. Diagnostic round → patch bump **0.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.""" """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, QWidget, QVBoxLayout, QPushButton, QTreeWidget, QTreeWidgetItem, QMenu,
QInputDialog, QMessageBox, QAbstractItemView, 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 PyQt6.QtGui import QBrush, QPainter, QPixmap
from lintunes.models import PlaylistType from lintunes.models import PlaylistType
from lintunes import tagging 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.playlist_ops import add_tracks_with_dup_check
from lintunes.gui.art_window import ArtWindow from lintunes.gui.art_window import ArtWindow
from lintunes.gui import drag_ghost from lintunes.gui import drag_ghost
@ -166,6 +168,15 @@ class PlaylistTree(QTreeWidget):
self.currentItemChanged.connect(self._on_selection_changed) self.currentItemChanged.connect(self._on_selection_changed)
self.itemChanged.connect(self._on_item_renamed) 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) manager.playlists_changed.connect(self.rebuild)
self.rebuild() self.rebuild()
@ -377,31 +388,41 @@ class PlaylistTree(QTreeWidget):
event.ignore() event.ignore()
def dragMoveEvent(self, event): 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: 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() self._clear_drop_target()
target = self.itemAt(event.position().toPoint()) target = self.itemAt(pos)
kind = target.data(0, KIND_ROLE) if target else "" kind = target.data(0, KIND_ROLE) if target else ""
if kind in ("folder", "") or target is None: if kind in ("folder", "") or target is None:
event.acceptProposedAction() event.acceptProposedAction()
else: else:
event.ignore() event.ignore()
self._update_autoscroll(pos, accepted=True)
return return
if not event.mimeData().hasFormat(TRACKS_MIME): if not event.mimeData().hasFormat(TRACKS_MIME):
self._clear_drop_target() self._clear_drop_target()
self._update_autoscroll(pos, accepted=False)
event.ignore() event.ignore()
return return
target = self.itemAt(event.position().toPoint()) target = self.itemAt(pos)
if target and target.data(0, KIND_ROLE) == "playlist": if target and target.data(0, KIND_ROLE) == "playlist":
self._set_drop_target(target) self._set_drop_target(target)
event.acceptProposedAction() event.acceptProposedAction()
self._update_autoscroll(pos, accepted=True)
else: else:
self._clear_drop_target() self._clear_drop_target()
event.ignore() event.ignore()
self._update_autoscroll(pos, accepted=False)
def dragLeaveEvent(self, event): def dragLeaveEvent(self, event):
self._clear_drop_target() self._clear_drop_target()
self._stop_autoscroll()
super().dragLeaveEvent(event) super().dragLeaveEvent(event)
def _set_drop_target(self, item): 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.setForeground(0, self._drop_target_fg)
self._drop_target = None 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): def dropEvent(self, event):
self._clear_drop_target() self._clear_drop_target()
self._stop_autoscroll()
target = self.itemAt(event.position().toPoint()) target = self.itemAt(event.position().toPoint())
if event.source() is self: if event.source() is self:
item = self.currentItem() 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()